k3s 镜像盘只增不减:删 8 个 tag 释放 0 字节,孤儿 manifest 才是真占盘的那个
一套自建镜像跑起来的湖仓平台(SQL 网关、批流引擎、Notebook 内核、OLAP、对象存储),镜像盘会只增不减:每次引擎升级都新增一组 tag,旧组留着回滚。等到磁盘告警,按常规操作删掉旧 tag——空间一个字节都不掉。
这不是玄学,是 containerd 的删除语义。「我的数据空间」(datastudiohappy.cn)这次把镜像盘从115G(71%)清到 81G(50%),释放 34G,全程零第三方镜像重下载。下面是可复用的那套判据。
一、清理只有一条约束
任何清理都不得导致「下次 build 或运行需要重新联网下载」。
一条约束就够,因为它同时覆盖两类东西:
- 自建镜像是真·单点——本地构建、从没 push 过任何仓库,任何 registry 上都不存在它,删了无处可拉、平台起不来;
- 第三方镜像公共仓库上有、能重拉——但重拉就是联网下载,正是这条约束要避免的。
所以清理的目标从来不是「删镜像」,而是删那些没有任何东西引用、却仍在占盘的记录与 blob。
二、删 tag 不释放空间,因为 tag 不是占盘的那一头
ctr images rm <tag>只摘 tag。同一个镜像的by-digest 记录(sha256:...那条)仍然在,继续钉住全部层——这就是删完旧 tag 后 overlayfs 快照纹丝不动的原因。
实测:删 8 个旧 tag,占用44399MB → 44399MB,0 释放。
真正该删的是孤儿 manifest:不被任何 tag 引用的那些sha256:记录。判据是引用式的——收集所有带 tag 镜像的 manifest digest 作为白名单,白名单之外的记录即孤儿。
实测:15 条孤儿删完释放7.8GB,33 个 tag 镜像一个不少、19 个 pod 全 Running、查询冒烟 8/8。
同样的道理还有一处更隐蔽:docker 侧的moby-dangling@sha256:...记录。它是 docker 自己已经不显示(docker images -f dangling=true为空)、但仍钉住快照的残留。这次清盘的主矿就是它。
三、三个存储层,漏一层就白干
产物散在三个互不相干的存储层,只看df根本不知道该清哪层:
| 层 | 里面是什么 | 清什么 |
|---|---|---|
| k3s containerd(运行时) | 镜像记录 + overlayfs 快照 | 孤儿 manifest 记录 |
| k3s content store | 压缩层 blob | 无引用的 blob(引用式 GC) |
| docker / moby | 本机只用来 build、零容器运行 | 构建产物镜像、moby-dangling@记录、无引用 content |
其中 content store 那层有个容易被误解的点:k3s 默认discard_unpacked_layers=true,镜像解包成 snapshot 之后,压缩层 blob 本就是冗余的(运行跑 snapshot 不跑 blob),所以引用式地清掉无引用 blob,build 和 run 都不受影响。
实测:content store20G → 1.1M,镜像一个不少、pod 全 Running。
四、引用式 vs「有没有容器在用」式——这是红线所在
这两种判据听起来像同一件事,后果差一个量级:
- 引用式(
content prune references、按白名单算孤儿):只删没有任何镜像对象引用的东西。仍被 tag 镜像引用的层由镜像对象保护,绝不会删。安全。 - 「有没有容器在用」式(
crictl rmi --prune、ctr images prune、docker system prune):按当前有没有 pod 在跑判生死。危险。
危险在于:数据平台里有一大类按需拉起的镜像——批处理 Spark 作业、Flink application 集群、Notebook 内核。它们空闲时一个 pod 都没有,于是在这类 prune 眼里全是垃圾,而它们恰恰是自建单点。
实测:某次批量清理一次释放 8GB,连带删掉了两个从未推过仓库的引擎镜像——两个都被在跑的部署清单引用着。判据是「有没有 pod 正在用」,不是「能不能重拉」。
五、实测:34G 从哪来,以及三条反直觉
| 动作 | 释放 |
|---|---|
摘249 条moby-dangling@记录+ 引用式 content prune | 23G |
| 删 docker 侧上一代引擎镜像组 | 7.6G |
| 删 k3s 侧上一代引擎镜像组(4 个 tag + 各自 by-digest 记录) | 5.7G |
| 逐条删 13 条 docker 悬空层 | 1G |
| 旧日志 + 已被取代的离线包 | 0.8G |
三条值得记住的:
- CLI 看不见的记录是主矿,悬空层不是。13 条悬空层的「大小」加起来 32GB,实删只掉1G(层几乎全与 tag 化镜像共享);而
docker images完全无感的 249 条记录掉了23G。按「大小」列排序去删,方向就错了。 - 别按文件名里的版本号判生死。名字带旧版号的 connector jar 可能正在用;而看着在用的某个 jar 其实是从发行版目录里拷的、跟本地物料无关。要看
COPY的源路径,grep 到名字 ≠ 引用了这个文件。 - 硬链接会浪费你半小时。备份目录与构建物料目录若是硬链接(inode 相同、链接数 2),只删一侧不释放任何空间。
六、leases不能一把梭
containerd 的 lease 是 GC 保护。清理时需要先解掉孤儿 lease 的 pin,再跑引用式 prune——但只能删确认是孤儿的那些。
把全部 lease 一次删光再 prune,containerd 会把「有镜像记录、但从没被任何 pod 解包使用过」的镜像整个回收掉(不只是压缩层)。实测一次丢了 15 个镜像,自建的那批只能从 build 侧重新导入,第三方的多数只能重新联网拉——正好撞在唯一那条约束上。
正确做法:只删确认孤儿的 lease,或者干脆不碰 lease,直接跑引用式 prune。
七、把判据固化成一个只读审计脚本
镜像盘冲到高位,根因不是「忘了清」,而是三件事:删除语义会骗人、产物散在三层、能不能删一直靠人读文档加临场记忆。
所以最后落下来的东西不是一份清理命令清单,而是一个只读、不删任何东西的审计脚本,输出报告供人决策,五段各答一个问题:
| 段 | 回答的问题 |
|---|---|
| 1 存储水位 | 现在多少、哪一层在胀 |
| 2 缺失镜像 | 有没有「声明了却不在」的(replicas=0的 workload 不报 ImagePullBackOff,全绿也可能缺镜像) |
| 3 孤儿 manifest | 有多少能安全释放 |
| 4 重建链 | 这么清会不会害得下次 build 联网 |
| 5 docker 侧产物 | 悬空层与moby-dangling@记录各多少 |
关键是判据做成版本无关:第 4 段只问「上游 base 是不是本仓库自建的镜像」——是则按链先建上游,否则是真断点。不硬编码任何 tag。结论会随升级过期,判据不会。
于是清理从「凭感觉动手」变成「跑一遍看报告再动手」。同一套思路在平台侧也是这么落的:资源与容量不靠人盯,靠可观测面板与配额闸。
八、四句话总结
- 删 tag 不等于删镜像:by-digest 的孤儿 manifest 才钉着层,删 tag 常常 0 释放;
- 清理判据必须是引用式的,「当前有没有容器在用」式的批量 prune 会精准删掉按需拉起的作业镜像;
- 产物散在三层(运行时镜像记录 / content blob / build 侧),CLI 看不见的那类记录往往是主矿,而「大小」列会把你带偏;
- 真正的产出不是一串删除命令,而是一份只读审计报告 + 一条版本无关的判据——先看报告,再动手。
「我的数据空间」是一套可私有化部署的数据平台(湖仓 + 调度 + 数据治理 + 智能诊断),部署、镜像与资源治理均已脚本化,支持 OEM 合作。产品介绍:https://datastudiohappy.cn/。