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 使用率,只盯容量会漏掉整整一类故障。
  • 凡是「按请求生成小文件」的目录,都要有生命周期上限,这是比清理更根本的解法。
查看「工程效能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。