磁盘还有空间却报 No space left on device:Linux inode 耗尽从定位到根治的完整排查

一、为什么单独讲「磁盘 inode 耗尽」

几乎所有站长都遇到过 No space left on device,第一反应是 df -h,结果根分区还剩好几个 G,于是开始怀疑是内核 bug、文件系统损坏、甚至云厂商的限制。实际上在小型 VPS 上,这个报错有相当大比例跟磁盘空间毫无关系,而是 inode 耗尽了。磁盘是按字节记账的,inode 是按「文件数量」记账的,两本账是独立的。一个分区可以有 50% 的空闲空间,同时 inode 用尽,此时任何创建文件、写日志、上传附件、PHP 写 session 的操作都会失败。

这个故障的典型特征是:网站整体还在跑,静态页正常,但一登录后台就报错;或者 PHP 报 session_start(): open(...) failed: No space left on device;MySQL 报 Can't create/write to file;rsyslog 开始丢日志。看上去像三件互不相关的事,其实是同一个原因。

二、inode 是什么,为什么会被用光

Ext4 / XFS 这类文件系统里,每个文件(包括目录、符号链接、硬链接指向的实体)都要占用一个 inode,记录权限、大小、时间戳、数据块位置等信息。Ext4 在 mkfs 时按比率一次性预分配 inode 总数,之后不能扩充(只能重做文件系统)。默认比率大约是每 16KB 空间一个 inode,也就是说一块 40G 的盘大约有 250 万个 inode。

正常情况下这个数字非常宽裕——除非你踩到下面几种场景:

  • PHP session 文件堆积:默认 session.save_path 写在 /var/lib/php/sessions,如果没有配 GC 或 GC 概率太低,百万级小文件很常见。
  • 邮件队列 / postfix 的 /var/spool:一封退信就能生成几十个小文件。
  • 日志轮转配置错误:logrotate 的 dateext 加上过短的周期,或者应用自己按小时切日志且从不删除。
  • 缓存目录:框架的 file cache、缩略图缓存、Composer / npm 缓存、Let's Encrypt 的 archive/ 目录。
  • 站点被恶意上传:webshell 或采集器把垃圾文件写进 uploads,往往几十万个小文件。
  • Docker 的 overlay2 层:镜像构建残留的层目录包含大量小文件。

三、第一步:确认是不是 inode

用 df -i 看 inode 使用率,而不是 df -h:

# 同时看两本账,第一列是容量,第二块是 inode
df -h
df -i

输出中的 IUse% 如果接近或达到 100%,就确认了。注意要看报错路径所在的分区:/var、/home、/tmp 在有些系统上是独立分区,根分区 inode 富余并不代表 /var 也有。

如果 df -i 显示使用率很高但并不满,而报错依然出现,还要考虑单目录文件数上限问题——Ext4 的 dir_index 特性下单个目录可以放很多文件,但目录项哈希冲突和 readdir 性能会急剧下降;同时某些应用(尤其是老 PHP 程序)在 scandir() 时会因为内存耗尽而返回失败,被误报成「空间不足」。

四、定位是哪个目录在吃 inode

最快的办法是从根往下逐层统计,找出文件数最多的目录:

