一块 200G 的盘,df -h 说只剩 54G,已用 72%。
平时也就算了,但这台机器同时还跑着一个小型 Kubernetes 集群。真到某次镜像拉取或者一次构建需要几个 G 的临时空间时,“四分之三满"就不是一个舒服的位置了。
这篇记录两件事:空间到底去哪了,以及——更重要的一半——我怎么确认删掉的东西没有碰坏任何正在运行的东西。
一、先看 df,再看 du
第一步永远是看数字,而不是先猜:
然后顺着顶层目录找"大块头”:
/var 和 /root 是全部。但这里藏着这整件事的第一个教训:
最大的目录,不等于最值得回收的目录。
二、陷阱一:/tmp 是 tmpfs,不占磁盘
/tmp 看起来有 2G,很想顺手清掉。但先看一眼它挂在哪个文件系统上:
tmpfs 是内存,不是磁盘。清空 /tmp 只会把内存还回去,对那块 200G 的盘一个字节的帮助都没有。
清任何东西之前,先确认你清的那个目录,真的在这个文件系统上。
三、/var 里的大头:containerd 的 blobs 与 snapshots
/var 里继续往下钻:
/var/snap 属于集群,/var/lib/containers 属于 Podman。先把集群那一半拆开:
再往 containerd 里看一层:
两个数字就有故事了:
- content/blobs(15G) 是压缩后的镜像层,是所有镜像共享的内容库,按 sha256 去重。
- overlayfs snapshots(40G) 是这些层被解包、叠加后的可写快照——它比 blobs 大得多,因为每个镜像解包后的体积通常都大于压缩包,而且换一层就会多出一份 diff。
而那 12G 的 default-storage 是持久卷的实体数据——数据库、用户上传的文件、模型权重。这是"活数据",和"缓存"是两回事,绝对不碰。
到这里,可回收的大头已经很清楚了。顺便说一句:日志根本不在名单上,journal 当时也就几十兆。经验里"磁盘满 = 日志大"这次的直觉是错的。
四、真的能删的:ctr images prune 必须带 --all
containerd 没有一个像 docker system prune 那样耳熟能详的入口,但它的调试客户端 ctr 带了一个 images prune。第一个坑就在这:
不传 --all,它什么都不干,而且不报错。你会以为自己回收了空间,其实只是安静地空跑了一次。所以:
输出是一长串 deleted image: sha256:...。镜像记录数从 237 掉到 22。
两个要理解的细节:
- 它是安全的,因为它按"引用"来删。 只要某个镜像还被任何一个容器、或者被 kubelet 的 lease 引用着,它就不会被删。也就是说,所有正在运行的 Pod 的镜像天然被保护,被删掉的是那些老版本、试过就丢的构建产物。
- 空间是异步还回来的。 prune 删的是"镜像引用",blobs 和 snapshots 的垃圾回收随后才跑。所以
df不会立刻变化,过一会儿再看。
五、Podman:镜像和卷分开清
Podman 这边先看总账,再动手:
97% 可回收,基本就是一份"这里全是历史遗留"的声明。
image prune -a 的语义要说清楚:它保留跑着的容器所用的镜像,也保留那两个已停止的数据库容器所引用的镜像;删掉的是其余的——包括一个 2G 的旧运行时、若干个 <none> 的中间层、以及各种试过就忘的测试 tag。剩下三张镜像,干净利落。
一个前提:-a 会把基础镜像(编译器、语言运行时)也一并删掉。它们都能重新拉,但前提是镜像仓库此刻可达。动手前值得花十秒确认一下——否则你删掉的可能是短期内拿不回来的东西。这次我做过这个检查。
六、构建缓存:重建有代价,但它一定能重建
/root 那 41G 里,大头是各种"下次会自己长回来"的东西:
| 目录 | 大小 | 说明 |
|---|---|---|
Rust target/debug | 8.3G | 下次编译是冷启动 |
| 工具链 venv + 下载的模型权重 | 3.1G | 重新 sync / 重新下载 |
| 生成的基准报告 | 1.1G | 下次跑批测重生成 |
| 一个陈旧的 scratch cargo target | 555M | 早就没用的对比构建 |
| Go module 缓存 | 535M | 下次构建自动回填 |
这些之所以安全,是因为它们本质上都是可再生的。但代价必须说清楚:删完之后,下一次构建是一次完整的冷启动。这笔账要自己权衡——如果最近还要密集改代码,那 8G 的 target 也许值得留着。
七、零碎但值得顺手清掉的
金额不大,但加起来也够一个镜像:
注意 snap 默认会保留两个版本以便回滚,所以删的是已经处于 disabled 状态的旧 revision,不是当前正在用的那个。
八、验证:不能只看 df
前面这些都还好,真正需要小心的只有 containerd 那一步——它跑在一个活着的集群上。所以我给自己定了三步验证,顺序很重要:
第一步:删之前先记下"正在用的镜像"。
第二步:删之后,逐个核对这 20 个还在不在。
| |
这里有个坑:kubectl 返回的是短名,比如 team/app:1.2.3;而 containerd 存的是全限定名,即 docker.io/team/app:1.2.3。更常见的还有 library/ 前缀:Pod 里写 myapp:1.2.3,containerd 里存成 docker.io/library/myapp:1.2.3。直接 grep 短名会得到一堆假的 MISSING,把自己吓一跳。结论:比较之前先归一化(补上 docker.io/ 和 library/)。归一化之后,20 个全部在位。
第三步:确认集群本身没受影响。
三层都过了,才算真的"删对了"。
九、结果
| 之前 | 之后 | |
|---|---|---|
| 已用 | 136G | 62G |
| 可用 | 54G | 128G |
| 使用率 | 72% | 33% |
粗算回收了约 74G。按来源大致是:
| 来源 | 约 |
|---|---|
| containerd 镜像 + 快照 GC | ~42G |
| Podman 未使用镜像 | ~20G |
| Rust 构建目录 | 8.3G |
| 工具 venv + 模型权重 | 3.1G |
| 基准报告等生成物 | 1.1G |
| apt 包缓存 | 772M |
| 陈旧 cargo target + Go 缓存 | 1.1G |
| 已禁用 snap revision | ~500M |
| /var/tmp 临时目录 | 360M |
(单列的数字是估算,彼此略有重叠,以 df 的前后差值 74G 为准。)
十、教训
- 最大的目录,不等于最值得回收的目录。 12G 的持久卷就在那里,但它是活数据,不是缓存。先分类,再动手。
- 清之前先看文件系统类型。
/tmp是tmpfs,是内存,清它不还磁盘。 prune的默认行为可能是个静默的空操作。ctr images prune不加--all什么都不做,也不报错——“命令跑成功了"不等于"空间回来了”。- 删"没用的",前提是你能说出"在用的"有哪些。 删之前把在用的清单抓出来,删之后逐项核对;containerd 的 prune 之所以安全,正是因为它按引用删,正在运行的 Pod 的镜像动不了。
- 构建缓存是有价的。 它一定能重建,但"冷启动"就是那张账单。
- 诚实地说边界。 这次全程镜像仓库不可达,所以被删掉的旧版本镜像短期内拉不回来——如果哪天真要回滚到一个老 tag,得等仓库恢复。这一条我验证不了,就明确写在这里,而不是含糊带过。