一次Linux服务器文件系统inode耗尽导致磁盘有空间却无法写入的排查实录

一、问题背景

周三上午10点左右,公司核心交易系统的Web层突然开始大量报错。监控平台连续发出”应用日志写入失败”和”业务健康检查超时”的告警,短短5分钟内收到的告警从0飙升至47条。

受影响的是部署在阿里云ECS上的Spring Boot交易网关服务,共3个节点,运行在CentOS 7.9系统上。该服务负责接收前端交易请求并将处理日志写入本地文件,再由Filebeat采集发送到ELK。因日志写入失败,服务开始返回HTTP 500错误,交易成功率从99.8%骤降至73%。

前一天运维同事刚做过一次应用版本发布,第一反应是”新版本有bug”。但在回滚到上一版本后问题依旧,才意识到这很可能不是代码问题,而是基础设施层面的故障。

二、故障现象

登录问题节点后,首先检查应用日志:

1
2
3
4
5
6
7
$ tail -f /var/log/trade-gateway/application.log
2026-06-27 10:03:42.127 ERROR [pool-3-thread-7] c.t.g.service.OrderService - Failed to write operation log
java.io.IOException: No space left on device
at java.base/java.io.FileOutputStream.writeBytes(Native Method)
at java.base/java.io.FileOutputStream.write(FileOutputStream.java:354)
at java.base/java.io.BufferedOutputStream.flushBuffer(BufferedOutputStream.java:81)
...

“No space left on device”——这个报错非常明确:磁盘没空间了。

习惯性地先查磁盘空间:

1
2
3
4
5
6
$ df -h
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 80G 45G 31G 60% /
devtmpfs 7.8G 0 7.8G 0% /dev
tmpfs 7.8G 16K 7.8G 1% /dev/shm
tmpfs 7.8G 1.2M 7.8G 1% /run

看得我一愣——根分区总共80G,使用了45G,还有31G可用空间。磁盘并没有满。

不信邪,手动创建文件试试:

1
2
3
4
5
$ touch /tmp/test_write
touch: cannot touch '/tmp/test_write': No space left on device

$ echo "test" > /var/log/test.log
-bash: /var/log/test.log: No space left on device

磁盘明明有31GB空闲,却无法创建任何文件。这就是典型的”文不对题”——报错说没空间,df却说空间充裕。此时心里已经有了方向:大概率是inode耗尽了

三、排查过程

3.1 确认inode使用情况

遇到”磁盘有空间但无法创建文件”,第一个要检查的就是inode:

1
2
3
4
5
6
$ df -i
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vda1 5242880 5242880 0 100% /
devtmpfs 2039552 407 2039145 1% /dev
tmpfs 2041888 19 2041869 1% /dev/shm
tmpfs 2041888 833 2041055 1% /run

真相大白。根分区一共5242880个inode,全部耗尽,IUse% = 100%。文件系统已经没有可用的inode来创建新文件了,哪怕磁盘物理空间还有大量剩余。

3.2 定位inode消耗大户

接下来的任务是找出哪些目录消耗了大量inode。先对整个根目录做一级统计:

