前几天一台跑着两个内容站的机器突然连不上 SSH,只能从服务商后台进 VNC。登进去第一件事是 df -h,根分区 100%。按常规流程删了几个日志文件、清了 /tmp,df -h 又变成 100%。这时候很多人会开始怀疑 df 本身有问题,其实更常见的情况是:文件被删了,但引用还在,磁盘空间根本没收回来。
这篇文章把"df 显示满、du 算出来不满、删了也不降"这一类问题从原理到处置完整走一遍。目标不是记住几条命令,而是知道每一步在验证什么,下次遇到不会被带着绕圈。
一、先分清两种"满":空间满和 inode 满
Linux 里一个文件占两样资源:数据块(block)和索引节点(inode)。块满了写不进内容,inode 满了连创建空文件都会失败。两种满的表现很像,但处理方式完全不同,所以第一步永远是两个命令一起看:
df -h
df -idf -h 看的是块使用率,df -i 看的是 inode 使用率(IUse / IFree)。如果 df -h 只有 60% 而 df -i 是 100%,那就是 inode 耗尽,跟文件大小毫无关系。典型场景是会话文件、缓存碎片、邮件队列目录里堆积了几百万个几 KB 的小文件。
inode 耗尽的排查方向是"找文件最多的目录",而不是"找最大的文件":
for d in /var/* /www/* /home/*; do
printf "%8s %s\n" "$(find "$d" -xdev -type f 2>/dev/null | wc -l)" "$d"
done | sort -rn | head -20-xdev 很重要,它阻止 find 跨文件系统,否则扫到挂载点时会把别的盘的内容也算进来,跑得又慢又不准。找到文件数最大的目录后,再按子目录拆一层,通常两三层就能定位。
还有一种容易被忽略的组合:块和 inode 都在涨,但 df 的 Use% 和 du -sh / 的结果差了 5 GB 以上。这就是本文要重点讲的情况。
二、df 与 du 差出来的那部分,是"已删除未释放"
先把两个命令的口径说清楚,这是理解差异的前提:
df读的是文件系统的超级块元数据,统计的是已分配但未释放的块,它不管这些块现在有没有名字指向。du遍历目录树,把所有存在的目录项指向的文件大小加起来,它只看得见有名字的文件。
Linux 删除文件的语义是 unlink:从目录里摘掉那个名字,把 inode 的链接计数减一,只有在链接计数归零、且没有任何进程打开它时,块才真正归还。所以只要有一个进程还拿着这个文件的描述符,文件对 du 不可见,对 df 却仍然算数。
这个语义在日志场景下几乎是必然发生的。假设 Nginx 的 worker 进程正往 access.log 写,你用 rm access.log 删掉它,进程手里的 fd 还指着那个 inode,它会继续往那块磁盘写,写到进程重载为止。这就是"删了日志磁盘不降"的完整解释。
顺带说一个更隐蔽的形态:mv access.log access.log.1 然后 kill -USR1 nginx 这种"传统轮转方式"里,如果 USR1 没送到或者被发到了 master 以外的地方,worker 就一直写着旧的 inode,新的 access.log 由 master 重新打开、几乎不长大,于是你会看到"归档文件每天长几百 MB,当前日志是 0 字节"。这个现象的排查入口不是磁盘,而是 ls -l /proc/<pid>/fd/ 里 fd 指向的文件名。
三、lsof +L1:把藏起来的洞找出来
定位命令本身很短:
lsof +L1 2>/dev/null+L1 的意思是列出链接计数小于 1 的打开文件,也就是"已经被删掉但仍被进程打开"的那些。输出里重点看三列:COMMAND(谁占着)、PID(进程号)、SIZE/OFF(有多大)。
如果机器上没装 lsof,可以用 /proc 直接查,效果一样,还不依赖包管理:
for pid in /proc/[0-9]*; do
for fd in $pid/fd/*; do
tgt=$(readlink "$fd" 2>/dev/null) || continue
case "$tgt" in
*"(deleted)"*)
sz=$(stat -Lc %s "$fd" 2>/dev/null || echo 0)
echo "$sz ${pid#/proc/} $tgt"
;;
esac
done
done | sort -rn | head -20这个脚本的好处是可以按大小排序,一屏之内就看出哪个洞最大。stat -Lc %s 里的 -L 是顺着符号链接看真实 inode,不加的话拿到的是链接本身的大小(通常是几十字节),会误判成"没什么大文件"。
实测中 +L1 一个都不返回,但 df 和 du 仍然差好几 GB,那就不是 unlink 未释放,而要往下面几个方向查:
- 挂载点被覆盖:某个目录上又 mount 了一个文件系统,原来的内容被"盖住"了,du 看不到、块还占着。用
grep -E " /(www|var)" /proc/mounts核对挂载表,再看du -x与不带-x的差异。 - 文件系统预留块:ext4 默认给 root 预留 5%,一块 100 GB 的盘就是 5 GB。这部分
df的 Use% 里算了,但普通用户写不进去。tune2fs -l /dev/vda1 | grep -i reserved可以看到具体块数。数据盘上这一项完全可以调到 1%:tune2fs -m 1 /dev/vda1。 - 块大小与稀疏文件:du 报的是实际占用块,ls 报的是逻辑大小,两者本来就不同,方向相反时容易误判。
- 快照 / LVM 层:宿主机或云盘做了快照,快照占的空间体现在底层,容器里一行都看不到。
四、三种回收方式,按侵入性排序
确认了是"已删除未释放"之后,有三种收法。选择依据是:能不能重启、这个进程重载是否安全、是不是正在写关键数据。
方式一:把 fd 内容清空(最安全)
: > /proc/<pid>/fd/<fd>这个操作是把该 inode 的内容截断为 0,磁盘立刻回收,进程手里的 fd 依然有效,接下来继续往同一个位置写也不会报错。对日志类文件这是最推荐的处置:不重启、不丢句柄、不会让进程崩掉。注意必须用 : >(重定向)而不是 > 单独用,否则 shell 会去创建同名文件。
有一点要提醒:这会把还没被采集走的内容一起清掉。如果这个日志正在被 Filebeat、Promtail 之类的采集器追,先确认采集进度,或者先做一次 curl -X POST http://127.0.0.1:9080/-/reload 之类的 flush。
方式二:让进程重新打开文件(平滑)
对 Nginx 是 nginx -s reopen 或者 kill -USR1 <master_pid>;对很多服务是 systemctl reload;对 PHP-FPM 是 kill -USR2 <master_pid>(注意不是 USR1,PHP-FPM 的 USR1 是 reopen 日志,USR2 才是平滑重载)。重载之后旧 worker 退出,旧 inode 的引用归零,块自动归还。
这个方式的优点是日志被完整切到新文件,历史内容不丢;缺点是要求服务支持重载,而且重载本身在极端情况下会引入短暂抖动。
方式三:重启进程(最后手段)
只有前两种都不适用时才走到这里,比如占着洞的进程已经不响应信号了。重启会丢连接、可能丢内存里的会话,纯静态站问题不大,有状态的业务要挑时间窗口。
五、根治:别再让 rm 出现在轮转脚本里
上面三种都是止血,真正要改的是"为什么会走到这一步"。绝大多数现场事故都来自同一个模式:手动 rm 日志,或者轮转脚本写得不对。
logrotate 的正确姿势是 create 或 copytruncate 二选一,两者的差别必须理解清楚:
- create:先把当前文件改名为 .1,然后创建一个同名新文件,接着通过
postrotate通知进程重新打开。适用于能 reload 的服务。Nginx 的标准写法就是postrotate ... nginx -s reopen ... endscript。这种模式最干净,日志被完整切走。 - copytruncate:先复制当前内容到 .1,然后把原文件截断为 0。进程不用重载,但复制和截断之间有一个窗口,这段时间写入的几十条日志会丢。适用于不支持 reload 的程序,或者你不想为轮转动服务。
绝不能出现的组合是:轮转里 rm 掉文件但没有任何 postrotate 信号。这种脚本跑一次就制造一个洞,跑三个月能吃掉十几 GB。
一个自检的办法是每次改完轮转配置后手动触发一次,然后五分钟内查一遍洞:
logrotate -d /etc/logrotate.d/nginx # 先 dry-run 看它打算做什么
logrotate -vf /etc/logrotate.d/nginx # 真跑一次
sleep 300
lsof +L1 2>/dev/null | awk '$7 > 104857600 {print}'-d 只打印不执行,能提前看出配置路径写错、通配符没匹配上这类问题。-v 加 -f 是强制真跑。跑完等几分钟再看洞,是因为有些程序要等到下一次写才暴露问题。
六、把这件事变成可观测的
靠人肉发现"df 和 du 差了好几 GB"不是长久之计。两个低成本的监控项就能覆盖:
- 根分区使用率超过 85% 告警,比"到 95% 再告警"有用得多,因为 85% 时你还有时间从容处理,95% 时 MySQL 可能已经写不进去了。
- df 与 du 的差值做成指标。写个 cron 每十分钟采一次,差值连续三次超过 2 GB 就发通知。这个指标能把 unlink 泄漏在变成事故之前抓出来。
#!/bin/bash
# /usr/local/bin/disk-delta.sh
set -u
dffree=$(df -B1 --output=used / | tail -1)
duused=$(du -sB1 -x / 2>/dev/null | cut -f1)
delta=$(( dffree - duused ))
echo "$(date +%s) df_used=$dffree du_used=$duused delta=$delta"
if [ "$delta" -gt 2147483648 ]; then
echo "WARN: df-du delta over 2GB, possible unlinked open files" >&2
lsof +L1 2>/dev/null | sort -k7 -n -r | head -5
fi把它放进 crontab,输出重定向到一个按天归档的文件里。这个脚本本身占用的 IO 也不小(du 要遍历整个根分区),所以频率别设得比十分钟更密,并且强烈建议配 nice -n 19 ionice -c3 降低优先级。
七、一次完整的处置顺序
把上面所有内容压成一个可以照着走的顺序,遇到磁盘满时按这个来,不会乱:
df -h与df -i同时看,先排除 inode 耗尽。du -x -h --max-depth=1 /找出最大的顶层目录,逐层收敛到具体路径。- 如果
du找到的量和df对不上(差 1 GB 以上),跑lsof +L1。 - 有洞:优先
: > /proc/pid/fd/n截断,其次 reload 进程,最后才重启。 - 没有洞:查挂载覆盖(
/proc/mounts)、ext4 预留(tune2fs -l)、快照占用。 - 处置完立刻改轮转配置,加
postrotate信号,并把copy_while_rotating这类不合规写法清掉。 - 补上磁盘使用率与 df/du 差值两个监控项,避免下次还是靠 VNC 才发现。
这套顺序背后的原则只有一条:先分清"哪一层在说谎",再动手删东西。磁盘满的现场最忌讳的是慌乱中 rm -rf 一个大目录,那往往会把日志证据和还在用的文件一起删掉,让问题从"空间不够"升级成"服务起不来"。多花两分钟看清 df 和 du 的差异,比事后恢复数据划算得多。
最后补充一个习惯:给关键分区单独挂载,把 /var/log 和 /www 放在独立卷上。这样即使日志把盘写满,也不会连带影响数据库和站点根目录,给排查争取的时间远比任何命令都值钱。