Docker 镜像、容器、Volume、Network 与构建缓存安全清理
磁盘腾出了 40 GB,第二天测试数据也没了
开发机磁盘只剩几百 MB,维护者先执行全局清理,又追加了 docker volume prune -a,很快释放了空间。随后另一个项目启动失败,数据库 named volume 已被删除;被清掉的基础镜像重新拉取时又碰上企业代理故障;构建缓存全部失效,整个团队的首次构建同时回源。清理者解决了容量告警,却把可恢复缓存、昂贵镜像和不可替代数据当成同一种垃圾。
Docker 对象不是一个扁平目录。容器引用镜像、volume 与 network,镜像层可能被多个 tag 和容器共享,BuildKit 缓存又属于具体 builder。判断“能否删除”必须先回答三个问题:对象在哪个 daemon/context、谁仍在引用、删除后能否从可信来源重建。
镜像与缓存通常可重建但会消耗网络和时间;停止容器的 writable layer 可能藏着尚未导出的现场;volume 可能是唯一数据副本;network 虽几乎不占磁盘,却影响路由、iptables/nftables 规则和服务发现。安全清理应从低风险对象向高风险对象推进,而不是从命令最短的地方开始。
盘点当前 daemon,先证明不是看错了世界
清理前保存以下证据:
docker context show
docker version --format 'client={{.Client.Version}} server={{.Server.Version}}'
docker info --format 'root={{.DockerRootDir}} driver={{.Driver}}'
docker system df -v
docker ps -a --size
docker image ls --digests
docker volume ls
docker network ls
docker buildx ls
docker buildx dudocker system df -v 给出镜像、容器、本地 volume 与构建缓存的总量和可回收估算,但“RECLAIMABLE”只表示 Docker 当前判断没有活动引用,不代表数据没有业务价值。docker buildx du 应针对当前 builder 查看;多个 builder 各自维护缓存,清错 builder 既可能没释放目标空间,也可能让另一条构建链变慢。
Windows 与 macOS 的 Docker Desktop 通常把 Linux 对象保存在虚拟机磁盘中,宿主文件看到的是稀疏磁盘而不是每个对象。删除对象后,宿主磁盘文件未必立刻缩小;不要因此重复执行更激进删除。Linux Engine 则应从 DockerRootDir、存储驱动和挂载点判断容量,禁止直接进入数据根目录手删 overlay 层或 volume 元数据。
用标签建立可审计的归属
Compose 自动给容器、network 和 volume 添加 com.docker.compose.project 等标签。团队还应给自建对象补充 owner、生命周期和保留策略:
services:
app:
image: alpine:3.20
command: ["sh", "-c", "sleep infinity"]
labels:
io.example.owner: payments-dev
io.example.lifecycle: disposable
volumes:
db-data:
labels:
io.example.owner: payments-dev
io.example.lifecycle: persistent-local
networks:
default:
labels:
io.example.owner: payments-dev镜像在 Dockerfile 或构建命令中添加 OCI 与团队标签:
LABEL org.opencontainers.image.source="https://example.invalid/repository"
LABEL io.example.owner="payments-dev"
LABEL io.example.lifecycle="rebuildable"example.invalid 是保留域名,不应替换成真实内网地址写入公开配置。实际项目应记录可访问的源码标识和 revision,但不要把访问令牌放入 label;label 会被 inspect、镜像清单和制品系统读取,不是秘密存储。
按项目查看对象:
project=compose-contract-lab
docker ps -a --filter "label=com.docker.compose.project=$project"
docker volume ls --filter "label=com.docker.compose.project=$project"
docker network ls --filter "label=com.docker.compose.project=$project"空结果并不证明对象不存在,还要确认 context、project name 是否变化,以及旧对象是否由手工命令创建而没有标签。治理脚本只能删除同时满足“允许的 project、允许的 lifecycle、满足时间条件、当前无引用”的交集。
建一个可恢复的清理实验
以下实验只创建带唯一前缀的临时对象,不依赖当前项目。POSIX Shell 中执行:
set -eu
lab="cleanup-lab-$(date +%s)"
case "$lab" in
cleanup-lab-[0-9]*) ;;
*) printf 'unsafe lab name: %s\n' "$lab" >&2; exit 64 ;;
esac
docker network create \
--label io.example.owner=cleanup-lab \
"$lab-net" >/dev/null
docker volume create \
--label io.example.owner=cleanup-lab \
--label io.example.lifecycle=disposable \
"$lab-data" >/dev/null
docker run --name "$lab-box" \
--label io.example.owner=cleanup-lab \
--network "$lab-net" \
--mount "type=volume,src=$lab-data,dst=/data" \
alpine:3.20 \
sh -ec 'printf "cleanup-evidence\n" > /data/proof && cat /data/proof'
docker container inspect "$lab-box" \
--format 'image={{.Image}} networks={{json .NetworkSettings.Networks}} mounts={{json .Mounts}}'
docker volume inspect "$lab-data"
docker network inspect "$lab-net"预期容器退出码为 0,proof 写入 named volume。容器虽然已停止,仍引用镜像与 volume;network 是否可删则取决于是否还有活动 endpoint,不能照搬 volume 的判断方式。先验证 volume 保护并查看 endpoint:
set +e
docker volume rm "$lab-data"
volume_rc=$?
set -e
test "$volume_rc" -ne 0
docker network inspect "$lab-net" --format '{{json .Containers}}'
printf 'protected volume_rc=%s\n' "$volume_rc"预期 volume 删除非零退出,并出现仍被容器使用的证据。一次性容器退出后,network 的 .Containers 通常为空,因为活动 endpoint 已释放;若容器仍在运行,字段中才会出现 endpoint。这里揭示了两个不同机制:停止容器仍能保护其 image 与 volume,但 network 删除由活动 endpoint 决定。只看 docker ps 会漏掉 volume 的引用者,清理前必须同时看 docker ps -a 与 docker network inspect。
从可逆操作开始
第 0 级:停止,不删除
docker stop "$lab-box"
docker inspect "$lab-box" --format 'status={{.State.Status}} exit={{.State.ExitCode}}'停止保留容器配置、日志和 writable layer,适合先解除 CPU、内存与端口占用。它几乎不释放镜像和 volume 空间,但保留最多排障证据。
第 1 级:导出证据,再删容器
容器 writable layer 中若有未挂载输出,先复制或导出;日志中可能含敏感信息,归档前要脱敏和控制访问。实验容器的数据已在 named volume,可以删除容器:
docker rm "$lab-box"
test -z "$(docker ps -aq --filter "name=^/${lab}-box$")"
docker volume inspect "$lab-data" >/dev/null
docker network inspect "$lab-net" >/dev/null删除容器后,volume 与 network 仍存在。此时可用只读挂载验证数据:
docker run --rm \
--mount "type=volume,src=$lab-data,dst=/evidence,readonly" \
alpine:3.20 \
cat /evidence/proof预期输出 cleanup-evidence。如果失败,应先检查 context、volume 名和挂载状态,不要继续删除。
第 2 级:删除可重建网络
网络没有 endpoint 后可删除:
docker network rm "$lab-net"
set +e
docker network inspect "$lab-net" >/dev/null 2>&1
rc=$?
set -e
test "$rc" -ne 0自定义 bridge network 可由项目配置重建;但删除网络会移除 endpoint、路由与防火墙规则。共享 daemon 上必须按项目标签定向处理,不能因为 network 几乎不占磁盘就全局清空。
第 3 级:删除可重新拉取的镜像与可重建缓存
先看镜像是否仍被任何容器引用:
image_ref='alpine:3.20'
docker ps -a --filter "ancestor=$image_ref" --format '{{.ID}} {{.Names}} {{.Status}}'
docker image inspect "$image_ref" --format 'id={{.Id}} size={{.Size}} tags={{json .RepoTags}}'如果存在引用者,先判断这些容器是否属于目标项目。没有引用后,docker image rm <image> 是比全局 prune 更可审计的操作。删除前确认镜像能从可信 registry 重新获得,或已经用 docker image save 保存了离线包并记录 SHA-256。
构建缓存应针对 builder 和年龄清理,并把目标 builder 写进命令,避免当前选择被其他操作改变:
docker buildx ls
builder='<reviewed-builder-name>'
docker buildx inspect "$builder"
docker buildx du --builder "$builder"
docker buildx prune --builder "$builder" --filter 'until=168h'交互提示会展示影响。自动化中不要直接加 --force;应先保存 du 证据并限制 builder、年龄和空间策略。缓存删除只应影响速度,若删除缓存导致构建结果变化,说明构建依赖了未声明输入,必须修复可复现性。
第 4 级:备份并删除 Volume
Volume 最接近数据本体。即使 Docker 判断它 unused,也可能只是容器暂时被删。先记录 inspect、标签和备份摘要:
mkdir -p ./cleanup-backup
docker volume inspect "$lab-data" > "./cleanup-backup/$lab-volume.json"
docker run --rm \
--mount "type=volume,src=$lab-data,dst=/source,readonly" \
--mount "type=bind,src=$(pwd)/cleanup-backup,dst=/backup" \
alpine:3.20 \
sh -ec 'tar -C /source -czf "/backup/'"$lab"'-data.tgz" .'
sha256sum "./cleanup-backup/$lab-data.tgz"
tar -tzf "./cleanup-backup/$lab-data.tgz"$(pwd) 来自当前实验目录。生产脚本必须先解析绝对路径并确认它位于允许的备份根目录,不能把空变量或 / 传给 bind mount、rm 或归档命令。Windows PowerShell 应用 Resolve-Path 和 -LiteralPath 做同样的边界检查。
确认归档存在、摘要已记录、volume 标签属于实验后,只生成删除候选记录,暂时保留原 volume:
owner="$(docker volume inspect "$lab-data" --format '{{index .Labels "io.example.owner"}}')"
lifecycle="$(docker volume inspect "$lab-data" --format '{{index .Labels "io.example.lifecycle"}}')"
test "$owner" = 'cleanup-lab'
test "$lifecycle" = 'disposable'
test -s "./cleanup-backup/$lab-data.tgz"
printf 'CANDIDATE volume=%s owner=%s lifecycle=%s\n' "$lab-data" "$owner" "$lifecycle"归档可读不等于数据可恢复。删除原 volume 是不可逆动作,必须等隔离恢复和业务断言通过后,才能进入精确名称、标签断言、恢复证据与人工复述四层护栏。共享环境还应经过 owner 审批,并避开数据库写入窗口。
恢复实验决定备份是否可信
只生成 tar 包不算完成。新建另一个临时 volume 并恢复:
restore="$lab-restore"
docker volume create \
--label io.example.owner=cleanup-lab \
"$restore" >/dev/null
docker run --rm \
--mount "type=volume,src=$restore,dst=/target" \
--mount "type=bind,src=$(pwd)/cleanup-backup,dst=/backup,readonly" \
alpine:3.20 \
sh -ec 'tar -C /target -xzf "/backup/'"$lab"'-data.tgz"'
restored="$(docker run --rm \
--mount "type=volume,src=$restore,dst=/evidence,readonly" \
alpine:3.20 cat /evidence/proof)"
test "$restored" = 'cleanup-evidence'
printf 'restore=%s\n' "$restored"预期断言通过并输出 restore=cleanup-evidence。真实数据库 volume 不能靠复制运行中的文件目录获得一致备份;应使用数据库自身备份工具、只读或停写窗口,并验证恢复后的逻辑数据、schema 与版本兼容性。
恢复断言成功后,先再次展示原 volume 与恢复 volume,再输入原 volume 的完整名称确认删除;归档继续保留供人工检查:
test "$(docker volume inspect "$lab-data" --format '{{index .Labels "io.example.owner"}}')" = 'cleanup-lab'
test "$(docker volume inspect "$restore" --format '{{index .Labels "io.example.owner"}}')" = 'cleanup-lab'
printf 'DELETE original=%s restored-copy=%s\n' "$lab-data" "$restore"
printf 'type the exact original volume name: '
read -r confirmation
test "$confirmation" = "$lab-data"
docker volume rm "$lab-data"
docker volume rm "$restore"旧 Volume 为什么会让验证结果失真
容器镜像升级后,named volume 仍保存旧 schema、账号、索引或初始化标记。许多镜像只在数据目录为空时执行 /docker-entrypoint-initdb.d;重建容器不会重跑初始化。于是老成员的环境继续成功,新成员从空 volume 启动却失败。
排查顺序应是:
docker context show
docker compose config --volumes
docker compose ps -a
docker volume ls --filter label=com.docker.compose.project
docker volume inspect <exact-volume-name>然后比较镜像 digest、应用迁移版本和 volume 内数据版本。需要验证“冷启动”时,使用新的 project name 或显式创建一次性 volume,而不是先删除唯一旧 volume。正向实验应同时覆盖已有数据升级与空数据初始化,两条路径都成功才有资格升级团队基线。
镜像清理要区分 tag、manifest 与共享层
删除一个 tag 不一定立即释放对应大小,因为其他 tag、镜像或容器可能共享同一层。docker image ls 的 SIZE 也不能简单相加。判断回收收益时应结合 docker system df -v,并检查目标镜像 ID 的所有 tag 与容器引用。
docker image inspect <image-or-id> --format 'id={{.Id}} tags={{json .RepoTags}} digests={{json .RepoDigests}}'
docker ps -a --filter ancestor=<image-or-id>docker image prune 默认只删除 dangling images;加 -a 会扩大到所有没有容器引用的镜像。后者可能移除团队离线环境依赖的基础镜像和昂贵工具链,因此只能在确认 registry 可达、代理证书正常、关键镜像已归档后使用,并限制年龄与维护窗口。
Container 清理前保留退出证据
停止容器的日志、退出码、OOM 状态和文件差异可能是故障唯一证据:
id=<exact-container-id>
docker inspect "$id" --format 'status={{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} error={{.State.Error}}'
docker logs --timestamps "$id" > ./container.log 2>&1
docker diff "$id"日志可能包含 token、连接串和用户数据,不能直接提交仓库或公开附件。保留期应与故障等级和数据分级匹配;确认不再需要后使用精确 ID 删除。docker container prune 会删除所有停止容器,适合个人隔离 daemon 的维护窗口,不适合共享开发机的日常快捷操作。
Network 清理不释放大磁盘,却能修复错误路由
Network 残留会留下 bridge、endpoint 和防火墙规则。删除失败时先看 endpoint:
docker network inspect <exact-network-name> --format '{{json .Containers}}'如果输出不为空,找到对应容器并判断是否仍在运行。不要用 docker network disconnect -f 强行拆除未知项目;强制断开可能让运行中服务失联。只有 endpoint 已清空且标签确认归属后才删除目标 network。
Build Cache 清理要围绕 Builder 与复现性
Buildx 可以有 docker、docker-container、远程或 Kubernetes 等不同 driver,缓存位置随 builder 改变。先固定目标 builder:
docker buildx ls
docker buildx inspect <builder-name>
docker buildx du --builder <builder-name>清理策略可以按年龄、可用空间或保留空间制定,但要先测量冷构建成本。大型 C/C++、JVM 与前端依赖构建可能让一次无差别清理转化为长时间回源和 registry 流量。共享 builder 还要防止低信任分支读取或污染高信任缓存;缓存隔离策略应先于容量策略。
为什么不把全局 Prune 当作日常按钮
docker system prune 会跨项目处理停止容器、未使用 network、dangling image 与 build cache;增加 -a 会扩大镜像集合。当前 CLI 的 docker system prune --volumes 只把未被容器引用的匿名 volume 纳入删除;docker volume prune 默认也只处理 unused 匿名 volume,只有再加 -a 才会扩大到 unused named volume。Docker 的“unused”始终是引用判断,不是业务所有权判断。
因此,docker system prune -a --volumes 与 docker volume prune -a 都不应进入日常脚本、README 快捷命令或新人排障话术。它们只可能出现在专用、可重建 daemon 的受控重置流程中,并且执行前必须满足:
context 与 daemon 身份已经复核,环境不是共享或生产主机;所有不可替代 volume 已备份并完成恢复验证;离线所需镜像已归档,registry、代理与 CA 已验证可用;
builder 冷构建时间和网络成本已接受;删除清单、维护窗口、审批人和回退方案有记录。
更稳妥的顺序是精确 rm、按 project 标签删除、按对象类型和年龄过滤,最后才考虑全局操作。过滤器也不能单独承担安全边界:负向 label 过滤会把“没有标签”的历史对象纳入集合,自动化前必须先打印候选并由 owner 确认。
一个带路径和对象双护栏的项目重置脚本
以下 PowerShell 骨架适合 Windows 团队继续封装。它只接受固定 project,先展示对象,再要求输入完整名称;不会删除全局镜像或构建缓存:
$ErrorActionPreference = 'Stop'
$allowedProject = 'compose-contract-lab'
$workspaceRoot = (Resolve-Path -LiteralPath '.').Path
$composeFile = (Resolve-Path -LiteralPath '.\compose.yaml').Path
$restoreEvidence = (Resolve-Path -LiteralPath '.\cleanup-backup\restore-verified.json').Path
function Invoke-DockerChecked {
param([Parameter(Mandatory)][string[]]$Arguments)
& docker @Arguments
if ($LASTEXITCODE -ne 0) {
throw "docker command failed ($LASTEXITCODE): docker $($Arguments -join ' ')"
}
}
foreach ($path in @($composeFile, $restoreEvidence)) {
if (-not $path.StartsWith($workspaceRoot + [IO.Path]::DirectorySeparatorChar)) {
throw "path escaped workspace: $path"
}
}
$evidence = Get-Content -LiteralPath $restoreEvidence -Raw | ConvertFrom-Json
if ($evidence.project -ne $allowedProject -or $evidence.status -ne 'passed') {
throw 'restore evidence does not match the project or has not passed'
}
$configJson = & docker compose -f $composeFile config --format json
if ($LASTEXITCODE -ne 0) { throw 'compose config failed' }
$actualProject = ($configJson | ConvertFrom-Json).name
if ($actualProject -ne $allowedProject) {
throw "unexpected project: $actualProject"
}
$volumes = @(docker volume ls `
--filter "label=com.docker.compose.project=$actualProject" `
--format '{{.Name}}')
Write-Host 'Containers:'
Invoke-DockerChecked @('ps', '-a', '--filter', "label=com.docker.compose.project=$actualProject")
Write-Host 'Volumes:'
$volumes | ForEach-Object { Invoke-DockerChecked @('volume', 'inspect', $_) }
Write-Host "Restore evidence: $restoreEvidence"
$confirmation = Read-Host "Type RESET-$actualProject to delete project volumes"
if ($confirmation -ne "RESET-$actualProject") {
throw 'confirmation mismatch'
}
& docker compose -f $composeFile down --remove-orphans
if ($LASTEXITCODE -ne 0) { throw 'compose down failed; no volume was deleted' }
foreach ($volume in $volumes) {
$projectLabel = & docker volume inspect $volume `
--format '{{index .Labels "com.docker.compose.project"}}'
if ($LASTEXITCODE -ne 0) { throw "volume inspect failed: $volume" }
if ($projectLabel -ne $actualProject) {
throw "volume ownership changed: $volume"
}
Invoke-DockerChecked @('volume', 'rm', $volume)
}脚本把恢复验证记录作为强制执行条件,并让每条 Docker 命令的非零退出立即终止流程;compose down 失败时尤其不能继续删 volume。恢复记录仍需由团队备份程序生成并包含对象名、摘要、恢复时间、验证人和恢复结果,不能靠创建一个空文件绕过。脚本只处理 Compose project volume,避免把镜像缓存回收和数据重置绑成一次不可审查的大动作。
按现象建立排查路径
删除后宿主磁盘仍未下降
先复查 docker system df -v 与 docker buildx du,确认释放发生在哪个 daemon 和 builder。Docker Desktop 虚拟磁盘可能未立即向宿主回收空闲块;Linux 上还要检查已删除但仍被进程打开的文件、容器日志与非 Docker 目录。不要因为宿主数字未变就重复删除 volume。
Volume prune 候选比预期多
“unused”只代表没有容器引用。检查每个 volume 的标签、创建时间、挂载点和备份状态;无标签历史 volume 应进入人工认领,而不是默认删除。为旧对象补标签前先找到 owner,不能通过猜测归属自动化处理。
镜像删不掉
查看报错中的容器 ID,再用 docker ps -a --filter ancestor=<image> 找出停止容器。确认其日志和 writable layer 已留证后删容器,再删精确 tag 或 ID。-f 会跳过保护症状,不应作为第一反应。
Network 显示 active endpoints
读取 docker network inspect 的 Containers 字段并核对容器状态。Compose 异常退出可能留下 orphan,使用正确 project 的 docker compose down --remove-orphans 比全局 network prune 更安全。
清完缓存后构建结果改变
这不是缓存“坏了”,而是构建缺少锁文件、固定基础镜像、完整 build context 或受控远程依赖。保存冷构建与热构建的输入、镜像 digest 和产物 SHA-256,修复构建确定性后再恢复缓存策略。
引用计数不是所有权模型
Docker 能判断对象是否被容器引用,却不知道某个离线镜像、测试数据库或故障现场对团队是否重要。owner、生命周期、备份和到期时间必须通过标签与资产记录补齐;无人认领不等于可立即删除。
数据恢复必须跨版本验证
Volume 归档成功不代表新旧镜像都能读取。数据库、搜索引擎和制品仓库可能在启动时升级磁盘格式。清理或升级前应在隔离环境恢复到目标版本,验证逻辑数据与应用查询,再决定是否删除旧副本。
缓存容量与供应链风险互相制约
缓存越共享,命中率越高,跨项目污染和低信任输入复用风险也越大。高信任发布、普通分支与外部贡献应使用不同 builder 或 cache namespace;容量预算按信任域分配,而不是用一个全局 prune 维持表面整洁。
清理动作本身需要审计
共享 daemon 上应记录操作者、context、候选对象、标签、回收前后容量、备份摘要与失败项。脚本退出码必须反映部分失败,不能删掉三个对象、漏掉一个 volume 后仍返回成功。对数据对象采用双人审批或 owner 确认,收益通常远高于再写一条更强的删除命令。
成本不只有磁盘
删除镜像和缓存会增加 registry 流量、构建时间、开发等待和离线失败概率;保留过多对象又增加磁盘、备份与漏洞暴露面。团队应以趋势治理:观察每个 builder 和 project 的增长速度、冷构建成本与可重建性,再设保留期和容量水位,而不是套用统一 GB 阈值。
清理前已记录 context、daemon、DockerRootDir、builder 和磁盘摘要。候选对象有 project、owner、lifecycle 标签;无标签对象先认领。停止容器的日志、退出码、OOM 与 writable layer 已按故障等级留证。
镜像删除前已确认全部 tag、容器引用、可信 registry 和离线恢复入口。Network 删除前 endpoint 为空,没有强制断开未知运行容器。Build cache 按目标 builder、信任域、年龄与冷构建成本治理。
Volume 删除前有精确名称、标签断言、备份摘要和恢复验证。路径通过绝对路径解析并限制在允许根目录,空值与根目录会被拒绝。普通停止、项目重置、缓存回收和 daemon 全量重建是四条独立流程。
日常文档和脚本没有把 docker system prune -a --volumes 或 docker volume prune -a 当快捷操作。
