服务器磁盘没满却写不进去排查实战:inode 耗尽诊断、find -printf 目录计数与自动清理脚本

问题现象:磁盘没满,网站却写不进数据

某个周五晚上,监控群突然炸了。Nginx 返回 500,PHP-FPM 日志里刷着同一句话:No space left on device。登上去一看 df -h,根分区只用了 62%,还剩 8GB 可用。这种"磁盘明明没满却报空间不足"的情况,十有八九不是磁盘容量问题,而是 Linux 的共享 inode 池耗尽了——你的分区上可能一个字节都没浪费,但已经没有"名额"再创建一个新文件了。

这篇文章不讲泛泛的理论,只讲一件事:一个正在稳定运行的个人站点,如何在十几分钟内定位这类故障,并且用几条命令当场止血,再配合长期监控避免第二次翻车。

先理解 inode:文件系统真正的记账单位

很多人对文件系统的认知停留在"一个大池子,往里塞文件"。实际上 ext4、XFS 这类文件系统在格式化时会把磁盘切成两部分:数据块(data block)和索引节点(inode)。数据块存文件内容,inode 存元数据——权限、属主、时间戳、大小,以及"这个文件的数据块都在哪儿"。

关键点在于:一个文件必然占用至少一个 inode。inode 的总数在 mkfs 那一刻就固定死了,运行时不能动态增加(除非重建文件系统)。这就解释了一个反直觉的现象:

  • 你往服务器上放 100 个 4GB 的镜像文件,可能只用掉 100 个 inode;
  • 但缓存目录里生成 200 万个 1KB 的会话文件,数据只占 2GB,inode 却烧掉了 200 万个。

个人站长最常踩这个坑的场景有三个:一是 PHP 的 session 目录(默认 /var/lib/php/sessions)在爬虫高频访问下疯狂生成小文件;二是自己写的缓存系统按 URL 哈希落盘,从来不清理;三是日志按请求切分,每来一个请求就 touch 一个文件。这些程序都"很省空间",但极耗 inode。

三分钟诊断:确认到底是不是 inode 耗尽

第一个命令,同时看空间和 inode,这一步足以分清 90% 的故障:

df -h /
df -i /

-h 看的是容量,-i 看的是 inode。输出里关注两列:IUsed(已用 inode)和 IUse%(使用率)。如果容量那一行显示 Use% 只有 60%,而 IUse% 是 100%——故障确认,不是磁盘满,是名额满。

如果你的站点分了独立分区(比如 /var、/home),一定要逐个分区看,因为 inode 池是按分区独立的:/home 用光了 inode,/ 再空也救不了写向 /home 的进程。

第二个命令,找出是谁把名额吃掉的。inode 耗尽的经典特征是"文件特别小、数量特别多",所以按目录统计文件数量比按大小统计有用得多:

# 按目录统计文件数量,列出最多的前 15 个
find /var -xdev -type f -printf '%h%x' 2>/dev/null \
  | sed 's|%x.*||' | sort | uniq -c | sort -rn | head -15

这里 -xdev 很关键,它让 find 不跨文件系统,避免统计时顺带爬进挂载的备份盘或者 NFS 共享,既拖慢速度又干扰结果。-printf '%h%x' 只输出"文件所在目录 + 扩展名",再交给 sed 去掉扩展名、uniq -c 计数,这样得到的是"哪个目录文件最多",而不是"哪个文件最大"。

如果你只想知道某个具体目录的文件总数,用这个更直接的写法:

find /var/lib/php/sessions -type f | wc -l

第三个命令,应对"删了文件 inode 却没释放"的诡异情况。如果 df -i 显示已满,你删掉了大量文件,但 IUse% 纹丝不动,说明有进程还持有这些文件的句柄:

# 列出被删除但仍被占用的文件
lsof +L1 2>/dev/null | head -20

+L1 的意思是"链接数小于 1",也就是文件已经在目录树里消失了,但某个进程的 fd 还指着它。此时 inode 不会回收,空间和名额都不会还给你。解决方案不是删文件,而是重启那个进程——它的 fd 一关,系统立刻回收。这也是为什么"删了日志磁盘还是满"的故障,重启服务比删文件有效。

止血:不重启服务的前提下腾出 inode

线上故障讲究"先恢复、再分析"。如果此刻站点已经写不进去,按以下顺序操作,每一步都可以单独生效、随时中断:

第一步,清理明确的临时目录。PHP session、应用缓存、临时上传目录通常可以安全清空:

# 先看有多少(确认量级,避免误删非预期内容)
find /var/lib/php/sessions -type f | wc -l

# 清理 3 天前的会话文件:用 -mtime +3 而不是全删,避免踢掉在线用户
find /var/lib/php/sessions -type f -mtime +3 -delete

注意这里用 -mtime +3 而不是无脑清空。全删会让当时所有在线用户的登录态瞬间失效,用户体验和日志都会很难看。-delete 是 find 内置动作,比 -exec rm {} \; 更快,因为它不需要为每个文件 fork 一次进程。

