一次Linux服务器文件系统inode耗尽导致磁盘有空间却无法写入的排查实录
一、问题背景
周三上午10点左右,公司核心交易系统的Web层突然开始大量报错。监控平台连续发出”应用日志写入失败”和”业务健康检查超时”的告警,短短5分钟内收到的告警从0飙升至47条。
受影响的是部署在阿里云ECS上的Spring Boot交易网关服务,共3个节点,运行在CentOS 7.9系统上。该服务负责接收前端交易请求并将处理日志写入本地文件,再由Filebeat采集发送到ELK。因日志写入失败,服务开始返回HTTP 500错误,交易成功率从99.8%骤降至73%。
前一天运维同事刚做过一次应用版本发布,第一反应是”新版本有bug”。但在回滚到上一版本后问题依旧,才意识到这很可能不是代码问题,而是基础设施层面的故障。
二、故障现象
登录问题节点后,首先检查应用日志:
1 | $ tail -f /var/log/trade-gateway/application.log |
“No space left on device”——这个报错非常明确:磁盘没空间了。
习惯性地先查磁盘空间:
1 | $ df -h |
看得我一愣——根分区总共80G,使用了45G,还有31G可用空间。磁盘并没有满。
不信邪,手动创建文件试试:
1 | $ touch /tmp/test_write |
磁盘明明有31GB空闲,却无法创建任何文件。这就是典型的”文不对题”——报错说没空间,df却说空间充裕。此时心里已经有了方向:大概率是inode耗尽了。
三、排查过程
3.1 确认inode使用情况
遇到”磁盘有空间但无法创建文件”,第一个要检查的就是inode:
1 | $ df -i |
真相大白。根分区一共5242880个inode,全部耗尽,IUse% = 100%。文件系统已经没有可用的inode来创建新文件了,哪怕磁盘物理空间还有大量剩余。
3.2 定位inode消耗大户
接下来的任务是找出哪些目录消耗了大量inode。先对整个根目录做一级统计:
1 | $ for dir in /*; do |
接近490万个inode集中在/tmp目录下,几乎占了全部inode的93%。这个发现让排查目标变得非常明确。
3.3 深入/tmp目录分析
进入/tmp目录,用ls看看情况:
1 | $ cd /tmp && ls | wc -l |
四百八十五万个文件!这个数字令人震惊。进一步查看文件类型分布:
1 | $ ls -lh /tmp | head -20 |
全是以.trade_lock_开头的隐藏文件,大小全部为0字节。这些是应用用于进程互斥的锁文件。
查看文件的创建时间分布:
1 | $ find /tmp -name '.trade_lock_*' -printf '%TY-%Tm-%Td\n' | sort | uniq -c | sort -rn |
这批锁文件从6月22日开始累积,最近3天每天产生超过100万个。但关键问题是:为什么这些临时锁文件没有被清理?
3.4 追查清理机制
查了一下项目内部的定时任务和代码逻辑:
1 | $ crontab -l -u appuser |
crontab里有一条清理任务:每天凌晨3点删除/tmp下24小时以前的锁文件。逻辑看起来没问题。
手动验证清理逻辑:
1 | $ find /tmp -name '.trade_lock_*' -mtime +1 | wc -l |
这个结果非常奇怪——几乎全部文件都满足”-mtime +1”条件,说明它们超过24小时了,但cron任务却没能清理掉它们。
继续深挖,查看cron的执行日志:
1 | $ grep "find /tmp" /var/log/cron | tail -5 |
cron确实按时执行了。那为什么没删掉?把命令抄出来单独跑一下:
1 | $ find /tmp -name '.trade_lock_*' -mtime +1 -delete |
破案了!**”Argument list too long”**——find命令在内部处理-delete操作时,对于数百万级别的文件,exec的参数列表超出了系统限制。
补充说明:
find的-delete参数会尝试在内部批量调用unlink(),当匹配到的文件数量极其庞大时,仍然可能触发内核的ARG_MAX限制(可以通过getconf ARG_MAX查看,通常为2MB)。这个限制不仅影响-exec ... {} \+的显式调用,在文件基数达到百万级时也可能影响-delete。
3.5 定位cron失败的根本原因
进一步验证:
1 | $ getconf ARG_MAX |
所有过期文件路径的总长度约186MB,远超ARG_MAX的2MB限制。find命令在执行-delete时一次性匹配的文件太多,路径总长度超过了内核参数上限,导致删除操作完全失败。
这也解释了为什么故障出现在周三——锁文件从上周日开始累积,到了周三总量突破500万,find -delete彻底失效形成”死锁”:cron定时删→delete失败→文件继续累积→下一次cron更不可能成功→恶性循环,直到inode彻底耗尽。
四、解决方案
4.1 紧急止血
当务之急是让服务恢复。不能直接用rm -rf /tmp/*——/tmp下还有其他进程的运行时文件。需要精准批量删除:
1 | # 用find配合xargs分批删除,避免ARG_MAX限制 |
执行过程中持续监控inode恢复情况:
1 | $ watch -n 2 'df -i / | tail -1' |
删除持续了约12分钟,inode使用率从100%降至8%。服务随即恢复正常。
4.2 修复清理机制
原有的cron删除命令需要改造,核心思路是让find通过管道将文件列表传给xargs分批处理,彻底绕过ARG_MAX限制:
修改crontab:
1 | # 将原来的单条find -delete |
1 | # 每天凌晨3点清理/tmp下超过24小时的锁文件(xargs分批避免ARG_MAX) |
4.3 调整inode监控
在Prometheus中添加inode监控告警规则:
1 | groups: |
同时配置Node Exporter采集inode指标,确保node_filesystem_files和node_filesystem_files_free指标正常上报。
五、根因分析
这个问题的根本原因有三层:
第一层(直接原因):应用代码使用File.createTempFile()创建的锁文件没有在进程退出时正确清理。代码中虽然注册了deleteOnExit(),但当进程被kill -9强制终止时,JVM的shutdown hook不会执行,锁文件被遗留在磁盘上。
第二层(放大因素):cron清理脚本使用find -delete一次性处理百万级文件时触发了ARG_MAX限制,导致清理完全失效。一旦清理失败一次,后续每次cron执行都面临更多的文件,形成”越积越多、越积越难清”的死循环。
第三层(监控缺失):监控体系中只覆盖了磁盘空间(df -h),从未关注inode使用率(df -i)。运维团队直到故障发生才意识到inode是一个独立的资源维度。
六、预防措施
6.1 应用层面
将锁文件从/tmp迁移到应用专属的工作目录,并在应用启动脚本中增加自清理逻辑:
1 | # 在应用启动前清理过期锁文件(配合xargs分批处理) |
同时修改Java代码,使用try-finally确保锁文件在finally块中释放,搭配FileLock机制代替纯文件存在性判断做互斥。
6.2 操作系统层面
调整/tmp的挂载参数,限制inode使用上限:
1 | # /etc/fstab 中为 /tmp 增加nr_inodes限制 |
另外,如果业务允许,可以将频繁产生小文件的目录单独分区并分配更多inode:
1 | # 创建文件系统时指定更多inode(默认情况下每16KB分配1个inode) |
6.3 监控层面
强制要求所有生产服务器部署inode监控告警,与磁盘空间监控同等对待。告警阈值:inode使用率 > 80% 预警,> 90% 严重告警,> 95% 紧急告警。
同时建立巡检制度,每周检查一次所有生产服务器的inode使用趋势,提前发现异常增长:
1 | # 巡检脚本示例 |
6.4 清理工具加固
将cron清理脚本统一升级为带有结果校验的版本:
1 |
|
七、总结
这次故障给我最大的教训是:运维监控不能只盯着”磁盘空间”这一个维度。
Linux文件系统中的inode和blocks是两个独立的资源。df -h只看blocks(数据块),df -i看inodes(索引节点)。很多运维工程师只关注前者,直到某天服务器磁盘”明明有空间却写不进去”才恍然大悟——原来还有inode这回事。
这次排查也让我意识到ARG_MAX这个内核参数在批量文件操作中的真实影响。日常运维中find -delete用得很多,通常不会出问题,但当文件数量达到百万级时,它就会成为隐藏的炸弹。xargs分批处理是一个简单而有效的解决方案,成本极低但能把系统稳定性提升一个量级。
最后,**”能写文件的磁盘就是好磁盘”是一个危险的假设**。生产环境中,inode的消耗通常比blocks更隐蔽、更难以发现,因为小文件对物理空间的占用微乎其微,但每个文件都必须消耗一个inode。对于频繁创建临时文件的应用,inode耗尽的速度可能远超你的想象。