磁盘写满的四步定位:df 找出挂载点、du 找出目录、lsof 找出已删除未释放、df -i 找出 inode
磁盘写满时最耗时的分支是"删了文件但空间没回来",其次是"看哪个目录都只有几十兆,df 却报 100%"。我按四步走:df -h 先确认是哪个挂载点和哪个维度(块还是 inode),du -x 在同一个文件系统内逐层下钻,lsof +L1 抓已删除但仍被进程持有的文件,最后 df -i 排除 inode 耗尽。每一步都能证伪上一步,走完这四步还没结论,才需要考虑快照、日志文件系统保留块这类更冷的原因。
四步顺序与每步的判断
| 步 | 命令 | 看什么 | 结论 | 反证(这步正常意味着) |
|---|---|---|---|---|
| 1 | df -h | Use% 接近 100% 的挂载点 | 锁定文件系统,记下 Mounted on | 空间充足,问题不在块占用 |
| 1b | df -i | IUse% | IUse% 高而 Use% 低即 inode 耗尽 | inode 充足,排除小文件海量问题 |
| 2 | du -xhd1 <挂载点> | 第一层里最大的目录 | 逐层下钻到具体目录 | 目录合计远小于 df 的 Used,进入第 3 步 |
| 3 | lsof +L1 | (deleted) 行、PID、NODE | 有进程持着已删除文件 | 无输出,空间确实没被占用 |
| 4 | 清理 | kill 进程或让它重开文件 | 空间回收 | — |
可复制的四步:
df -h # 1. 哪个挂载点满了
df -i # 1b. 是块满还是 inode 满
du -xhd1 /var 2>/dev/null | sort -h # 2. -x 不跨文件系统,逐层下钻
lsof +L1 | head -20 # 3. 已删除未释放(+L1 = link count < 1)
stat -Lc 'size=%s blocks=%b' /proc/<pid>/fd/<n> # 4. 核实真实占用
一次 1 GiB 的实测:df 和 du 差了整整 1 GiB
在 debian:12-slim 容器里(overlay 根,1007 GiB)写入 1 GiB,再让一个进程以只读方式持有它并删除:
# 写入前
used_kb_before=18662976
# dd 1 GiB + sync 后
used_kb_after_write=19711556 # 差 1048580 KB ≈ 1 GiB
# 以只读 fd 持有后 unlink
used_kb_after_unlink=19711556 # 一点没回
$ du -sh /tmp/demo
4.0K /tmp/demo # 目录已经是空的
$ lsof +L1
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
sleep 3898 root 3r REG 0,97 1073741824 0 45751 /tmp/demo/big.bin (deleted)
$ stat -Lc 'size=%s blocks=%b' /proc/3898/fd/3
size=1073741824 blocks=2097160
# kill 3898 之后
used_kb_after_kill=18662976 # 精确回到写入前的值
这组数字说明两件事:du 报 4.0K 是对的(目录里确实没有文件了),df 报 1 GiB 被占也是对的(inode 还活着,块没释放)。两者不矛盾,差的正是那个 (deleted) 文件。NLINK 为 0 是 lsof +L1 能抓到它的原因。
四个坑
第一,把 df 与 du 的差额当成 du 算错。只要差额恰好等于某个"删掉的大文件",基本就是已删除未释放。判断依据是 df 的 Used 在删除前后没有变化,而不是 du 的数字。
第二,du 不加 -x。默认会跟着进入其他文件系统,把 bind mount、overlay 下层、容器挂载点反复统计,结果可能比 df 大得多或者小得多。统一用 du -xhd1,只在当前文件系统内下钻。需要看真实占块而非表观大小时,注意 du 默认报的是设备占用,-b/--apparent-size 报的是表观大小,稀疏文件两者能差出几个数量级。
第三,只看 lsof 的 SIZE/OFF 列判断占用。这一列读的是文件当前的 inode 大小,如果这个文件曾被 O_TRUNC 打开过,它就会显示 0,而 df 上仍可能有历史占用。真正可信的是 /proc/<pid>/fdinfo 和 df 的前后差值。我第一次做这个实验时就踩了:用 exec 3>file 打开,重定向把 1 GiB 文件截成了 0,df 随之回落,看上去像"删除即释放",换成 exec 3<file 才复现出真正的持有场景。
第四,df -h 很空但写入失败,是 inode 用完了。本机 df -i / 显示 Inodes 67108864, IUsed 88674, IUse% 1%,容器里这个数字来自 overlay 下层,未必代表你自己的数据盘。inode 耗尽的典型来源是小文件堆积(session 文件、邮件队列、/var/spool 下的临时文件),此时 du -sh 总量不大,但 df -i 已经 100%。
小结
四步的价值在于每步都有明确的证伪条件:df 正常就不必修磁盘,df -i 正常就不用找小文件,du 合计与 df 对得上就不用查 lsof,lsof 没有 (deleted) 行就说明空间确实不在这些进程手里。这样走下来,绝大多数"空间去哪了"能在两分钟内收敛到具体目录或具体进程。
清理时优先让进程重开文件(kill -HUP、重启服务、日志轮转),而不是直接 kill -9。对于不方便重启的进程,truncate -s 0 /proc/<pid>/fd/<n> 也能释放块,但这属于应急手段,事后要补上日志轮转或 copytruncate 之类的配置,否则同样的问题会再来一次。
还有一类高频来源值得单独提:被 tail -f、docker logs -f、日志采集 agent 打开着的日志文件。运维删掉文件以为腾出了空间,采集进程的 fd 还指着旧 inode,df 一点都不降。判断方法和上面完全一样,lsof +L1 的 COMMAND 列会直接写出是哪个采集器。此时正确处理是重启采集器或让它重开文件,而不是继续删别的文件。
inode 上限则要在建模阶段就确认:df -i 的 IUsed% 和 IFree 是这个文件系统创建时定下的,事后不能在线扩容(部分文件系统支持,代价不小)。小文件密集的服务(缓存目录、邮件队列、CI 的临时工作区)应当单独挂一个 inode 充足的卷,避免把根文件系统一起拖进 inode 耗尽。
数据来源与检索时间
| 来源 | URL | 对应数据 |
|---|---|---|
| man7.org · df(1) | https://man7.org/linux/man-pages/man1/df.1.html | -i 列 inode 用量、-h、--output 字段、-t 限定文件系统类型 |
| man7.org · du(1) | https://man7.org/linux/man-pages/man1/du.1.html | -x 不跨文件系统、-d 深度、-b/--apparent-size 与设备占用的区别 |
| man7.org · lsof(8) | https://man7.org/linux/man-pages/man8/lsof.8.html | lsof 列出打开文件、-p 限定进程、输出列含义(FD/TYPE/DEVICE/NLINK/NODE) |
| man7.org · free(1) | https://man7.org/linux/man-pages/man1/free.1.html | 页缓存与 buff/cache 的关系(判断"内存被缓存吃掉"时的对照) |
检索时间:2026-11-10(Asia/Shanghai)。
核验说明
- 真跑:1 GiB 写入、只读 fd 持有后
unlink、df差值、du -sh、lsof +L1、stat -L /proc/<pid>/fd/3、kill后空间回收,全部在debian:12-slim容器内实测,数值为原始输出。 - 真跑:一次失败的经验也保留在文中——用
exec 3>file会截断文件,导致df回落、lsof的SIZE/OFF显示 0;换成exec 3<file才复现出空间不回收。 - 真跑:
df -i /的 inode 总量与使用率(67108864 / 88674 / 1%)为容器内实际输出。 - 仅依据文档,未在本机运行:inode 耗尽场景未复现。容器内执行
mount -t tmpfs tmpfs /mnt返回permission denied(无 CAP_SYS_ADMIN),无法创建可独立耗尽的文件系统;该节结论依据df(1)的-i语义与一般经验,并已标注。