一块 200G 的盘,df -h 说只剩 54G,已用 72%。

平时也就算了,但这台机器同时还跑着一个小型 Kubernetes 集群。真到某次镜像拉取或者一次构建需要几个 G 的临时空间时,“四分之三满"就不是一个舒服的位置了。

这篇记录两件事:空间到底去哪了,以及——更重要的一半——我怎么确认删掉的东西没有碰坏任何正在运行的东西。

一、先看 df,再看 du

第一步永远是看数字,而不是先猜:

1
2
3
df -h /
# Filesystem      Size  Used Avail Use% Mounted on
# /dev/sda1       197G  136G   54G  72% /

然后顺着顶层目录找"大块头”:

1
2
3
4
5
du -xsh /* | sort -rh | head
# 92G  /var
# 41G  /root
# 3.2G /usr
# 2.0G /tmp

/var 和 /root 是全部。但这里藏着这整件事的第一个教训:

最大的目录,不等于最值得回收的目录。

二、陷阱一:/tmp 是 tmpfs,不占磁盘

/tmp 看起来有 2G,很想顺手清掉。但先看一眼它挂在哪个文件系统上:

1
2
3
df -h /tmp
# Filesystem      Size  Used Avail Use% Mounted on
# tmpfs           7.9G  2.0G  5.9G  25% /tmp

tmpfs 是内存,不是磁盘。清空 /tmp 只会把内存还回去,对那块 200G 的盘一个字节的帮助都没有。

清任何东西之前,先确认你清的那个目录,真的在这个文件系统上。

三、/var 里的大头:containerd 的 blobs 与 snapshots

/var 里继续往下钻:

1
2
3
4
du -xsh /var/* | sort -rh | head
# 67G  /var/snap
# 24G  /var/lib
# 785M /var/cache

/var/snap 属于集群,/var/lib/containers 属于 Podman。先把集群那一半拆开:

1
2
3
du -xsh /var/snap/microk8s/common/*
# 55G  .../common/var            <- containerd 的运行时目录
# 12G  .../common/default-storage <- 持久卷(PVC)实体文件

再往 containerd 里看一层:

1
2
3
4
du -xsh .../var/lib/containerd/io.containerd.content.v1.content/blobs/sha256
# 15G
du -xsh .../var/lib/containerd/io.containerd.snapshotter.v1.overlayfs
# 40G

两个数字就有故事了:

  • content/blobs(15G) 是压缩后的镜像层,是所有镜像共享的内容库,按 sha256 去重。
  • overlayfs snapshots(40G) 是这些层被解包、叠加后的可写快照——它比 blobs 大得多,因为每个镜像解包后的体积通常都大于压缩包,而且换一层就会多出一份 diff。

而那 12G 的 default-storage 是持久卷的实体数据——数据库、用户上传的文件、模型权重。这是"活数据",和"缓存"是两回事,绝对不碰。

到这里,可回收的大头已经很清楚了。顺便说一句:日志根本不在名单上,journal 当时也就几十兆。经验里"磁盘满 = 日志大"这次的直觉是错的。

四、真的能删的:ctr images prune 必须带 --all

containerd 没有一个像 docker system prune 那样耳熟能详的入口,但它的调试客户端 ctr 带了一个 images prune。第一个坑就在这:

1
2
3
ctr images prune --help
# --all  Remove all unused images, not just dangling ones
#        (if all is not specified no images will be pruned)

不传 --all,它什么都不干,而且不报错。你会以为自己回收了空间,其实只是安静地空跑了一次。所以:

1
2
# microk8s 用的是自带的 ctr 包装器,它会自动指定 k8s.io 命名空间
microk8s ctr images prune --all

输出是一长串 deleted image: sha256:...。镜像记录数从 237 掉到 22。

两个要理解的细节:

  1. 它是安全的,因为它按"引用"来删。 只要某个镜像还被任何一个容器、或者被 kubelet 的 lease 引用着,它就不会被删。也就是说,所有正在运行的 Pod 的镜像天然被保护,被删掉的是那些老版本、试过就丢的构建产物。
  2. 空间是异步还回来的。 prune 删的是"镜像引用",blobs 和 snapshots 的垃圾回收随后才跑。所以 df 不会立刻变化,过一会儿再看。

五、Podman:镜像和卷分开清

Podman 这边先看总账,再动手:

1
2
3
4
5
podman system df
# TYPE           TOTAL  ACTIVE  SIZE      RECLAIMABLE
# Images         143    2       21.01GB   20.33GB (97%)
# Containers     3      1       135.8kB   123.7kB (91%)
# Local Volumes  4      2       2.633GB   114.9MB (4%)

97% 可回收,基本就是一份"这里全是历史遗留"的声明。

1
2
podman image prune -a -f    # 删掉所有"没有任何容器引用"的镜像
podman volume prune -f      # 只删没被挂载的卷

image prune -a 的语义要说清楚:它保留跑着的容器所用的镜像,也保留那两个已停止的数据库容器所引用的镜像;删掉的是其余的——包括一个 2G 的旧运行时、若干个 <none> 的中间层、以及各种试过就忘的测试 tag。剩下三张镜像,干净利落。

一个前提:-a 会把基础镜像(编译器、语言运行时)也一并删掉。它们都能重新拉,但前提是镜像仓库此刻可达。动手前值得花十秒确认一下——否则你删掉的可能是短期内拿不回来的东西。这次我做过这个检查。

六、构建缓存:重建有代价,但它一定能重建

/root 那 41G 里,大头是各种"下次会自己长回来"的东西:

目录大小说明
Rust target/debug8.3G下次编译是冷启动
工具链 venv + 下载的模型权重3.1G重新 sync / 重新下载
生成的基准报告1.1G下次跑批测重生成
一个陈旧的 scratch cargo target555M早就没用的对比构建
Go module 缓存535M下次构建自动回填

这些之所以安全,是因为它们本质上都是可再生的。但代价必须说清楚:删完之后,下一次构建是一次完整的冷启动。这笔账要自己权衡——如果最近还要密集改代码,那 8G 的 target 也许值得留着。

七、零碎但值得顺手清掉的

金额不大,但加起来也够一个镜像:

1
2
3
apt-get clean                                              # 772M 包缓存
snap remove <name> --revision=<old-rev>                    # ~500M 已禁用的旧 revision
rm -rf /var/tmp/<scattered-scratch-dirs>                   # 360M 各种验证脚本的临时产物

注意 snap 默认会保留两个版本以便回滚,所以删的是已经处于 disabled 状态的旧 revision,不是当前正在用的那个。

八、验证:不能只看 df

前面这些都还好,真正需要小心的只有 containerd 那一步——它跑在一个活着的集群上。所以我给自己定了三步验证,顺序很重要:

第一步:删之前先记下"正在用的镜像"。

1
2
3
kubectl get pods -A -o jsonpath='{range .items[*]}{.spec.containers[*].image}{"\n"}{end}' \
  | sort -u
# 20 个镜像

第二步:删之后,逐个核对这 20 个还在不在。

1
ctr images ls | awk '{print $1}' | sort -u

这里有个坑: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 个全部在位。

第三步:确认集群本身没受影响。

1
2
3
kubectl get pods -A        # 全部 Running,重启次数没有增长
curl -sS -o /dev/null -w '%{http_code}' https://<endpoint>/
# 200

三层都过了,才算真的"删对了"。

九、结果

之前之后
已用136G62G
可用54G128G
使用率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,得等仓库恢复。这一条我验证不了,就明确写在这里,而不是含糊带过。