テクノロジー · 実用ガイド

Docker ボリュームが「使用中」なのにコンテナが見えない?清掃前に所有者台帳を作る

--force、prune、デーモン再起動から始めないでください。エラーを正確な Docker context に結び付け、停止済みコンテナや Swarm サービスを含む全マウント所有者を棚卸しし、証拠を先に保存します。

選択 endpoint、ボリューム情報、コンテナ状態、Compose ラベル、Swarm 参照から Docker ボリューム所有者台帳を作るオリジナル概念図。
オリジナル概念図:Docker context と endpoint を固定し、正確なボリュームと全所有者を調べ、清掃前にハッシュ付き証拠を保存します。 Watch at Ease

Docker の volume-in-use エラーは、清掃問題である前に所有権の問題です。参照元は停止済み・作成済みコンテナ、Compose project、Swarm service、plugin、remote Engine context、または最初の一覧が見ていない scope かもしれません。

このガイドは正確な Docker context と endpoint に結び付く読み取り専用の所有者台帳を作ります。volume metadata、全 container state、Compose/Swarm の手掛かり、read failure、未解決境界を一つの hashed JSON に保存し、stop、remove、prune、restart、update、deploy は実行しません。

台帳は削除を許可しません。application、cluster、storage の所有者が次のレビュー済み操作を決めるための証拠であり、visibility 不足、non-local scope、failed read、manager 権限不足、backup の不確実性を停止理由として扱います。

エラーは清掃命令ではなく所有権のシグナル

Docker の現行ボリューム削除ドキュメントは、安全境界を明確にしています。コンテナが使用中のボリュームは削除できません。ただし「使用中」は「目に見える実行中コンテナ」より広い概念です。停止済み、または作成済みのコンテナも残っており再起動できるため、そのマウントは有効な参照です。Docker Desktop の Volumes ビューも、停止と削除を区別しています。

そのため `docker ps` だけでは不十分です。既定では実行中コンテナしか表示しません。全コンテナ表示では exited、created、paused、restarting、removing、dead の記録も見つかります。Compose はラベル、保持コンテナ、孤立コンテナで所有関係を追加し、Swarm はサービスとタスクの所有関係を追加します。また CLI は現在の context、`DOCKER_CONTEXT`、`DOCKER_HOST`、明示的な `--context` により別の Engine を指す場合があります。

このエラーは所有者を特定する要求として扱ってください。参照の削除、プロジェクトの除去、ホストの prune、デーモン再起動、force フラグを許可するものではありません。

対象を固定する:context、endpoint、ボリューム名、scope

所有者を集める前に、エラーに出た正確なボリューム名と、失敗したコマンドが使用した Docker context を記録します。`docker context show` は選択中の context を示し、`docker context inspect` は Docker endpoint を示します。一方の Engine でコンテナ一覧が空でも、別の Engine のボリューム参照は説明できません。

次にボリューム自体を inspect し、driver、scope、labels、options、作成時刻、mountpoint を保存します。local 以外の scope または driver は停止条件であり近道ではありません。プラグインやクラスターボリュームはローカル一覧外に所有者を持つ可能性があるため、ローカル結果だけでは未使用を証明できません。

クライアントとサーバーのバージョンも記録します。過去の Docker CLI 問題では、Swarm が有効な特定バージョンで、ボリュームが残っていても削除コマンドが名前を表示し得ました。一行の出力から成功を推測せず、オブジェクト状態を独立に確認してください。

読み取り専用の所有者台帳を実行する

付属の Node スクリプトはボリューム名と出力ファイル名を受け取り、context、version、info、volume inspect、container list/inspect、service list/inspect という読み取り操作だけを実行します。stop、rm、down、prune、restart、update、deploy は一切呼びません。選択 endpoint、ボリューム記録、該当する全コンテナマウント、Compose/Swarm ラベル、サービスマウント、失敗、未解決境界を一つの JSON に保存します。

環境変数で選択 context が変わるため、エラーを発生させた同じ shell と環境から実行します。出力は排他的に作成され、既存の証拠を上書きしません。完成 JSON の SHA-256 も表示されるので、後の担当者が変更を検出できるようインシデント記録に添付してください。

`readErrors` と `unresolved` が両方とも空の場合に限り、所有者ゼロという結果に意味があります。読み取り失敗が一つでもある、またはプラグイン/クラスター scope の場合、欠けた行は「不明」であって「所有者なし」の証明ではありません。

```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(); ```

コンテナ所有者を削除せず解釈する

