df 说盘满了,du 加起来却只有一半,那二十多个 G 去哪了
df 说盘满了,du 加起来却只有一半,那二十多个 G 去哪了作者:昭鸢笺(hi@mibase.top) 周日晚上快十一点,手机震了一下,Uptime Kuma 的推送:Gitea 挂了,HTTP 502。 我当时躺在床上刷东西,本来想着明天再说,但是又想到这台机子上除了 Gitea 还跑着我的 RSS、一个自己写的书签服务,还有这个博客的评论后端(虽然现在评论已经关了),拖到明天总觉得心里不踏实,就爬起来开了电脑。 机器是年初在某家小厂买的,2 核 2G,40G 的系统盘,Debian 12。便宜,一年一百出头,所以我对它的期待一直很低,只要别动不动就挂就行。 先是个很普通的开头 ssh 上去之后第一反应当然是看 Gitea 为什么挂: systemctl status gitea journalctl -u gitea -n 50 --no-pager 日志里一堆 no space left on device,往 sqlite 里写不进去,起不来。 行吧,盘满了。这种事不算稀奇,小机器上日志、docker 镜像、apt 缓存,哪个都能吃掉几个 G。我顺手 df -h: Filesystem Size Used Avail Use% Mounted on /dev/vda1 40G 38G 0 100% / tmpfs 987M 0 987M 0% /dev/shm 注意这个 Avail 是 0,Used 是 38G,加起来不到 40,是因为 ext4 默认给 root 留了 5% 的保留块。这个我知道,不是问题。 问题在后面。 我习惯性地进根目录跑一下 du,找大头: cd / du -xh --max-depth=1 2>/dev/null | sort -h | tail 等了一会儿,结果: 310M ./opt 1.1G ./usr 1.4G ./home 2.3G ./var/lib (这是我后面单独看的) 5.8G ./var 14G . 整个根分区 du 出来才 14G。df 说用了 38G。 差了二十四个 G,这就不对了。 第一个怀疑:挂载点底下藏了东西 遇到 df 比 du 大很多的情况,我脑子里最先蹦出来的是挂载遮盖。 大概是这么回事:假如 /data 本来是个单独的目录,里面已经写了一堆文件,后来你又把另一块盘挂到 /data 上,那原来目录里的文件并没有消失,只是被新挂上去的文件系统盖住了,ls 看不到,du 也扫不到,但它们依然占着根分区的空间。 我之前真干过这事,是在另一台机子上,数据盘忘了挂,服务先起来了,往 /data 里写了几个 G,后来补挂了盘,那几个 G 就成了幽灵。 所以先看这台机子有哪些挂载: findmnt -lo TARGET,SOURCE,FSTYPE | grep -v -E 'proc|sys|cgroup|tmpfs|devpts|mqueue' TARGET SOURCE FSTYPE / /dev/vda1 ext4 /boot/efi /dev/vda15 vfat /var/lib/docker/overlay2/.... overlay 除了 docker 的 overlay 就没有别的了,我这台机子压根没挂过别的盘。 不过我还是想确认一下,因为"我记得没有"这种话在运维里最不可靠。有个办法是把根分区再 bind 挂载一次,这样挂载点底下的东西就能看到: mkdir /mnt/rootcheck mount --bind / /mnt/rootcheck du -xsh /mnt/rootcheck umount /mnt/rootcheck bind mount 出来的是根文件系统本身,不带子挂载,所以被遮住的内容会露出来。结果是 14G,和之前一样。 这条路排除了。 第二个怀疑:inode df 有两个维度,一个是块,一个是 inode。有时候块没满但是 inode 用光了,同样报 no space left on device。不过这次 df -h 里块本身就是满的,按理说和 inode 无关,但我还是顺手看了一眼: df -i / Filesystem Inodes IUsed IFree IUse% Mounted on /dev/vda1 2621440 412873 2208567 16% / 16%,很健康。 这里说一句题外话,我以前遇到过一次真的是 inode 耗尽的,是某个 PHP 应用的 session 目录里堆了上百万个几百字节的小文件,df -h 看一半空着,就是写不进去。那种情况下 ls 那个目录都能卡半分钟。这次不是。 第三个怀疑:docker du 我加了 -x,意思是不跨文件系统。docker 的 overlay2 虽然在 /var/lib/docker 下面,但它自己的 merged 目录是 overlay 挂载,-x 会跳过它们,但 /var/lib/docker/overlay2//diff 这些层本身是普通目录,是会被统计到的。所以 docker 的镜像和层其实已经算在那个 14G 里了。 保险起见我还是问了一下 docker 自己: docker system df TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 9 6 3.2GB 1.1GB (34%) Containers 7 6 412MB 0B Local Volumes 5 4 1.9GB 310MB Build Cache 0 0 0B 0B 加起来五六个 G,对得上,也远远凑不够那 24G。 容器的 json 日志呢?/var/lib/docker/containers/*/*-json.log,这个也是 docker 目录下的普通文件,du 会算。我又单独 ls -lh 了一遍,最大的是 Uptime Kuma 的,80 多 M。不是它。 到这里我已经折腾了二十来分钟了,有点烦。 想起来一件事 我坐在那儿盯着屏幕,忽然想起上个周末干过的一件事。 有个小爬虫脚本,抓一些我关注的站点的更新,用 nohup python3 crawl.py > crawl.log 2>&1 & 挂在后台跑。上周末我发现 crawl.log 已经长到好几个 G 了(脚本里有个 debug 输出忘了关,每抓一个页面就把整个 HTML 打一遍),我当时看了一眼,想着反正不重要,直接: rm ~/crawl/crawl.log 然后就去干别的了。 没有重启脚本。 这在 Linux 下是个经典的坑,但我居然自己踩了。rm 删的只是目录项,也就是文件名到 inode 的那条链接。只要还有进程打开着这个文件,inode 和它的数据块就不会被释放,进程照样往里写,只是你再也在任何目录里找不到这个文件了。 所以 du 看不见它,因为 du 是顺着目录树往下走的,目录树里已经没有这个名字了。df 看得见,因为 df 问的是文件系统,文件系统知道这些块还被占着。 这就解释了为什么两边对不上。 验证一下 lsof 有个选项专门找这种东西,+L1 的意思是列出链接数小于 1 的已打开文件,也就是已删除但还开着的: lsof +L1 2>/dev/null | head COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME python3 1873 zhou 1w REG 254,1 24831209472 0 915487 /home/zhou/crawl/crawl.log (deleted) python3 1873 zhou 2w REG 254,1 24831209472 0 915487 /home/zhou/crawl/crawl.log (deleted) SIZE 那一列 24831209472,除以 1024 三次,23.1G。 就是它。后面还跟着一个很显眼的 (deleted)。 fd 1 和 fd 2 是标准输出和标准错误,当初重定向的时候 2>&1 让它们指向了同一个文件。所以同一个文件开了两次,lsof 列出两行,但占的空间只有一份,不要重复加。 一周的时间,这个脚本往一个我以为已经不存在的文件里写了二十三个 G。我现在回头想想,那个 debug 输出的量大得有点离谱,可能当时那个站点对爬虫做了什么重定向循环,脚本一直在重试。反正这是另一个问题了。 怎么处理 有两个办法。 最直接的是把进程杀了。进程一退出,所有 fd 关闭,内核发现这个 inode 没有任何引用,就会释放空间。 kill 1873 但是我这次想先试试不杀进程的做法,原因也没什么,就是想验证一下,顺便以后遇到不能随便杀的进程也有个预案。 Linux 的 /proc//fd/ 下面有这个进程所有打开的文件描述符,每一个都是一个符号链接,即便指向的文件已经被删除,通过这个链接依然能访问到它。所以可以直接把它截断: : > /proc/1873/fd/1 那个冒号是 shell 的空命令,加上重定向就是把文件截成 0 字节。也可以用 truncate -s 0 /proc/1873/fd/1,效果一样。 跑完之后马上 df -h: /dev/vda1 40G 14G 24G 38% / 空间一下就回来了。 这里有个要注意的点。我截断的是 fd 1,但 fd 2 指向同一个文件,同一个 inode。如果进程是用 O_APPEND 打开的(shell 的 > 重定向其实不是,它是 O_TRUNC,不带 append),那截断之后写入位置会回到文件末尾,也就是新的 0;但如果不是 append 模式,进程内部的文件偏移量还停在原来 23G 的位置,下一次写入的时候会从 23G 那里开始写,文件系统会产生一个稀疏文件,大小显示还是 23G,但实际块占用很小。 我这次用的是 >,没带 append,所以其实就是后面那种情况。我又跑了一次 ls -l /proc/1873/fd/1,大小显示又变回了几百兆,因为脚本还在继续写,偏移量不对,形成了空洞。实际占的磁盘块不多,所以空间没有问题,但这个文件的"表面大小"会很吓人。 说得有点绕。总之如果要长期这么做,应该用 >> 重定向启动脚本,这样 truncate 完了偏移量会自己跟到文件末尾。 我最后还是把那个脚本杀掉了,没打算留着它,重新启动的时候改成了: nohup python3 crawl.py >> ~/crawl/crawl.log 2>&1 & 顺便把那个愚蠢的 debug 输出删了。 Gitea 还是没起来 空间回来之后我 systemctl restart gitea,还是起不来,报 database disk image is malformed。 这个我一点都不意外,sqlite 在写到一半磁盘满了,很可能把库写坏了。 好在 Gitea 每天凌晨有个备份,是我之前随手加的 cron,用 gitea dump 打包到 /home/zhou/backup/。去看了一下,昨天凌晨的包是正常的,3.1M。 systemctl stop gitea cp /var/lib/gitea/data/gitea.db /var/lib/gitea/data/gitea.db.broken sqlite3 /var/lib/gitea/data/gitea.db "PRAGMA integrity_check;" 输出了一大堆 *** in database main *** 和 Page xxx: btree page is out of order 之类的,确实坏了。 我先试了一下 .recover: sqlite3 gitea.db.broken ".recover" | sqlite3 gitea.db.recovered 恢复出来的库能打开,integrity_check 也是 ok。但我不太敢直接用它,因为 .recover 对丢失的页面是尽力而为,我
以上内容来自博客订阅源,版权归原作者。关注作者后可以在 关注列表 第一时间查看作者文章。

登录后,加入这场讨论。
登录参与