# 统计根下每个一级目录的文件数(不含子目录数量本身)
for d in /*; do
  printf "%10s  %s\n" "$(find "$d" -xdev -type f 2>/dev/null | wc -l)" "$d"
done | sort -rn | head -15

确认了大致方向后,再在那个目录里往下钻。比如怀疑 /var:

for d in /var/*; do
  printf "%10s  %s\n" "$(find "$d" -xdev -type f 2>/dev/null | wc -l)" "$d"
done | sort -rn | head -15

这里有几个要点。第一,一定要带 -xdev,否则 find 会跨进挂载点,既慢又可能被网络存储卡住。第二,/proc、/sys 这类伪文件系统不要统计,它们会给出无意义的数字。第三,统计上百万文件时 find | wc -l 可能要跑几十秒,可以先用 ncdu 的交互界面按文件数排序,或者在后台跑并加 nice -n 19 降低对线上的影响。

如果不想装工具,du --inodes 也能直接给出每个目录占用的 inode 数,缺点是同样要遍历整棵树:

du --inodes -d 1 /var | sort -rn | head -15

五、常见元凶的清理顺序(从安全到危险)

清理一定要按「可再生成 → 可归档 → 不可恢复」的顺序来,千万不要一上来就 rm -rf 大目录。

5.1 PHP session 文件

# 先看数量和时间分布
ls /var/lib/php/sessions | wc -l
ls -lt /var/lib/php/sessions | tail -5

如果文件都很旧,说明 GC 没工作。可以按 mtime 清理,保留最近 2 天的:

find /var/lib/php/sessions -type f -name 'sess_*' -mtime +2 -delete

更彻底的做法是改 PHP 配置,把 session 存到 Redis,并把 session.gc_maxlifetime 和 gc_probability 调成合理值。默认 gc_probability=1、gc_divisor=100 意味着约百分之一的请求会触发 GC,在低流量站点上相当于几乎不清理。

5.2 应用日志

先确认 logrotate 配置有没有真的生效:

logrotate -d /etc/logrotate.conf        # -d 是 dry run,只看不删
cat /var/lib/logrotate/status | head

很多站点的问题是应用自己写日志到 /www/wwwlogs/,却没有进 logrotate 的 /etc/logrotate.d/。补一个配置即可,注意 postrotate 里要 reload 对应服务,否则进程会继续往被删除的旧文件句柄里写,磁盘空间不释放(这是另一个高频坑)。

5.3 缓存与构建残留

du --inodes -d 1 ~/.composer ~/.npm ~/.cache 2>/dev/null | sort -rn | head

这类目录删掉只会让下次构建慢一点,属于最安全的清理目标。

5.4 恶意文件

如果发现上传目录里有大量随机命名的 .php 文件,先别急着全删——那可能是站点已经被拿下的证据。正确顺序是:先把目录打包留证,再停掉写入权限,然后排查入口(通常是老插件、上传类型校验缺失、或者文件包含)。只删文件不改入口,过几天会重新长回来。

六、真正根治:调整 inode 密度或迁移分区

如果确认业务本身就要存海量小文件(图片站、附件站、日志归档),而 df -i 已经是 95% 以上,清理只能续命,最终要做结构性调整:

  • 重建文件系统并提高 inode 数量:mkfs.ext4 -i 8192 /dev/vdb 表示每 8KB 一个 inode,密度翻倍,代价是可用空间略减少。这一步必须在挂新盘时做,不能对已在用的分区做。
  • 改用 XFS:XFS 的 inode 是动态分配的,不会出现「空间还有但 inode 用光」的情况,代价是不能缩容。
  • 把小文件挪出本地盘:session 进 Redis,附件进对象存储,日志进集中日志服务器,构建缓存进 tmpfs 或定期清理。这是最符合长期运维思路的做法。
  • 给关键分区加监控:只监控磁盘容量是不够的,inode 必须单独告警。

七、监控与预防:把 inode 纳入例行检查

一条 Prometheus node_exporter 查询就能覆盖:

# inode 使用率超过 85% 告警
(node_filesystem_files{exclude_mountpoint!~"/proc.*|/sys.*"}
 - node_filesystem_files_free{exclude_mountpoint!~"/proc.*|/sys.*"})
 / node_filesystem_files{exclude_mountpoint!~"/proc.*|/sys.*"} > 0.85

没有 Prometheus 的小站,用 cron 每天跑一次并把结果写进邮件或 ntfy 即可:

#!/bin/bash
# /usr/local/bin/inode-check.sh
LIMIT=85
df -iP | awk -v limit="$LIMIT" '
  NR==1 { next }
  $5+0 >= limit { printf "%s 分区 inode 使用率 %s (已用 %s / 共 %s)\n", $6, $5, $3, $2; found=1 }
  END { exit found?1:0 }'

配合 crontab 每早八点执行,失败时触发 curl 推到手机。这个小脚本的价值在于它会在故障发生前几天就告诉你趋势:inode 耗尽从来不是瞬间发生的,通常是某个进程缓慢泄漏小文件,曲线是稳步上升而不是跳变。看到斜率,就能反推是哪个目录在增长。

八、一次真实排查的复盘

前阵子一台 2C4G 的小机器就出现过这个故障:站点首页正常,后台登录报 500,PHP 错误日志里写 session_start(): open(/var/lib/php/sessions/sess_xxx, O_RDWR) failed: No space left on device。df -h 显示根分区用 62%,看不出问题;df -i 显示根分区 inode 用到 100%。

按上面的方法逐层统计,发现 /var/lib/php/sessions 有 210 万个 sess_ 文件,占了 inode 的绝大部分。原因是站点早期被扫描器打了大量请求,每个请求都 session_start() 生成一个 session 文件,而 GC 概率太低从未清理。按 mtime 删掉 2 天以前的文件后,inode 立刻降到 12%,后台恢复正常。

但这不是结束。真正治本的三步是:把 session 存储切到 Redis;在 Nginx 层面对明显的扫描器 UA 返回 444 减少无效请求;给 inode 加了 85% 的告警。这三步做完之后,同类问题再没出现过。这也印证了一个判断:No space left on device 报错时,先看 inode,再看容量,这个顺序能省下大量时间。

九、小结

磁盘空间和 inode 是两本独立的账,报错文案却完全一样,这是最容易误判的地方。df -i 是第一诊断命令,-xdev 的分层统计是定位手段,清理要按可再生成、可归档、不可恢复的顺序推进。真正的解决在于把海量小文件迁出本地盘,并给 inode 单独设置监控。把这三件事做成习惯,这类故障就只会出现在别人的运维复盘里。

Last modification:October 18th, 2026 at 12:24 pm

Leave a Comment