技术 · 实用指南
Docker 卷显示“正在使用”,却找不到明显容器?清理前先建立所有者台账
不要从 --force、prune 或重启守护进程开始。先把错误绑定到准确的 Docker context,清点包括已停止容器和 Swarm 服务在内的所有挂载所有者,并保存证据。

Docker 卷“正在使用”错误首先是所有权问题,然后才可能是清理问题。引用可能来自已停止或仅已创建的容器、Compose 项目、Swarm 服务、插件、远程 Engine context,或第一次容器列表没有覆盖的其他范围。
本指南建立一份绑定到准确 Docker context 与端点的只读所有者台账。它把卷元数据、全部容器状态、Compose 与 Swarm 线索、读取失败和未解决边界保存到一个带哈希的 JSON 文件中,全程不停止、删除、prune、重启、update 或 deploy 任何对象。
台账不授权删除。它帮助应用、集群或存储所有者决定下一项经审阅的动作,并把可见性缺失、非 local scope、读取失败、缺少 manager 权限和备份不确定性全部视为停止理由。
错误是所有权信号,不是清理指令
Docker 当前的卷删除文档对安全边界说得很清楚:只要容器仍在使用某个卷,就不能删除它。容易让人误判的是,“正在使用”的范围比“我能看到的运行中容器”更广。已停止或仅已创建的容器仍然存在,也可以再次启动,因此它的挂载依旧是有效引用。Docker Desktop 的卷视图也明确区分了停止容器与删除容器。
因此,只做一次 `docker ps` 检查并不够。它默认只显示运行中的容器;全容器视图还可能找到 exited、created、paused、restarting、removing 或 dead 状态的记录。Compose 项目会通过标签以及保留或孤立的容器增加一层所有权;Swarm 还可能增加服务和任务所有权。CLI 也可能因为当前 context、`DOCKER_CONTEXT`、`DOCKER_HOST` 或显式 `--context` 参数而连接到另一台 Engine。
应把错误视为识别所有者的请求。不要把它当作删除引用、移除项目、prune 主机、重启守护进程或尝试 force 参数的许可。
冻结目标:context、端点、卷名与作用域
收集所有者之前,先记下错误中的准确卷名,以及失败命令使用的 Docker context。`docker context show` 会报告所选 context,`docker context inspect` 会显示它的 Docker 端点。这个组合很重要:某台 Engine 上的空容器列表,解释不了另一台 Engine 上的卷引用。
然后检查卷本身,保存 driver、scope、labels、options、创建时间和 mountpoint。非 local 的 scope 或 driver 是停止条件,不是捷径。插件卷和集群卷的所有权可能位于本地容器列表之外,因此只有本地结果不能证明卷无人使用。
同时记录客户端和服务器版本。Docker CLI 的一个历史问题说明,删除命令打印出的结果并不足以证明成功:在特定的 Swarm 版本条件下,CLI 可能打印卷名,但卷实际上仍然存在。必须独立核验对象状态,不能从一行输出推断结果。
运行只读所有者台账
配套 Node 脚本接收一个卷名和一个输出文件名。它只执行 Docker 的 context、version、info、volume inspect、container list、container inspect、service list 与 service inspect 读取操作,绝不会调用 stop、rm、down、prune、restart、update 或 deploy。脚本写出一份 JSON,其中包含所选端点、卷记录、每个匹配的容器挂载、Compose 标签、Swarm 任务标签、匹配的服务挂载、命令失败和未解决边界。
请在产生原始错误的同一个 shell 和环境中运行,因为环境变量可能改变所选 context。输出文件使用独占写入,不会覆盖旧证据。脚本会打印最终 JSON 的 SHA-256;把摘要与事件记录一起保存,后续审阅者就能判断证据是否变化。
只有当 `readErrors` 和 `unresolved` 都为空时,零所有者结果才有意义。只要有一次读取失败,或所选卷使用插件或集群作用域,缺失的行就代表未知,而不是没有所有者。
```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(); ```
解释容器所有者,但不要删除它们
对每个匹配容器,检查其状态、镜像、挂载目标以及 Compose 或 Swarm 标签。已停止容器仍然是一份可复现配置,也可能是应用如何启动的唯一记录。如果容器属于 Compose,请先记录项目名、服务、工作目录和配置文件标签,再考虑任何操作。这些标签能帮助你回到正确的项目定义,而不是根据卷前缀猜测。
不要看到 Compose 标签就直接运行 `docker compose down --volumes`。Docker 自己的 Compose 文档说明,`--volumes` 会删除项目声明的命名卷和附加到容器的匿名卷;外部卷虽受保护,但项目管理的数据仍可能被删除。必须先确认正确项目、预期保留策略、可用备份和应用所有者批准。
同样,不要用 `docker container prune` 代替针对性证据。它会删除作用域内所有已停止容器,而不只是占用这个卷的容器。台账刻意限定范围,让审阅者可以逐一决定每个具名所有者。
解释 Swarm 与远程 context 结果
如果 Swarm 已启用且所选节点是 manager,台账会检查服务,并记录所有在任务模板中挂载该命名卷的服务。服务所有者会改变决策边界:删除本地任务容器可能只是暂时的,因为编排器可以依据服务定义再创建任务。应在服务或 stack 层协调变更,并先保存期望状态证据。
如果 Swarm 已启用,但当前节点不是 manager,服务检查仍属未解决。请把调查移到获授权的 manager,或联系集群所有者;不要把空的本地列表解释为卷已空闲。
如果 context 端点是远程地址,请确认失败命令和台账确实指向同一端点。不要把 TLS 材料或凭据粘贴进报告。脚本只保留端点字符串和经过选择的非秘密元数据。
使用停止/继续矩阵,而不是通用修复清单
发现容器所有者:停止。识别应用所有者,确认容器为何仍存在,核验可恢复备份,并由负责人决定该特定容器应保留、重建还是删除。台账不会替你作出业务决定。
发现 Compose 标签:停止。重新打开准确的 Compose 项目,把当前配置与记录的标签对照。在项目范围与数据保留策略明确前,不要运行会删除卷的 teardown。
发现 Swarm 服务,或无法进行 Swarm 检查:停止。在 manager 上检查服务或 stack。本地容器操作不能证明期望状态已经改变。
插件 driver、集群 scope、Desktop extension 或读取失败:停止。升级给对应的 driver、集群、Desktop extension 或平台所有者。看不见不等于不存在。
没有所有者、没有错误、driver 与 scope 都是 local:如条件允许,再用 Docker Desktop 的 in-use 标签页交叉检查,确认卷仍存在,并在考虑清理前保存或测试备份。如果 Engine 仍报告无法解释的冲突,请收集版本和守护进程日志交给支持人员。不要把不确定性转换成 prune 或 restart 建议。
为何这比 --force、prune 或 restart 更安全
`--force` 参数经常被误解。历史 Docker 讨论说明,已停止容器仍可能合理地保护一个卷:该容器并未消失,还能重新启动。安全做法是理解这个所有者,而不是寻找更强的删除开关。
Prune 命令会扩大影响范围。system prune 会按选项处理已停止容器、网络、镜像与构建缓存;volume prune 在范围扩大时也可能包含未使用的命名卷。两者都不能回答哪个应用拥有这份具体数据。
重启守护进程或许会改变瞬时状态,但它也会在改变系统的同时抹去部分变更前证据。如果支持人员最终建议重启,也要先保存台账、Docker 版本和日志。目标不是让报错消失,而是让下一步尽可能可审阅、可逆。
本指南不能证明什么
干净的台账不能证明删除卷是安全的。它不会验证应用一致性备份、数据库静默、插件侧快照、集群复制、Desktop extension 所有权或恢复流程,也无法从缺少 manager 权限的 worker 检查 Swarm 服务。
把 JSON 与其 SHA-256 一同保存在事件记录中。只要所有者、端点、标签、driver、scope、权限或备份仍有任何问题,就应在清理前停止。最有价值的结果可能是把证据精确交接给应用、集群或存储所有者,而不是给出删除命令。
