Watch at Ease看見世界,讀懂生活。

科技 · 實用指南

Docker 卷顯示「使用中」,卻找不到明顯容器?清理前先建立擁有者臺帳

不要從 --force、prune 或重新啟動守護程序開始。先把錯誤綁定到正確的 Docker context,盤點包含已停止容器與 Swarm 服務在內的所有掛載擁有者,並先保存證據。

原創概念圖:從所選端點、卷元數據、容器狀態、Compose 標籤與 Swarm 引用建立 Docker 卷擁有者台賬。
原創概念圖:先凍結 Docker context 與端點,再檢查準確卷、清點全部擁有者,並在清理前保存一份帶哈希的證據記錄。 Watch at Ease

Docker 卷「使用中」錯誤首先是所有權問題,之後才可能是清理問題。引用可能來自已停止或僅建立的容器、Compose 專案、Swarm 服務、plugin、遠端 Engine context,或第一次容器清單未涵蓋的其他範圍。

本指南建立一份綁定到正確 Docker context 與 endpoint 的唯讀擁有者臺帳。它把卷中繼資料、所有容器狀態、Compose 與 Swarm 線索、讀取失敗和未解決界線保存到一個含雜湊的 JSON 檔案,全程不停止、刪除、prune、重新啟動、update 或 deploy 任何物件。

臺帳不授權刪除。它協助應用程式、cluster 或 storage 擁有者決定下一項經審閱的動作,並把可見性不足、非 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、權限或備份仍有任何問題,就應在清理前停止。最有價值的結果可能是把證據精確交接給應用、集群或存儲擁有者,而不是給出刪除命令。

原創概念圖:在清理 Docker 卷前,把容器、Compose、Swarm、插件與讀取錯誤列為停止條件。
原創概念圖:擁有者、遠程作用域、缺少 manager 權限、插件邊界或讀取錯誤都是停止信號,不是使用更強刪除開關的理由。 Watch at Ease