返回博客

2026.10.15

du 说 681G,df 说 795G,docker 说能省 74.6GB:清一块快满的盘,到底该信哪个数?

同一块 1T 的盘,四个工具给了四个互相打架的数。我拿今天的复测把它们挨个对了一遍:普通用户的 du 漏掉 113G,uv 缓存 61.8G 里只有 18G 删了会真的变成空位,一个 17.3G 的 SQLite 实际数据 26MB,docker 报的可回收量比它自己的上限还大。四个除法,外加一个八月那次我解释错了的 34G。

Linux磁盘方法论homelab

同一块盘,同一个上午,我拿到三个数:

du -s /                 →  681 G
df /                    →  795 G 已用
docker system df        →  镜像可回收 74.6 GB

du 和 df 差了 113G。docker 说能腾出来的空间,比它没在用的那几个镜像全部加起来还大。

这块盘我八月清过一次,从只剩 8.4G 清到 222G。那次我交出去的第一份账就错了,漏了 178G。当时我以为自己吸取了教训,今天复测才发现,我在笔记里给那次对账写的解释也是错的(第 1 章末尾)。

只想要公式的直接看每章的代码块,一章一个除法;想知道哪个数最会骗人,看第 4 章。

1. du 漏了 113G,而且它其实报过错

先说八月那次。盘快满了,我用普通用户跑了一遍 du,把各个目录的占用列成一份账。账一加起来就露馅了:才四百七十 G,盘可是 1T 的。

漏的那 178G 全在只有 root 能读的目录里:docker 的数据目录我报 10G,实际 91G;一个模型目录我报的其实是它上一级目录的数;还有一整块缓存我报的是 0。

今天我又复测了一遍,同一个 du -x -s /,跑两次:

普通用户:697,539 MiB  (退出码 1,stderr 71 行「权限不够」)
root    :813,642 MiB
差      :116,103 MiB ≈ 113 GiB

(这篇里的 G 都是 GiB,du -m 的单位是 MiB。docker 那一章除外,docker 用的是十进制 GB。)

我在八月的笔记里写的是「du 会静默跳过,而且不报错」。这句话不对。它报了,71 行,退出码也是 1。真正坑人的是最后那行汇总:照样打出一个干干净净的数字,前面不加任何「注意,这个数不全」。错误信息全进了 stderr,我那次只看了 stdout。

我这边 sudo 要密码,但用户在 docker 组里,所以用的是只读挂载的办法:

docker run --rm -v /:/host:ro alpine du -x -s -m /host

一个小容器把宿主根目录只读挂进去,用容器里的 root 去读。光是 docker 自己的数据目录,root 读出来就是 109,398 MiB,普通用户读出来几乎是 0。

那怎么知道自己读全了?拿它去跟 df 对账:

看不见的部分 = df 的 Used − root du 的总和

今天的对账:

814,130 − 813,642 = 488 MiB    占 0.06%

这 488 MiB 我也不打算细究。root 那次 du 跑了 32 分钟,这期间 df 的 Used 从 814,130 涨到了 814,627,涨了 497 MiB,跟这个差额一个量级。盘一直在写,你量的时候它在长。

现在说那个解释错了的地方。 八月对账时 du 是 825.8G,df 是 860.2G,差 34G。我当时在笔记里写:「差的 34G 是 ext4 的 5% 保留块,正常。」

保留块根本不在 Used 里。ext4 会给 root 留一块应急空间(默认 5%),df 把它算成「空闲但你用不了」,所以它藏在 Size 和 Used + Avail 之间的那条缝里:

保留块 = Size − Used − Avail
       = 937,292 − 814,130 − 75,478 = 47,684 MiB ≈ 46.6 GiB

再拿 stat -f / 交叉验一下:空闲块减可用块,31,529,360 − 19,322,308 = 12,207,052 个 4K 块,正好 47,683 MiB。整个分区的 5% 是 46,865 MiB,对得上。

保留块是 46.6G,可它不在 du 和 Used 的差里面。今天那个差只剩 488 MiB,证明 du 读全的时候,两边几乎能对平。所以八月那 34G 是别的东西。最可疑的是那种「文件已经删了,但还被某个进程开着」的情况:du 看不见,df 照样算占用。不过那次的现场已经没了,没办法验证。我当时随手找了个听起来很合理的解释就交差了。

2. 61.8G 的缓存,删了只能空出 18G

八月清盘时,我还犯过一个错:uv(一个 Python 包管理器)的缓存,我报了 24G 可回收,实测真正的垃圾只有 2.07G。差在硬链接上。

硬链接可以想成同一套房子挂了两个门牌号。缓存里一个门牌,某个虚拟环境里一个门牌,du 去缓存目录里数,房子当然算在缓存头上。可你把缓存这边的门牌摘了,房子还在,因为虚拟环境那边的门牌还挂着。

今天的同一个目录:

du ~/.cache/uv                           63,270 MiB
  链接数 = 1 的文件                      17,977 MiB
  链接数 > 1,但所有链接都在缓存目录里       437 MiB
  链接数 > 1,有链接在缓存目录外面        43,372 MiB

最后那 43,372 MiB 删了一个字节都不会回来。能真正变成空位的是前两行:

可释放量 = Σ 某个文件的大小,条件是这个 inode 的所有链接都在你要删的目录里
         = 17,977 + 437 = 18,414 MiB ≈ 18 GiB

du 报 61.8G,能释放 18G,只有 29%。

自己算也不麻烦。find 能把每个文件的 inode 号、链接数、大小一起打出来:

find DIR -type f -links +1 -printf '%i %n %s\n' \
  | awk '{c[$1]++; n[$1]=$2; s[$1]=$3}
         END{for(i in c) if(c[i]>=n[i]) f+=s[i]; else k+=s[i];
             printf "能释放 %.0f MiB,释放不了 %.0f MiB\n", f/2^20, k/2^20}'

在目录里数到的链接数等于这个 inode 的总链接数,说明它没有门牌挂在外面,删了就真没了。数到的少于总数,那就是外面还有人挂着。再加上链接数为 1 的那部分,就是答案。

3. 一个 17.3G 的数据库,实际数据 26MB

这是这个月的事。盘又只剩 13G 了,我做了一遍只读盘点,排在前面的是一个日志用的 SQLite 文件:17.3G。

打开一看,实际数据 26MB。其余 99.9% 全是空闲页。

它有一条 14 天的保留规则,每天把过期的行删掉。可 SQLite 删行之后,腾出来的页只是被挂到一张空闲列表上,留着下次写入复用,文件大小一点都不会变。要让文件真正缩下来,得跑一次 VACUUM,或者一开始就开 auto_vacuum。这个库两样都没有。

所以文件大小记录的只是它历史上最胖的那一刻。

可回收量 ≈ freelist_count × page_size

两个值都能直接问它:

PRAGMA page_size;       -- 4096
PRAGMA freelist_count;  -- 空闲页数
PRAGMA auto_vacuum;     -- 0 表示没开

我当时是先 VACUUM INTO 往另一块盘上导了一份备份,确认备份能打开,再原地 VACUUM。17.3G → 26MB。今天再查,freelist_count = 1,auto_vacuum = 0。现在是干净的,但机制没变,它还会再长回去。

那次一共腾出 80G 左右,光这一个文件就占了 17G。而它在 du 的列表里只是普通的一行「这个文件 17.3G」,看上去跟一个塞满真数据的库一模一样。按文件大小排序,你根本分不出哪个是真胖、哪个是虚胖。

4. docker 报的可回收量,比它自己的上限还大

这是今天最离谱的一个数。

docker system df
TYPE      TOTAL   ACTIVE   SIZE      RECLAIMABLE
Images    50      42       81.99GB   74.6GB (90%)

90% 可回收,看着像是镜像随便删。我把每个镜像的明细拉出来(docker system df -v):

50 个镜像的 Size 加总        99.78 GB
50 个镜像的 UniqueSize 加总  23.90 GB
docker 报的总大小            81.99 GB

Size 加起来比总大小还多 18 GB,因为镜像之间共享层,每个镜像都把共享层算进了自己的 Size。真正只属于某一个镜像的层(UniqueSize),全部加起来才 23.9 GB。

再看没被任何容器引用的镜像,也就是 docker image prune -a 真正会删的那批:9 个。

这 9 个镜像的 UniqueSize 加总   7.39 GB   ← 下限
这 9 个镜像的 Size 加总        40.06 GB   ← 上限

把它们全删掉,能空出来的空间一定在 7.39 到 40.06 GB 之间。下限是它们各自独占的层,上限是假设它们所有的层都没被别人用。

docker 报的 74.6 GB 比上限还多了 34.5 GB。

它这个数到底怎么算的,我没去读源码,不知道。但不需要知道:不管它内部用的什么口径,删掉这 9 个镜像,不可能腾出比这 9 个镜像本身更大的空间。别人给你报一个「可回收」,先找它的上限,超过上限的部分直接不信。

删一组镜像的释放量 ∈ [ 这组 UniqueSize 之和 ,  这组 Size 之和 ]

5. 哪些地方我没测准

  • 只有一台机器、一种文件系统。 ext4,一块 NVMe。保留块那套算法是 ext4 的,btrfs、XFS、ZFS 的 df 语义都不一样,不能照搬
  • root 那次 du 跑了 32 分钟,盘一直在写。 对账剩下的 488 MiB 我没法跟这期间的写入分开
  • uv 那个 18G 是算出来的,不是删出来的。 我按链接数算了可释放量,没有真删一遍去看 df 的变化。理论上这就是答案,但「删了再看 df」才是直接证据
  • docker 的 74.6 GB 我只证明了它不可能,没解释它从哪来。 上下限那个区间也只是区间,要知道确切值,得真删然后看 df
  • SQLite 那 17.3G 是怎么胖起来的,我没查。 有保留规则、空闲页会复用,按理说文件应该稳定在某个高水位。它能胖到 17G,说明某段时间每天写进去的量比现在大得多,那段历史我没追
  • 八月那 34G 的真正来历,现在已经验证不了。 「已删除但被进程打开」只是我最怀疑的一种,下次碰到用 lsof +L1 当场查

6. 下次清盘,按这个顺序问

  1. du 的 stderr 有没有内容,退出码是不是 0。不是 0,汇总那行数字就是错的
  2. 用 root(或者只读挂载)重跑一遍,拿总和去跟 df 的 Used 对账,差额大于 1% 就是还有东西没看见
  3. 别拿保留块去解释对账差额,保留块是 Size − Used − Avail,它从来不在 Used 里
  4. 要删缓存目录,先按 inode 数一遍链接,只算所有链接都在目录里的那部分
  5. 看到一个很胖的 SQLite,先问 freelist_count × page_size,再决定它是真胖还是虚胖
  6. 别人给你报「可回收」,先算它的上限。docker 镜像的上限是那组镜像的 Size 之和,超过上限的部分不用看
  7. 删完之后再跑一次 df,那才是你真正腾出来的空间