1
2
3
4
5
6
7
8
9
10
11
12
$ for dir in /*; do
if [ -d "$dir" ]; then
count=$(find "$dir" -xdev -printf '.' | wc -c)
echo "$count $dir"
fi
done | sort -rn | head -10

4892012 /tmp
247163 /var
89321 /usr
31002 /opt
...

接近490万个inode集中在/tmp目录下,几乎占了全部inode的93%。这个发现让排查目标变得非常明确。

3.3 深入/tmp目录分析

进入/tmp目录,用ls看看情况:

1
2
$ cd /tmp && ls | wc -l
4850000

四百八十五万个文件!这个数字令人震惊。进一步查看文件类型分布:

1
2
3
4
5
6
$ ls -lh /tmp | head -20
-rw-r--r-- 1 appuser appuser 0 Jun 24 03:00 .trade_lock_20260624_030001_8372
-rw-r--r-- 1 appuser appuser 0 Jun 24 03:00 .trade_lock_20260624_030002_9104
-rw-r--r-- 1 appuser appuser 0 Jun 24 03:00 .trade_lock_20260624_030003_2841
...
-rw-r--r-- 1 appuser appuser 0 Jun 27 10:00 .trade_lock_20260627_100001_5539

全是以.trade_lock_开头的隐藏文件,大小全部为0字节。这些是应用用于进程互斥的锁文件。

查看文件的创建时间分布:

1
2
3
4
5
6
7
$ find /tmp -name '.trade_lock_*' -printf '%TY-%Tm-%Td\n' | sort | uniq -c | sort -rn
1520000 2026-06-25
1438000 2026-06-24
1087000 2026-06-26
696000 2026-06-23
104000 2026-06-22
...

这批锁文件从6月22日开始累积,最近3天每天产生超过100万个。但关键问题是:为什么这些临时锁文件没有被清理?

3.4 追查清理机制

查了一下项目内部的定时任务和代码逻辑:

1
2
3
$ crontab -l -u appuser
# 每天凌晨3点清理/tmp下超过24小时的临时文件
0 3 * * * find /tmp -name '.trade_lock_*' -mtime +1 -delete

crontab里有一条清理任务:每天凌晨3点删除/tmp下24小时以前的锁文件。逻辑看起来没问题。

手动验证清理逻辑:

1
2
$ find /tmp -name '.trade_lock_*' -mtime +1 | wc -l
4870000

这个结果非常奇怪——几乎全部文件都满足”-mtime +1”条件,说明它们超过24小时了,但cron任务却没能清理掉它们。

继续深挖,查看cron的执行日志:

1
2
$ grep "find /tmp" /var/log/cron | tail -5
Jun 27 03:00:01 trade-gw-01 CROND[19238]: (appuser) CMD (find /tmp -name '.trade_lock_*' -mtime +1 -delete)

cron确实按时执行了。那为什么没删掉?把命令抄出来单独跑一下:

1
2
$ find /tmp -name '.trade_lock_*' -mtime +1 -delete
-bash: /usr/bin/find: Argument list too long

破案了!**”Argument list too long”**——find命令在内部处理-delete操作时,对于数百万级别的文件,exec的参数列表超出了系统限制。

补充说明:find-delete 参数会尝试在内部批量调用 unlink(),当匹配到的文件数量极其庞大时,仍然可能触发内核的 ARG_MAX 限制(可以通过 getconf ARG_MAX 查看,通常为2MB)。这个限制不仅影响 -exec ... {} \+ 的显式调用,在文件基数达到百万级时也可能影响 -delete

3.5 定位cron失败的根本原因

进一步验证:

1
2
3
4
5
$ getconf ARG_MAX
2097152

$ find /tmp -name '.trade_lock_*' -mtime +1 | wc -c
195482103

所有过期文件路径的总长度约186MB,远超ARG_MAX的2MB限制。find命令在执行-delete时一次性匹配的文件太多,路径总长度超过了内核参数上限,导致删除操作完全失败。

这也解释了为什么故障出现在周三——锁文件从上周日开始累积,到了周三总量突破500万,find -delete彻底失效形成”死锁”:cron定时删→delete失败→文件继续累积→下一次cron更不可能成功→恶性循环,直到inode彻底耗尽。

四、解决方案

4.1 紧急止血

当务之急是让服务恢复。不能直接用rm -rf /tmp/*——/tmp下还有其他进程的运行时文件。需要精准批量删除:

1
2
# 用find配合xargs分批删除,避免ARG_MAX限制
$ find /tmp -name '.trade_lock_*' -mtime +1 -print0 | xargs -0 -n 1000 rm -f

执行过程中持续监控inode恢复情况:

1
$ watch -n 2 'df -i / | tail -1'

删除持续了约12分钟,inode使用率从100%降至8%。服务随即恢复正常。

4.2 修复清理机制

原有的cron删除命令需要改造,核心思路是让find通过管道将文件列表传给xargs分批处理,彻底绕过ARG_MAX限制:

修改crontab:

1
2
3
# 将原来的单条find -delete
# 改为 find + xargs 分批删除模式
$ crontab -e -u appuser
1
2
# 每天凌晨3点清理/tmp下超过24小时的锁文件(xargs分批避免ARG_MAX)
0 3 * * * find /tmp -name '.trade_lock_*' -mtime +1 -print0 | xargs -0 -n 500 rm -f

4.3 调整inode监控

在Prometheus中添加inode监控告警规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
groups:
- name: filesystem_alerts
rules:
- alert: InodeUsageHigh
expr: (node_filesystem_files_free / node_filesystem_files) * 100 < 10
for: 5m
labels:
severity: warning
annotations:
summary: "文件系统inode使用率超过90%"
description: "主机 {{ $labels.instance }}{{ $labels.mountpoint }} 的inode使用率为 {{ $value | humanize }}%,仅剩 {{ $value }}% 可用。"

- alert: InodeUsageCritical
expr: (node_filesystem_files_free / node_filesystem_files) * 100 < 5
for: 1m
labels:
severity: critical
annotations:
summary: "文件系统inode使用率超过95%,即将耗尽!"
description: "主机 {{ $labels.instance }}{{ $labels.mountpoint }} 的inode仅剩 {{ $value }}%,请立即处理。"

同时配置Node Exporter采集inode指标,确保node_filesystem_filesnode_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
2
3
4
5
6
7
# 在应用启动前清理过期锁文件(配合xargs分批处理)
cleanup_locks() {
local LOCK_DIR="/data/app/trade-gateway/locks"
local MAX_AGE_MINUTES=60
find "$LOCK_DIR" -name '.trade_lock_*' -mmin "+${MAX_AGE_MINUTES}" -print0 \
| xargs -0 -n 500 rm -f 2>/dev/null
}

同时修改Java代码,使用try-finally确保锁文件在finally块中释放,搭配FileLock机制代替纯文件存在性判断做互斥。

6.2 操作系统层面

调整/tmp的挂载参数,限制inode使用上限:

1
2
# /etc/fstab 中为 /tmp 增加nr_inodes限制
tmpfs /tmp tmpfs defaults,size=4G,nr_inodes=500000 0 0

另外,如果业务允许,可以将频繁产生小文件的目录单独分区并分配更多inode:

1
2
# 创建文件系统时指定更多inode(默认情况下每16KB分配1个inode)
mkfs.ext4 -i 4096 /dev/vdb1 # 每4KB分配1个inode,inode数量变为默认的4倍

6.3 监控层面

强制要求所有生产服务器部署inode监控告警,与磁盘空间监控同等对待。告警阈值:inode使用率 > 80% 预警,> 90% 严重告警,> 95% 紧急告警。

同时建立巡检制度,每周检查一次所有生产服务器的inode使用趋势,提前发现异常增长:

1
2
3
4
5
# 巡检脚本示例
for host in $(cat /etc/ansible/hosts | grep '\[prod\]' -A 100 | grep -v '\['); do
echo "=== $host ==="
ssh $host "df -i / /data 2>/dev/null | grep -v '^Filesystem'"
done

6.4 清理工具加固

将cron清理脚本统一升级为带有结果校验的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/bash
# /usr/local/bin/cleanup_tmp_locks.sh
LOCK_DIR="/tmp"
PATTERN=".trade_lock_*"
MAX_AGE="+1"
BATCH_SIZE=500
ERROR_LOG="/var/log/tmp_cleanup_error.log"

# 分批删除
DELETED=0
while IFS= read -r -d '' file; do
rm -f "$file" && ((DELETED++))
done < <(find "$LOCK_DIR" -name "$PATTERN" -mtime "$MAX_AGE" -print0 2>/dev/null)

# 结果校验
REMAINING=$(find "$LOCK_DIR" -name "$PATTERN" -mtime "$MAX_AGE" 2>/dev/null | wc -l)

if [ "$REMAINING" -gt 0 ]; then
echo "$(date): 清理不完整!已删除 $DELETED 个文件,仍有 $REMAINING 个过期文件未清理。" >> "$ERROR_LOG"
fi

七、总结

这次故障给我最大的教训是:运维监控不能只盯着”磁盘空间”这一个维度

Linux文件系统中的inode和blocks是两个独立的资源。df -h只看blocks(数据块),df -i看inodes(索引节点)。很多运维工程师只关注前者,直到某天服务器磁盘”明明有空间却写不进去”才恍然大悟——原来还有inode这回事。

这次排查也让我意识到ARG_MAX这个内核参数在批量文件操作中的真实影响。日常运维中find -delete用得很多,通常不会出问题,但当文件数量达到百万级时,它就会成为隐藏的炸弹。xargs分批处理是一个简单而有效的解决方案,成本极低但能把系统稳定性提升一个量级。

最后,**”能写文件的磁盘就是好磁盘”是一个危险的假设**。生产环境中,inode的消耗通常比blocks更隐蔽、更难以发现,因为小文件对物理空间的占用微乎其微,但每个文件都必须消耗一个inode。对于频繁创建临时文件的应用,inode耗尽的速度可能远超你的想象。