Tecnología · GUÍA PRÁCTICA
¿El volumen Docker está “en uso” y no ves ningún contenedor? Crea un registro de propietarios antes de limpiar
No empieces con --force, prune ni reiniciando el daemon. Vincula el error al Docker context exacto, inventaría todos los propietarios de montajes —incluidos contenedores detenidos y servicios Swarm— y conserva primero la evidencia.

Un error de volumen en uso es una cuestión de propiedad antes que un problema de limpieza. La referencia puede pertenecer a un contenedor detenido o solo creado, un proyecto Compose, un servicio Swarm, un plugin, un Engine context remoto u otro ámbito que no cubrió la primera lista.
Esta guía crea un registro de propietarios de solo lectura ligado al Docker context y endpoint exactos. Conserva metadatos del volumen, todos los estados de contenedor, pistas Compose y Swarm, fallos de lectura y límites sin resolver en un JSON con hash, sin detener, eliminar, ejecutar prune, reiniciar, actualizar ni desplegar objetos.
El registro no autoriza el borrado. Ayuda al propietario de la aplicación, clúster o almacenamiento a decidir el siguiente paso revisado y trata falta de visibilidad, scope no local, lecturas fallidas, ausencia de autoridad manager y copias inciertas como razones para detenerse.
El error señala propiedad; no ordena limpiar
La documentación actual de Docker fija un límite claro: no se puede eliminar un volumen que esté en uso por un contenedor. Sin embargo, “en uso” abarca más que “un contenedor en ejecución que puedo ver”. Un contenedor detenido o solamente creado sigue existiendo y puede arrancar de nuevo, por lo que su montaje continúa siendo una referencia activa. La vista de volúmenes de Docker Desktop también distingue entre detener y eliminar.
Por eso una comprobación rápida con `docker ps` no basta. De forma predeterminada solo muestra contenedores en ejecución. La vista de todos los contenedores puede revelar estados exited, created, paused, restarting, removing o dead. Compose añade propiedad mediante etiquetas y contenedores conservados o huérfanos; Swarm añade servicios y tareas. Además, la CLI puede apuntar a otro Engine por el context activo, `DOCKER_CONTEXT`, `DOCKER_HOST` o `--context`.
Trata el error como una solicitud para identificar al propietario. No lo interpretes como permiso para borrar la referencia, retirar el proyecto, ejecutar prune, reiniciar el daemon o probar una opción force.
Fija el objetivo: context, endpoint, volumen y scope
Antes de recopilar propietarios, anota el nombre exacto del volumen y el Docker context usado por el comando que falló. `docker context show` informa del context seleccionado y `docker context inspect` expone su endpoint. Una lista vacía en un Engine no explica una referencia de volumen en otro.
Inspecciona después el volumen y conserva driver, scope, labels, options, fecha de creación y mountpoint. Un scope o driver no local es una condición de parada, no un atajo. Los volúmenes de plugin o clúster pueden tener propietarios fuera de la lista local, así que un resultado local no demuestra que estén libres.
Registra también las versiones de cliente y servidor. Un problema histórico de Docker CLI mostró que, en una versión concreta con Swarm activo, el comando podía imprimir el nombre aunque el volumen siguiera existiendo. Verifica el estado del objeto de forma independiente en vez de inferir éxito de una línea.
Ejecuta el registro de propietarios de solo lectura
El script Node incluido recibe un nombre de volumen y un archivo de salida. Solo ejecuta operaciones de lectura: context, version, info, volume inspect, container list/inspect y service list/inspect. Nunca llama a stop, rm, down, prune, restart, update ni deploy. Escribe un JSON con endpoint, volumen, todos los montajes coincidentes, etiquetas Compose y Swarm, servicios, fallos y límites sin resolver.
Ejecútalo desde el mismo shell y entorno que produjo el error, porque las variables pueden cambiar el context. El archivo se crea de forma exclusiva y no sobrescribe capturas anteriores. El script imprime el SHA-256 del JSON final; guárdalo con las notas del incidente para detectar cambios posteriores.
Un resultado de cero propietarios solo es significativo cuando `readErrors` y `unresolved` están vacíos. Si falla cualquier lectura o el volumen usa plugin o cluster scope, la fila ausente significa “desconocido”, no “sin propietario”.
```javascript #!/usr/bin/env node import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { writeFileSync } from "node:fs"; function runDocker(args) { const result = spawnSync("docker", args, { encoding: "utf8", shell: false, maxBuffer: 16 * 1024 * 1024, }); return { ok: result.status === 0, status: result.status, stdout: result.stdout.trim(), stderr: result.stderr.trim(), }; } function parseJson(text, fallback) { try { return JSON.parse(text); } catch { return fallback; } } function dockerJson(args, errors, label, fallback = null) { const result = runDocker(args); if (!result.ok) { errors.push({ label, args, status: result.status, error: result.stderr || "docker command failed" }); return fallback; } const parsed = parseJson(result.stdout, Symbol.for("invalid-json")); if (parsed === Symbol.for("invalid-json")) { errors.push({ label, args, status: result.status, error: "docker returned invalid JSON" }); return fallback; } return parsed; } function dockerText(args, errors, label, fallback = "") { const result = runDocker(args); if (!result.ok) { errors.push({ label, args, status: result.status, error: result.stderr || "docker command failed" }); return fallback; } return result.stdout; } function compactContext(context) { return { name: context?.Name ?? null, description: context?.Metadata?.Description ?? null, orchestrator: context?.Metadata?.StackOrchestrator ?? null, dockerEndpoint: context?.Endpoints?.docker?.Host ?? null, skipTlsVerify: context?.Endpoints?.docker?.SkipTLSVerify ?? null, }; } function compactMount(mount) { return { type: mount?.Type ?? null, name: mount?.Name ?? null, source: mount?.Source ?? null, destination: mount?.Destination ?? null, driver: mount?.Driver ?? null, mode: mount?.Mode ?? null, rw: mount?.RW ?? null, propagation: mount?.Propagation ?? null, }; } function compactContainer(container, volumeName) { const labels = container?.Config?.Labels ?? {}; const mounts = (container?.Mounts ?? []) .filter((mount) => mount?.Type === "volume" && (mount?.Name === volumeName || mount?.Source === volumeName)) .map(compactMount); if (mounts.length === 0) return null; return { id: container?.Id ?? null, name: String(container?.Name ?? "").replace(/^\//, "") || null, state: container?.State?.Status ?? null, running: container?.State?.Running ?? null, created: container?.Created ?? null, image: container?.Config?.Image ?? null, mounts, compose: { project: labels["com.docker.compose.project"] ?? null, service: labels["com.docker.compose.service"] ?? null, oneoff: labels["com.docker.compose.oneoff"] ?? null, workingDir: labels["com.docker.compose.project.working_dir"] ?? null, configFiles: labels["com.docker.compose.project.config_files"] ?? null, }, swarm: { serviceName: labels["com.docker.swarm.service.name"] ?? null, serviceId: labels["com.docker.swarm.service.id"] ?? null, taskId: labels["com.docker.swarm.task.id"] ?? null, }, }; } function serviceMountsVolume(service, volumeName) { const mounts = service?.Spec?.TaskTemplate?.ContainerSpec?.Mounts ?? []; return mounts.some((mount) => mount?.Type === "volume" && mount?.Source === volumeName); } function compactService(service, volumeName) { const mounts = (service?.Spec?.TaskTemplate?.ContainerSpec?.Mounts ?? []) .filter((mount) => mount?.Type === "volume" && mount?.Source === volumeName) .map((mount) => ({ type: mount?.Type ?? null, source: mount?.Source ?? null, target: mount?.Target ?? null, readOnly: mount?.ReadOnly ?? null, volumeOptions: mount?.VolumeOptions ?? null, })); return { id: service?.ID ?? null, name: service?.Spec?.Name ?? null, labels: service?.Spec?.Labels ?? {}, mode: service?.Spec?.Mode ?? null, mounts, }; } function main() { const volumeName = process.argv[2]; const outputPath = process.argv[3] || `docker-volume-owner-ledger-${Date.now()}.json`; if (!volumeName || volumeName.startsWith("-")) { console.error("Usage: node docker-volume-owner-ledger.mjs VOLUME_NAME [OUTPUT.json]"); process.exit(2); } const errors = []; const contextName = dockerText(["context", "show"], errors, "context-show", null); const contexts = contextName ? dockerJson(["context", "inspect", contextName], errors, "context-inspect", []) : []; const version = dockerJson(["version", "--format", "{{json .}}"], errors, "version", null); const info = dockerJson(["info", "--format", "{{json .}}"], errors, "info", null); const volumes = dockerJson(["volume", "inspect", volumeName], errors, "volume-inspect", []); const containerIds = dockerText(["container", "ls", "-aq", "--no-trunc"], errors, "container-list", "") .split(/\s+/) .filter(Boolean); let containers = []; if (containerIds.length > 0) { const inspected = dockerJson(["container", "inspect", ...containerIds], errors, "container-inspect", []); containers = inspected.map((container) => compactContainer(container, volumeName)).filter(Boolean); } const swarm = { localNodeState: info?.Swarm?.LocalNodeState ?? null, controlAvailable: info?.Swarm?.ControlAvailable ?? null, nodeId: info?.Swarm?.NodeID ?? null, services: [], inspectionSkippedReason: null, }; if (swarm.localNodeState === "active" && swarm.controlAvailable === true) { const serviceIds = dockerText(["service", "ls", "-q", "--no-trunc"], errors, "service-list", "") .split(/\s+/) .filter(Boolean); if (serviceIds.length > 0) { const services = dockerJson(["service", "inspect", ...serviceIds], errors, "service-inspect", []); swarm.services = services .filter((service) => serviceMountsVolume(service, volumeName)) .map((service) => compactService(service, volumeName)); } } else if (swarm.localNodeState === "active") { swarm.inspectionSkippedReason = "Swarm is active, but this node is not a manager; service ownership remains unresolved."; } else { swarm.inspectionSkippedReason = "Swarm is not active on the selected Docker Engine."; } const volume = volumes[0] ?? null; const unresolved = []; if (!contextName) unresolved.push("Could not determine the active Docker context."); if (!volume) unresolved.push("Could not inspect the requested volume on the selected context."); if (volume?.Scope && volume.Scope !== "local") unresolved.push(`Volume scope is ${volume.Scope}; cluster or plugin ownership needs separate review.`); if (volume?.Driver && volume.Driver !== "local") unresolved.push(`Volume driver is ${volume.Driver}; vendor or plugin state is outside this local audit.`); if (swarm.localNodeState === "active" && swarm.controlAvailable !== true) unresolved.push("Swarm service inspection requires a manager node."); if (errors.length > 0) unresolved.push("One or more read-only Docker commands failed; do not infer that missing owners do not exist."); const ledger = { schema: "watchatease.docker-volume-owner-ledger.v1", capturedAt: new Date().toISOString(), volumeName, context: compactContext(contexts[0]), environmentOverrides: { dockerContextPresent: Boolean(process.env.DOCKER_CONTEXT), dockerHostPresent: Boolean(process.env.DOCKER_HOST), note: "Values are intentionally omitted; rerun from the same shell that produced the error.", }, clientVersion: version?.Client?.Version ?? null, serverVersion: version?.Server?.Version ?? null, volume: volume ? { name: volume.Name ?? null, driver: volume.Driver ?? null, scope: volume.Scope ?? null, mountpoint: volume.Mountpoint ?? null, labels: volume.Labels ?? {}, options: volume.Options ?? {}, createdAt: volume.CreatedAt ?? null, } : null, containerOwners: containers, swarm, readErrors: errors, unresolved, mutationCommandsRun: [], }; const canonical = JSON.stringify(ledger, null, 2) + "\n"; writeFileSync(outputPath, canonical, { encoding: "utf8", flag: "wx" }); const sha256 = createHash("sha256").update(canonical).digest("hex"); console.log(JSON.stringify({ outputPath, sha256, owners: containers.length, swarmServices: swarm.services.length, unresolved: unresolved.length })); if (unresolved.length > 0) process.exitCode = 3; } main(); ```
Interpreta propietarios sin eliminar contenedores
Para cada contenedor coincidente revisa state, image, mount destination y labels de Compose o Swarm. Un contenedor detenido aún representa una configuración reproducible y puede ser el único registro de cómo se inició la aplicación. Si pertenece a Compose, conserva project, service, working directory y config-file labels antes de tocar nada, para volver al proyecto correcto sin adivinar por el prefijo del volumen.
No pases de una etiqueta Compose a `docker compose down --volumes`. La documentación oficial indica que `--volumes` elimina volúmenes con nombre declarados por el proyecto y volúmenes anónimos conectados; los external volumes están protegidos, pero los datos gestionados por el proyecto pueden borrarse. Confirma primero proyecto, retención, copia restaurable y aprobación del propietario.
Tampoco sustituyas la evidencia dirigida por `docker container prune`. Elimina todos los contenedores detenidos de su ámbito, no solo el que mantiene este volumen. El registro es deliberadamente estrecho para decidir sobre un propietario identificado cada vez.
Interpreta resultados de Swarm y context remotos
Si Swarm está activo y el nodo elegido es manager, el registro inspecciona servicios y anota toda task template que monte el volumen. Un propietario de servicio cambia el límite: eliminar una tarea local puede ser temporal porque el orquestador puede recrearla. Coordina el cambio en service o stack y conserva antes el desired state.
Si Swarm está activo pero el nodo no es manager, la inspección de servicios queda sin resolver. Traslada la investigación a un manager autorizado o al propietario del clúster; no concluyas que una lista local vacía libera el volumen.
Si el context endpoint es remoto, confirma que el comando fallido y el registro apuntaron al mismo endpoint. No pegues material TLS ni credenciales en el informe. El script guarda solo el endpoint y metadatos no secretos seleccionados.
Usa una matriz de parada/avance, no una lista genérica
Se encontró un contenedor propietario: detente. Identifica al propietario de la aplicación, averigua por qué existe, verifica una copia restaurable y deja que la persona responsable decida si conservarlo, recrearlo o eliminarlo. El registro no toma esa decisión empresarial.
Se encontraron etiquetas Compose: detente. Abre el proyecto exacto y compara su configuración con las etiquetas registradas. No ejecutes un teardown que elimine volúmenes hasta que ámbito y retención sean explícitos.
Se encontró un servicio Swarm o no se puede inspeccionar: detente. Revisa service/stack desde un manager. Una acción local sobre contenedores no demuestra que cambió el desired state.
Plugin driver, cluster scope, Desktop extension o lectura fallida: detente. Escala al propietario correspondiente. La falta de visibilidad no equivale a ausencia.
Sin propietarios, sin errores y con driver/scope local: contrasta la pestaña in-use de Docker Desktop si está disponible, verifica que el volumen exista y conserva o prueba una copia antes de considerar limpieza. Si Engine aún informa de un conflicto inexplicable, reúne version y daemon logs para soporte. No conviertas incertidumbre en una recomendación de prune o restart.
Por qué es más seguro que --force, prune o restart
`--force` suele malinterpretarse. La discusión histórica de Docker explica que un contenedor detenido puede proteger legítimamente un volumen: no ha desaparecido y puede arrancar otra vez. La respuesta segura es comprender al propietario, no buscar un interruptor de borrado más fuerte.
Los comandos prune amplían el radio de impacto. system prune afecta contenedores detenidos, networks, images y build cache según sus opciones; volume prune puede incluir named volumes sin uso al ampliar el ámbito. Ninguno responde qué aplicación posee estos datos.
Reiniciar el daemon puede cambiar estado transitorio, pero también altera el sistema y borra parte de la evidencia previa. Si soporte acaba recomendándolo, captura primero registro, versiones y logs. El objetivo no es ocultar el error, sino hacer que el siguiente paso sea revisable y reversible cuando sea posible.
Lo que esta guía no demuestra
Un registro limpio no demuestra que sea seguro borrar el volumen. No valida copias consistentes con la aplicación, quiescencia de base de datos, snapshots del plugin, replicación del clúster, propiedad de extensiones Desktop ni recuperación. Tampoco inspecciona servicios Swarm desde un worker sin autoridad manager.
Guarda el JSON y su SHA-256 con el incidente. Si queda sin resolver cualquier duda sobre propietario, endpoint, label, driver, scope, permiso o copia, detente antes de limpiar. El resultado más útil puede ser una entrega precisa al propietario de aplicación, clúster o almacenamiento, no un comando de borrado.
