df 还剩 42 GB,却报 No space left on device:inode 与已删除文件
磁盘明明还有 42 GB,日志却写不进去,df -i 显示 inode 已经 100%。三百多万个小文件来自 Postfix 队列和容器日志;另一类是同名报错但 du 和 df 对不上,要用 lsof +L1 找被进程占用的已删除文件。
凌晨两点,日志采集 agent 停止上报。应用日志里刷的最后一行是 write /var/log/app/access.log: no space left on device。
我敲 df -h,根分区显示还有 38% 可用,42 GB。du -sh /var/log 加起来不到 200 MB。当时的第一反应是文件系统坏了,甚至准备申请重启机器。
第二个命令救了我:df -i。IUse% 是 100%。
磁盘空间指的是「字节够不够」,inode 指的是「还能不能新建文件」。这两件事在 ext4 上是分开记账的,一个用尽不影响另一个显示。那天写不进去,是因为根分区的 320 万个 inode 用完了,而字节还剩一大半。
一、现象:两个命令给出相反的答案
同一台机器,一分钟内的两条输出:
$ df -h /
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 100G 58G 42G 58% /
$ df -i /
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vda1 3276800 3276800 0 100% /
df -h 说还有 42 GB,df -i 说一个 inode 都不剩。日志写入需要「新建文件」或者「追加并可能触发 rotate」,一旦 inode 耗尽,open(O_CREAT) 就会以 ENOSPC 失败——注意这个错误码就是 No space left on device,所以报错信息会让你以为是空间问题。
把常见组合整理一下,排查时可以对着看:
| 场景 | df -h |
df -i |
真实原因 |
|---|---|---|---|
| 大文件写满 | 100% | 正常 | 真没空间了 |
| 大量小文件 | 正常 | 100% | inode 耗尽 |
| 删除后空间没回来 | 100% | 正常 | 文件被进程占用未释放 |
| 保留块被非 root 挡 | 有 5% | 正常 | ext4 默认给 root 留 5% |
第三行是另一个高频坑,后面单独说。这次遇到的是第二行。
二、inode 被谁吃掉了
inode 是文件系统给每个文件/目录分配的元数据结构,存权限、时间戳、数据块指针。ext4 在 mkfs 时就固定了 inode 总数,用完之后即使有空间也建不了新文件。
327 万个 inode 被吃掉,说明有海量小文件。我按目录逐层找:
# 找出 inode 数最多的目录(从 / 开始,深度 3)
find / -xdev -type d -maxdepth 3 2>/dev/null | while read d; do
echo "$(find "$d" -xdev -maxdepth 1 2>/dev/null | wc -l) $d"
done | sort -rn | head -20
-xdev 很重要,否则会跨到 /proc、挂载的容器卷里去,跑不完还误导人。
结果第一名的目录是 /var/spool/postfix/deferred,7 个小时里堆了 210 万个文件。
原因很快查清:这台机器上有个脚本把「发送告警邮件」当成了主通道,而 SMTP 是通的但投递一直被对端拒收(451 4.7.1 Try again later)。Postfix 把每封信放进 deferred 队列重试,每封信一个文件,重试周期 5 分钟。一次批量任务失败会发几百封,几千次失败之后就是百万级文件。
第二名是 /var/lib/docker/overlay2,容器日志按 10 MB 切分、保留 5 份的配置只写在了一个 compose 文件里,另外几个直接 docker run 起的容器用的是默认的 json-file 无限增长,切出来的碎片文件数量也很可观。
第三名才是应用日志目录,/var/log/app,按小时切分并且压缩,一年下来十几万个文件。
这三个加起来,就是 320 万。
三、先止血:删文件要讲顺序
百万级文件不能直接 rm -rf 目录。rm 会在目录项上做大量随机 IO,一个 200 万文件的目录删起来可能跑几个小时,期间磁盘 util 100%,业务跟着抖。
我用的顺序是:
第一步,停掉源头。 先 postfix stop,不然边删边生成,永远删不完。
第二步,按时间分片删。 用 find -delete 逐个处理,避免一次展开整个目录:
# 先统计,不删
find /var/spool/postfix/deferred -type f -mtime +1 | wc -l
# 再分批删,每批 5 万个,删完等一下让 IO 回落
find /var/spool/postfix/deferred -type f -mtime +1 -print0 \
| split -z -l 50000 - /tmp/pf-batch-
for f in /tmp/pf-batch-*; do
xargs -0 rm -f < "$f"
sleep 5
echo "$(date +%H:%M:%S) done $f, ifree=$(df -i / | awk 'NR==2{print $4}')"
done
每批之后打印剩余 inode,能直观看到进度,也方便发现「删了但没减少」的异常。
第三步,删空的子目录。 deferred 下面按 A/B/C 三层哈希分目录,文件删完会留下空目录,也要一起清:
find /var/spool/postfix/deferred -type d -empty -delete
这里有个顺序问题:先删文件再删目录。反过来会先把分目录去掉,剩下的文件路径失效但 inode 还占着,反而更难清。
整个过程跑了 52 分钟,IUse% 从 100% 降到 41%。业务恢复。
四、另一类更隐蔽的:删了但空间没回来
inode 那次之后一周,另一台机器出现了「df -h 100%,du 加起来只有 30G」的情况。这类问题的根因是:文件被删除后,如果还有进程持有它的文件描述符,数据块不会释放。
日志文件被 rotate 改名删除,但写日志的进程还开着 fd,于是一个「已经不存在的文件」继续占着 60 GB。
# 找出被删除但仍被占用的文件,按大小排序
lsof +L1 2>/dev/null | awk 'NR==1 || $7 > 1073741824' | sort -k7 -rn | head
# 没有 lsof 时的等价做法
for p in /proc/[0-9]*; do
ls -l "$p/fd" 2>/dev/null | grep deleted
done | awk '{print $NF, $0}' | sort -k1 -rn | head
输出里能看到类似 java 1234 root 3w REG 253,1 64424509440 1234567 /var/log/app/app.log (deleted)。60 GB 的文件,进程还开着 3w。
处理办法有两个,按风险从低到高:
| 办法 | 操作 | 影响 |
|---|---|---|
| 不重启恢复 | : > /proc/1234/fd/3 清空内容 |
无影响,立即释放 |
| 让进程重开文件 | kill -USR1(需应用支持)或滚动重启 |
短暂不可用 |
| 最粗暴 | 重启进程 | 有中断 |
truncate 那种 : > /proc/pid/fd/3 的办法能立刻把那 60 GB 还回去,代价是日志里对应的那段内容彻底没了。如果还想要数据,得在同一文件系统上先 cp /proc/pid/fd/3 /tmp/x.log(等于又占一份空间,空间紧张时不可行)。
五、防复发:把 inode 纳入监控
这次两个事故的共同点是:现有监控只盯 df -h,而告警阈值是 85%。inode 和 deleted 文件都不在视野里。
补了三件事:
一是把 df -i 加进采集。 node_exporter 的 node_filesystem_files_free 直接就是可用 inode 数,配一条规则即可:
groups:
- name: filesystem
rules:
- alert: InodeUsageHigh
expr: |
(1 - node_filesystem_files_free{fstype!~"tmpfs|overlay"}
/ node_filesystem_files{fstype!~"tmpfs|overlay"}) > 0.85
for: 10m
labels:
severity: warning
annotations:
summary: "{{ $labels.instance }} {{ $labels.mountpoint }} inode 使用率过高"
二是给容器日志加上限。 在 /etc/docker/daemon.json 里统一配,比每个 docker run 都记得加参数可靠得多:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
}
}
三是给 deferred 队列设上限。 Postfix 可以限制队列文件总数和单封信的存活时间:
postconf -e 'maximal_queue_lifetime = 2h'
postconf -e 'bounce_queue_lifetime = 2h'
postconf -e 'message_size_limit = 10240000'
postconf -e 'queue_run_delay = 5m'
maximal_queue_lifetime 从默认 5 天降到 2 小时,意味着一封投不出去的信最多留 24 次重试,而不是 1440 次。这一条直接把文件数量压下去两个数量级。
顺带把「告警邮件」从主通道降级成兜底通道。真正的告警走 webhook,邮件只在 webhook 全挂的时候发——减少了一个「越故障越发信、越发信越故障」的正反馈。
六、/var/spool 之外,还有哪些地方会长 inode
排查时我把几个高频目录列了个清单,下次遇到直接照着看:
| 目录 | 典型产物 | 治理方式 |
|---|---|---|
/var/spool/postfix |
邮件队列 | 限制队列生命周期 |
/var/lib/docker/overlay2 |
层、容器日志 | 日志轮转 + 定期 prune |
/tmp |
会话、上传临时文件 | 启动清理 + tmpfiles.d |
/var/log |
按小时/按请求切分 | 集中采集后本地只留 3 天 |
| 应用 sess 目录 | PHP/Python session 文件 | 定期 GC,别放本地盘 |
/var/cache/yum、apt |
包缓存 | 定时 clean all |
这张表贴在了值班手册里。判断标准很简单:只要一个目录「按请求数或按时间产生一个小文件」,它就有长成百万级目录的潜力。
小结
No space left on device不等于没空间,第一个动作应该是df -i而不是du。df -h和df -i是两本账,ext4 的 inode 在格式化时固定,用完了连空文件都建不了。- 删百万级文件要分批、先停源头、先删文件再删空目录,否则 IO 会拖垮整机。
du和df对不上时查lsof +L1,被进程占用的已删除文件不会释放数据块。- 监控上必须补 inode 使用率,只盯容量会漏掉整整一类故障。
- 凡是「按请求生成小文件」的目录,都要有生命周期上限,这是比清理更根本的解法。
正在加载评论…