
凌晨两点半容量告警把手机震醒。登录上去看一眼df -h显示 /data 这块 500G 的盘已经用掉 468G只剩 6.4G 可用眼看就要写满。可紧接着跑的du -h --max-depth1 /data把一级目录挨个加了一遍总共才 381G。差了 87G两个都是系统自带命令谁也没报错谁也没被 kill。这种df -h 和 du -h --max-depth1 查出的磁盘大小不一致的场景干过一两年运维或者自己维护过服务器的人基本都撞上过区别只在于差的量级——有人差几百兆有人差几十 G运气差的能差出半个盘。这篇东西不打算把 man 手册抄一遍而是把这件事拆开df 和 du 各自的数字到底从哪儿来、为什么天生就对不上、遇到差异时按什么顺序排、哪几种情况必须立刻处理、哪几种其实可以放着不管。内容基于我在几台生产机上真实的排查过程整理命令都能直接复制执行也会把踩过的坑和后来总结的习惯一并写上。不管你是刚接手一台云主机的开发还是管着几十台机器的运维照着这个思路走一遍基本能定位到具体是哪几个文件在吃掉空间。1. df 和 du 读的根本不是同一份数据1.1 df 问的是文件系统不是目录很多人以为 df 和 du 是同一套统计逻辑的两种呈现这是理解整件事最大的障碍。df 压根不去遍历任何目录它做的事情非常轻对给定的路径调用一次statvfs(3)内核里对应的文件系统驱动直接返回它 superblock 里维护的几个计数器。整个过程是 O(1) 的不管盘里有一万个文件还是一千万个文件df 都是一瞬间出结果。这些计数器里关键的有四个f_blocks是文件系统总块数f_bfree是空闲块数注意含保留块f_bavail是普通用户真正能用的空闲块数不含保留块还有f_files和f_ffree管 inode。df 显示的 Used计算方式是f_blocks - f_bfree也就是块分配器认为已经被分配出去的块。这个数字的来源是文件系统的分配位图或者空闲 extent 树它反映的是磁盘上物理层面的占用跟这些块有没有被某个目录项指向一点关系都没有。这就解释了一个很反直觉的现象df 看到的已用空间是块级的事实而目录树只是块的一个使用者。文件被从目录树里摘掉块不一定还回去反过来某些被 df 算作已用的块从来就没出现在任何目录里。1.2 du 走的是目录遍历逐块累加du 的路子完全相反。它是从你给的路径开始递归readdir对每一个目录项做lstat取出st_blocks字段POSIX 规定单位是 512 字节乘 512 换算成字节再逐级往上累加。目录本身也占块ext4 上一个空目录通常是 4Kdu会把它算进去。所以 du 得到的数字本质是目录树里当前还挂着的这些文件各自占了多少块加起来是多少。两个细节经常被忽略。第一st_blocks是实际分配的磁盘块数不是文件的逻辑长度。一个用seek造出来的 10G 稀疏文件ls -l显示 10Gst_blocks可能只有 8du 就报 4K想按逻辑长度统计得加--apparent-size。第二GNU 版本的 du 在一次调用过程中会记录已经统计过的 inode所以硬链接指向同一个 inode 的多个名字只会被算一次。但如果你分几次跑 du每次统计一个子目录这个去重就失效了硬链接会被重复计数加起来反而比 df 还大。用一张表把两者的口径差异摆清楚维度dfdu数据来源statvfs文件系统 superblock 计数器readdir lstat遍历目录树复杂度O(1)O(目录项数量)是否看得到无目录项的文件看得到看不到是否统计文件系统元数据算不算是否统计保留块在 Avail 里扣掉Used 不含无关硬链接与它无关一次调用内去重是否需要 root 才能准确不需要需要否则权限不足的目录直接跳过默认跨文件系统只报当前挂载点会跨除非加-x1.3 一个容易搞错的公式Size 不等于 Used 加 Avail顺手把另一个高频困惑点讲清楚。很多人看到df -h里 Size 是 500G、Used 是 468G、Avail 是 6.4G加一下只有 474.4G剩下二十多 G 不知道去哪了就怀疑是不是也有隐藏文件。这二十多 G 大概率就是文件系统的保留块。准确的公式是Size Used f_bfree而Avail f_bfree - Reserved。也就是说Size - Used - Avail Reserved差额就是保留块。ext4 在mkfs时默认给 root 留 5% 的空间500G 的盘就是 25G。这块空间普通用户写不进去但 df 的 Avail 已经把它扣掉了所以三个数字加不齐是正常的跟幽灵文件没关系。这一点我见过太多人搞混抓着保留块去查了半天隐藏文件白折腾。想确认具体数字直接读超级块tune2fs -l /dev/mapper/vg-data | grep -iE block count|reserved block count|block size把 reserved block count 乘 block size 就是保留的总字节数。2. 最常见的元凶删掉了但没释放的文件2.1 rm 到底做了什么如果 df 明显大于 du而且差的量级在 G 以上十次里有七八次是这个原因。要理解它得先接受一个事实rm这个命令并没有删除文件这么浪漫它做的事叫 unlink——把文件名从目录项里摘掉对应 inode 的链接计数减 1。真正决定空间什么时候还回去的是两个条件同时满足链接计数归零并且没有任何进程还持有这个 inode 的打开文件描述符。只要有一个进程还握着 fd 不放内核就不能回收 inode 和数据块因为那个进程随时可能继续读写。对文件系统来说这些块依然是已分配状态df 老老实实把它们算进 Used而目录树里已经找不到这个名字了du 自然一笔都不记。中间那个差额就是这类幽灵文件的体量。它不会自己消失进程不退出、不重开文件这块空间就一直挂着直到机器重启。2.2 生产上最典型的三种制造方式第一种是日志轮转写错。用logrotate的时候如果配置里只有rotate、daily而没有copytruncate轮转后旧文件被改名或者删掉但应用进程还开着原来的 fd继续往那个已经不见了的 inode 里写。有些脚本更粗暴自定义的清理逻辑直接find /data/logs -name *.log -mtime 7 -delete只要应用没做 reopen删多少都白删。第二种是手工操作失误。半夜清理空间看到几个大文件rm -f一敲df一看没变化以为命令没生效再删一遍还是没变化然后就开始怀疑人生。实际上文件早就从目录里摘掉了只是对应的进程还在写。第三种是容器和临时文件。程序把上传的临时文件、导出的报表写到/tmp或者某个临时目录写完 fork 一个子进程去处理父进程不等子进程结束就把文件 unlink 了。子进程如果处理得慢这块空间就一直挂着。2.3 两条命令把幽灵文件揪出来定位这件事lsof是主力。最简单的写法是sudo lsof -nP L1 2/dev/null | head -50L1的意思是只列出链接计数小于 1 的打开文件也就是那些已经没有目录项、但还被进程开着的文件。输出的 SIZE/OFF 列就是它占了多少字节。要按体积排序找大头sudo lsof -nP L1 2/dev/null | awk NR1 $70 1073741824 {printf %8.2f GB pid%s %s %s\n, $7/1024/1024/1024, $2, $1, $9}这条会把大于 1G 的幽灵文件按大小列出来包含进程名、PID 和原路径。如果机器上没装 lsof精简镜像里很常见直接从 procfs 里翻sudo find /proc/[0-9]*/fd -lname *deleted* -ls 2/dev/null | awk {print $5, $6, $7, $8, $9, $10, $11, $12, $13}或者更直观一点sudo ls -l /proc/*/fd 2/dev/null | grep -i deleted要注意ls -l显示的路径后面会跟着(deleted)字样而文件大小这一列有时候会显示成 0 或者不准想让数字可靠用stat -L -c %s %n /proc/pid/fd/n去读-L是关键它让 stat 跟随符号链接去 stat 真正的 inode。提示find /proc/*/fd这个写法在进程数量多的时候会比较慢而且会产生大量权限报错务必加上2/dev/null并用 sudo 执行否则非 root 看到的 fd 是不全的容易漏掉大文件。2.4 释放空间的三条路以及各自的代价找到之后怎么处理取决于这个文件是什么、业务能不能中断。最干净的是让进程自己重开文件。日志类场景通常都可以nginx 用kill -USR1 master_pid多数 Java 应用支持SIGHUP或者提供 reload 接口systemd 管理的服务可以systemctl reload。这样进程会关掉旧 fd、打开新文件旧 inode 的引用计数归零空间立刻释放业务不中断。其次是从外部把文件截断。truncate -s 0 /proc/pid/fd/n可以让 inode 长度归零块随之释放进程的 fd 依然有效后续写入会从偏移 0 继续——对只追加写的日志来说完全没问题。等价的写法是: /proc/pid/fd/n。这招很好用但有两个前提必须确认。第一这个文件不能是被 mmap 映射的。如果进程用 mmap 把文件映射进内存截断后访问超出新长度的页会触发 SIGBUS进程可能直接崩掉。数据库文件、某些缓存实现都属于这一类动手前一定要搞清楚。第二如果文件里的数据还有审计或者取证价值先捞出来再截断sudo cp /proc/pid/fd/n /backup/orphan-$(date %F-%H%M).logcp读的是 inode 本身跟目录项无关能把内容完整救回来。这一步花不了几秒但能救命。最后才是重启进程。systemctl restart或者kill -9简单粗暴代价是业务中断而且kill -9会让进程没有机会冲刷缓冲区可能丢数据。除非这个进程状态不重要否则我一般放到最后考虑。还有一点要说清楚这三条路都是在止血不解决根因。如果不改日志轮转配置过几天还会再来一遍。真正要改的是让应用支持 reopen或者把 logrotate 改成copytruncate——它会先复制内容再截断原文件fd 保持有效不会产生幽灵文件代价是复制那一下有额外的 IO 峰值。3. 不是幽灵文件也能差出几十 G这些空间 du 天生看不见3.1 保留块不解释 Used 与 du 的差异前面提过保留块这里再强调一次它的边界因为混淆这一点会让人排查方向跑偏。保留块影响的是 df 输出里 Size、Used、Avail 三者的关系不影响 Used 与 du 的差异。原因是Used f_blocks - f_bfree而保留块是包含在f_bfree里的空闲块根本没进 Used。所以如果 df 的 Used 比 du 大 25G你不能拿5% 保留块去解释得继续往别处找。反过来说如果哪天你发现 Avail 比预想的少很多比如一块 500G 的盘空了 100G 但普通用户只能写 75G那就是保留块的锅。调整方式sudo tune2fs -m 1 /dev/mapper/vg-data把保留比例从 5% 降到 1%。但我不建议在根分区上降到 0留 1% 到 2% 是为了防止碎片化严重时系统连日志都写不进去那才是真的救不回来。XFS 没有保留块这个概念所以同样的现象在不同文件系统上表现不一样排查前先df -T看清类型。3.2 文件系统元数据和日志区du 只统计目录树里的普通文件和目录文件系统自己消耗的块它一概不算。ext4 上的开销主要有三块inode 表、日志journal、以及 extent 树和块位图这类管理结构。inode 表在mkfs时就按-N指定的数量分配好了每个 inode 默认 256 字节100 万个 inode 就是 256MB这部分空间在文件系统创建的那一刻就已经被占掉了。日志区更明显。dumpe2fs -h /dev/mapper/vg-data | grep -i journal能看到日志大小通常是 128M也可以用-J size在创建时指定更大的值。日志文件在文件系统内部有固定位置但它不在任何目录里du永远看不到它。这些开销加起来在几 T 级别的盘上通常是 1% 到 3%。也就是说一块 4T 的盘即使目录树里什么都没有df 也可能报几十 G 的已用。这不是故障是设计如此。3.3 被挂载点盖住的旧数据这是最容易被忽略、后果也最麻烦的一种。假设 /data 一开始是根分区上的一个普通目录你在里面放了 200G 数据。后来加了一块数据盘直接mount /dev/sdb1 /data。从这一刻起原来那 200G 数据还在根分区的块里但目录树被新的文件系统盖住了du -x /data只能看到新盘上的内容那 200G 就人间蒸发了。df 在根分区上照样算着那 200G。于是你看到根分区莫名其妙满了du 加来加去对不上。判断方法很简单看挂载关系findmnt -R /data findmnt -o TARGET,SOURCE,FSTYPE | awk $1 ~ /^\/data/如果发现有子目录被另一个设备盖住那就对上了。处理方式是先把数据盘 umount露出底层目录把旧数据清掉或者迁移走再重新挂载。注意 umount 前务必确认没有进程在写lsof D /data或者fuser -m /data检查一遍。容器环境里同样的坑换了个形态。/var/lib/docker/overlay2下面全是 mount 点宿主机的du -x /遇到这些挂载点会直接跳过看起来比 df 小一大截。这种时候要么针对底层文件系统单独统计要么用du -x配合明确的路径不要幻想一条命令能把容器层的账算清。3.4 稀疏文件、硬链接和 --max-depth 的计数陷阱du -h --max-depth1这个命令本身也有两个坑。第一--max-depth1的输出里包含一行是当前目录自己的总计把这一行和下面所有子目录行一起加起来结果直接翻倍。我见过有人这么算完得出du 比 df 大一倍的结论然后一路往错误的方向排查。正确做法是要么只加子目录行要么用du -x -s单独拿总计du -x --max-depth1 -B1 /data | awk NR1 {s$1} END {printf %.2f GiB\n, s/1024/1024/1024}第二稀疏文件的处理方式。对比文件系统占用和文件逻辑大小时这两个数在稀疏文件上差得很远du -h --apparent-size sparse.img # 逻辑大小通常很大 du -h sparse.img # 实际占用通常很小 ls -lh sparse.img # st_size stat -c %s %b sparse.img # %s 是字节%b 是块数512B 单位数据盘上如果有虚拟机镜像、数据库数据文件、预分配的日志文件这类差异累积起来能到几十 G。想知道全盘稀疏文件占了多少可以用find /data -type f -size 1G -printf %s %b %p\n | awk $2*512 $1/2 {print}粗筛一遍。4. 一次 87G 差异的完整排查链路4.1 先把现场信息固定下来回到开头那台机器。环境和现象Rocky 8LVM 上一块 500G 的 ext4挂载在 /data跑着一个 Java 数据处理服务日志写在/data/logs。第一步不是急着找文件而是把已知条件固定住避免后面边查边变df -hT /data df -i /data findmnt /data lsblk -f确认了几件事/data 确实是独立的 ext4 挂载点不是根分区的一部分inode 用了不到 3%排除 inode 耗尽df -hT显示 Used 468GAvail 6.4GSize 500G。顺手用df -B1 --outputsource,size,used,avail /data拿到字节级的精确数字后面算差值用这个比 G 单位靠谱。4.2 做减法一步步缩小范围第二步重跑 du加上-x和 sudo把权限和跨文件系统这两个干扰因素一次性排掉sudo du -x -h --max-depth1 /data 2/dev/null | sort -h结果一级目录加起来 381G差额确认还是在 87G 左右。因为已经加了-xdu 不会跨挂载点所以du 统计了别的盘这个方向可以划掉。第三步查覆盖挂载。用findmnt -R /data看有没有子目录被别的设备盖住。结果是空的/data 下面没有其他挂载点这条也划掉。第四步直接上 lsof 查幽灵文件这是所有步骤里回报率最高的一步sudo lsof -nP L1 2/dev/null | awk NR1 $70 1073741824 {printf %8.2f GB pid%s %s %s\n, $7/1024/1024/1024, $2, $1, $9}输出里有一行82.31 GB pid18422 java /data/logs/app.log (deleted)。到这里基本就锁定了。为了确认这个 fd 是真的还在被写看一眼文件偏移量有没有在涨sudo ls -l /proc/18422/fd | grep deleted sudo cat /proc/18422/fdinfo/7 | grep pos sleep 10 sudo cat /proc/18422/fdinfo/7 | grep pos两次 pos 不一样说明进程确实还在往这个已经删掉的文件里追加实锤。4.3 处理与验证82G 加上原来就存在的元数据开销用dumpe2fs -h估了一下大概 4G 左右86G 出头跟差额的 87G 基本吻合剩下的零点几 G 是 du 因为权限漏掉的零碎文件。数字对上了就可以动手。因为这个服务支持优雅重载联系方式也简单我选了让日志重开的路径先给进程发信号让新的日志文件生成再确认旧的 fd 关闭。sudo systemctl reload>df -hT -x tmpfs -x devtmpfs -x squashfs看 inode 有没有被小文件吃光这条在邮件队列、缓存目录、session 文件多的机器上非常关键df -i -x tmpfs -x devtmpfs定位大头目录注意-x和排序sort -h能正确处理人类可读的单位sudo du -x -h --max-depth1 / 2/dev/null | sort -h | tail -20如果机器上允许装额外的工具ncdu比什么都好用交互式翻目录能立刻看出是哪一层出了问题sudo ncdu -x /data-x这个参数我要再强调一遍。不加的时候du 会跟着 mount 一路钻进去把 tmpfs、容器、数据盘全都算到一起得出的数字在物理上毫无意义。凡是做容量统计先加上-x需要看某个具体挂载点再单独指定路径。5.2 目录容量统计的正确姿势手工核对 df 和 du 的时候有几个细节能省掉大量返工。单位统一用字节避免 h 单位四舍五入带来的几百兆误差df -B1 --outputused /data sudo du -x -s -B1 /data用字节做差两个数字的差就是需要解释的部分精确到字节不用猜。算目录汇总的时候记得跳过总计行前面已经给过 awk 的写法。还有跑 du 一定要用 sudo非 root 跑出来的数字偏小而且是那种看起来合理但就是差一截的偏小最容易被当成幽灵文件的证据实际上只是权限问题。统计用st_blocks还是逻辑大小取决于你想回答什么问题。想知道磁盘快满了谁占的用默认的块统计想知道按业务逻辑算某个目录应该占多少加--apparent-size。这两个数在稀疏文件多的场景下能差出一倍混用会得出自相矛盾的结论。5.3 监控告警该以哪个数为准最后说告警。容量告警必须用 df因为只有 df 反映的是文件系统真实可写的剩余空间。du 不能用来做告警原因有三个它慢在几 T 的盘上跑一遍可能几分钟它需要 root很多监控 agent 以非特权用户运行它看不到幽灵文件一块盘已经 100% 了 du 可能才报 60%告警永远不触发。正确的组合是df 负责要不要报警du 负责报警之后查谁。df 的阈值一般设在 80% 到 85%同时把 inode 使用率也配上小文件多的场景 inode 先满的情况不少见。du 可以做成定时任务每天凌晨跑一次一级目录统计把结果写到本地文件或者推给监控出问题时直接看历史曲线不用临时登录去跑。日志这块的预防措施值得单独做。限制 systemd 日志总量# /etc/systemd/journald.conf SystemMaxUse1G SystemMaxFileSize100M然后systemctl restart systemd-journald生效。应用日志用 logrotate 时要么配copytruncate要么在postrotate里给进程发信号让它 reopen两个选一个别两个都不做。定期扫一遍幽灵文件也值得做成巡检项sudo lsof -nP L1 2/dev/null | awk NR1 $70 1073741824 {printf %8.2f GB pid%s %s %s\n, $7/1024/1024/1024, $2, $1, $9} echo no orphan large file这条命令跑起来很快加到每日巡检里成本几乎为零但能把 90% 的空间莫名消失在爆发前拦住。我个人在几次事故之后最深的体会是容量排查这件事顺序比技巧重要。先确认两个命令的口径可比再加-x和 sudo 把干扰因素排除最后才去怀疑幽灵文件。跳过前面两步直接上 lsof十次有八次是在浪费时间。而只要在日志轮转和定期巡检上花半天时间做一次规范化这类半夜被叫醒的事基本就不会再发生了。