第二步,处理超量单目录。如果某个目录里有上百万个文件,rm -rf 会卡死甚至报 Argument list too long。这时用 xargs 分批喂给 rm:

find /app/cache -type f -name '*.tmp' -print0 \
  | xargs -0 -n 1000 rm -f

-print0 配 xargs -0 是标准搭配,用 NUL 字节分隔文件名,这样文件名里含空格、引号、换行都不会出错。-n 1000 表示每批 1000 个,避免一次传参超长。

第三步,验证释放效果。每做一步都回查一次,不要攒到最后:

df -i /var | tail -1

一个真实案例:每分钟 300 个文件的缓存目录

去年我接手的一个图片站,症状是每周日晚上 /var 分区 inode 打满,周一早上自动恢复。这个"规律"很有迷惑性,看着像定时任务导致的,其实是流量规律——周日是站点高峰,访问量大,而恢复是因为周一凌晨的重启脚本顺手清过缓存。

排查过程:先是 df -i 确认 /var 的 IUse% 到了 99.7%,容量只有 41%。然后跑目录计数,第一位是 /var/www/cache/thumb,里面有 180 万个文件,平均每个 1.2KB——典型的缩略图缓存,按图片 ID 一图一文件落地,但从来没有人清理过。

我做的第一件事是算生成速率。取两次 find ... | wc -l 的快照,间隔 60 秒:

c1=$(find /var/www/cache/thumb -type f | wc -l)
sleep 60
c2=$(find /var/www/cache/thumb -type f | wc -l)
echo "每分钟新增: $((c2 - c1))"

结果是每分钟约 300 个。按这个速度,每 24 小时新增 43 万个 inode,而 /var 池子里总共只有约 300 万个名额——难怪每周爆一次。

止血阶段我用 find -mtime +7 -delete 删掉 7 天前的缩略图,IUse% 从 99.7% 降到 38%,站点立刻恢复写入。

根治阶段做了两件事。第一,把缩略图缓存改写到独立分区,并且单独格式化时把 -i(bytes-per-inode)从默认的 16384 调到 4096,同样的空间能容纳 4 倍的 inode:

# 格式化时指定 inode 密度,4096 表示每 4KB 数据配一个 inode
mkfs.ext4 -i 4096 -m 1 /dev/vdb1

-m 1 是把默认给 root 预留的 5% 空间降到 1%。现在磁盘都很大,5% 往往是几十 GB 被白白锁住,对非系统分区没必要留这么多。

第二,加了一条每日清理任务,并且在监控里加上 inode 使用率告警:

# /etc/cron.d/thumb-cleanup
30 4 * * * www-data find /var/www/cache/thumb -type f -mtime +3 -delete

别写进 root 的 crontab 就完事——缓存文件的属主是 www-data,用 root 跑会产生权限混乱,而且 find -delete 对跨属主目录的行为不好预期。写在 /etc/cron.d/ 里指定用户是更规范的做法。

长期监控:让告警在打满之前响

空间告警人人都有,inode 告警十个人里九个没配。这两个指标必须分开监控,因为处理方式完全不同。给 nagios 或者自研脚本用的话,一行 shell 就能拿到百分比:

df -i / | awk 'NR==2 {gsub("%","",$5); print $5}'

我把它接进了一个每分钟跑一次的小脚本,超过 80% 就发告警、超过 90% 自动执行清理:

#!/bin/bash
# /usr/local/bin/inode-guard.sh
LIMIT=80
used=$(df -i /var | awk 'NR==2 {gsub("%","",$5); print $5}')
if [ "$used" -ge "$LIMIT" ]; then
  find /var/www/cache/thumb -type f -mtime +3 -delete
  echo "inode $used% >= $LIMIT%, cleanup done: $(df -i /var | awk 'NR==2{print $5}')"
fi

写成幂等脚本很关键:它不需要记录"上次跑到哪",因为判据是当前实际状态。无论它被 cron 每分钟调一次,还是被手工触发十次,结果都一样,不会重复删不该删的东西。

写入之前的自查清单

每次上线一个会产生大量小文件的功能之前,我都会问自己三个问题:

  1. 这个目录会不会无限增长? 如果答案是"会",那它在设计上就必须配一个清理策略,而不是等出事再补。
  2. 有没有现成的更好方案? 会话用 Redis 而不是文件,缓存用 key-value 单文件而不是一 URL 一文件,日志写一个文件靠 logrotate 切分而不是按请求建文件——很多 inode 危机在架构选择阶段就能避免。
  3. 文件放哪个分区? 别和系统盘抢 inode 池。给"会爆炸的目录"单独一个分区,既是隔离故障,也让监控指标更清晰。

最后强调一点:inode 耗尽和磁盘写满的表现完全一样,根因完全不同,修法也完全不同。遇到 No space left on device,先敲 df -i,这一个动作就能让你少走半小时弯路。个人站长手上资源有限,但把这类基础诊断做扎实,能挡掉绝大部分深夜告警。

Last modification:September 26th, 2026 at 12:24 pm

Leave a Comment