一致した各コンテナについて state、image、mount destination、Compose/Swarm labels を確認します。停止済みコンテナも再現可能な構成であり、アプリの起動方法を示す唯一の記録かもしれません。Compose 所属なら project、service、working directory、config-file labels を先に保存し、ボリューム名の prefix から推測せず正しいプロジェクト定義へ戻ります。

Compose ラベルを見て直ちに `docker compose down --volumes` を実行してはいけません。公式文書では `--volumes` がプロジェクト宣言の named volume とコンテナ接続の anonymous volume を削除します。external volume は保護されても、プロジェクト管理データは削除され得ます。正しいプロジェクト、保持方針、利用可能なバックアップ、アプリ所有者の承認を先に確認します。

`docker container prune` で対象を広げないでください。これは対象 scope 内の停止済みコンテナをすべて削除し、このボリュームの所有者だけに限定されません。台帳は名前付き所有者ごとに判断できるよう意図的に狭くしています。

Swarm と remote context の結果を解釈する

Swarm が active で選択ノードが manager なら、台帳はサービスを調べ、指定ボリュームをマウントする task template を記録します。サービス所有者がいる場合、ローカル task コンテナを消しても orchestrator が再作成できます。service または stack レベルで調整し、desired state の証拠を先に保存します。

Swarm が active でも現在ノードが manager でなければ、サービス検査は未解決です。権限のある manager へ移るかクラスター所有者に依頼し、空のローカル一覧を空きボリュームの証拠にしないでください。

context endpoint が remote の場合、失敗コマンドと台帳が同じ endpoint を対象にしたか確認します。TLS 素材や認証情報をレポートに貼り付けないでください。スクリプトは endpoint 文字列と選択した非秘密メタデータだけを保存します。

一般的な修正一覧ではなく stop/go matrix を使う

コンテナ所有者を発見:停止。アプリ所有者を特定し、コンテナが残る理由と復元可能なバックアップを確認して、そのコンテナを保持、再作成、削除のどれにするか責任者が決めます。台帳は業務判断を代行しません。

Compose ラベルを発見:停止。正確な Compose プロジェクトを開き、現在構成を記録ラベルと比較します。プロジェクト範囲と保持方針が明確になるまで、ボリュームを削除する teardown を実行しません。

Swarm サービスを発見、または Swarm 検査不能:停止。manager 上で service/stack を確認します。ローカルコンテナ操作だけでは desired state の変更を証明できません。

plugin driver、cluster scope、Desktop extension、読み取り失敗:停止。該当 driver、cluster、Desktop extension、platform の所有者へエスカレーションします。見えないことは存在しないことではありません。

所有者なし、エラーなし、driver/scope が local:可能なら Docker Desktop の in-use タブで照合し、ボリュームの存在を確認し、清掃を検討する前にバックアップを保存またはテストします。Engine がなお説明不能な衝突を報告するなら、version と daemon logs を集めて support へ渡し、不確実性を prune や restart の提案に変換しません。

--force、prune、restart より安全な理由

`--force` はよく誤解されます。過去の Docker 議論が示すとおり、停止済みコンテナは消滅しておらず再起動できるため、正当にボリュームを保護し得ます。安全な対応は、より強い削除スイッチを探すことではなく、その所有者を理解することです。

prune は影響範囲を広げます。system prune は options に応じて停止コンテナ、network、image、build cache を対象にし、volume prune は範囲次第で未使用 named volume も含みます。どちらも、このデータを所有するアプリを答えません。

daemon restart は一時状態を変える可能性がありますが、システムを変更しながら変更前証拠の一部も失わせます。support が最終的に推奨する場合も、台帳、Docker versions、logs を先に保存します。目的はエラーを消すことではなく、次の操作をレビュー可能かつ可能な限り可逆にすることです。

このガイドが証明しないこと

きれいな台帳でもボリューム削除の安全性は証明できません。application-consistent backup、database quiescence、plugin-side snapshot、cluster replication、Desktop extension 所有権、recovery procedure は検証しません。manager 権限のない worker から Swarm service を検査することもできません。

JSON と SHA-256 をインシデント記録に保存します。所有者、endpoint、label、driver、scope、permission、backup のいずれかが未解決なら清掃前に停止します。最も有用な結果は削除コマンドではなく、application、cluster、storage の所有者への正確な引き継ぎかもしれません。

Docker ボリューム清掃前のコンテナ、Compose、Swarm、plugin、読み取りエラーの停止条件を示すオリジナル概念図。
オリジナル概念図:所有者、remote scope、manager 権限不足、plugin 境界、読み取りエラーは停止シグナルであり、強い削除スイッチの許可ではありません。 Watch at Ease