- 摩杰斯
- 2026-10-29
Linux 磁盘没满,为什么一个字节都写不进去(inode 耗尽排查)
那天的电话很典型:内部管理系统登不上去。页面能打开,登录框也正常,输账号密码,转一圈,回到登录页。换浏览器、清缓存、换账号都试过,用户的描述很准确——"永远登录不上"。
最后查出来的原因,和登录、和这套系统、和数据库都没有关系:这台 Linux 服务器上有一个没人管的邮件服务,把文件系统的 inode 用完了。而那时候 df -h 显示磁盘还有大半空闲。
这类故障在 Linux 上最有迷惑性的地方就在这里:报错说"No space left on device",人去看磁盘空间,空间充足;再去找大文件,找不到;于是开始怀疑应用、怀疑数据库、怀疑密码。真正该看的那条命令,往往要绕很久才想起来。
这篇把 Linux 上这个过程和机制完整拆一遍,包括三组可以自己复现的实测,以及一套下次遇到"写不进去"可以照着走的判断顺序。
![]()
一、故障是从"登不上"开始的
页面能打开,说明 Web 服务活着;登录框能显示,说明应用还能响应请求。这两点排除了"服务挂了"。接下来输账号密码点登录,转一圈回到登录页——这个现象指向会话(session):登录成功后要写一个会话文件,之后每次请求凭它认人。会话写不进去,登录这件事在逻辑上就完成不了:密码校验通过了,会话没建起来,下一次请求仍然认为你没登录,于是把你送回登录页。
顺着这个思路查下去:重启应用服务,没用;看应用日志,日志里没有报错;查数据库连接,正常;直接跑登录相关查询,能出结果;账号状态和密码都没问题。
事后回看,有一个细节当时被忽略了——日志里不是没有报错,是报错写不出去。写不进日志的系统,看起来特别健康。
二、第一轮误判:以为磁盘满了
"写不进去"最常见的理由是磁盘满了,于是先看空间:
df -h
结果出乎意料:根分区还有几十个 G 余量,使用率不到六成。既然空间够,那就不是磁盘的问题——当时我们就是这么判断的,这个判断让排查多绕了将近一个小时。
接着做的事是找"谁把空间吃掉了":翻大文件、看日志目录、看上传目录、看数据库文件。du 一层层扫下去,最大的目录也就几个 G,加起来对不上任何异常。而系统那时候还在不停地报"写不进去"。
真正让人回头的是一个很朴素的疑问:如果空间真的够,为什么创建不了一个新文件?
顺着这个问题试了另一条命令:
df -i
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vda1 6553600 6553600 0 100% /
inode 用完了,100%。而同一时刻 df -h 依然显示空间充裕。两条命令,两个世界。
三、inode 是什么,为什么它也会用完
Linux 的 ext4 文件系统里,磁盘上有一块区域专门记录"文件清单",每个文件在清单里占一条记录,这条记录的编号就是 inode(索引节点)。一个文件实际占两样东西:
数据块——文件内容本身占的地方,df -h 统计的是这个;inode——文件名、权限、属主、大小、时间、数据块位置等元信息,df -i 统计的是这个。
所以"空间够不够"和"还能不能建新文件"是两个独立的问题。空间够、inode 用完,结果就是:已有的文件照常读,一个字也写不进去新的,报错还是那句最容易误导人的话——No space left on device。这句话里既没有 "disk" 也没有 "inode",它只说"设备上没有空间了"。
两种文件系统的差别也值得记住:ext4 的 inode 总数在格式化那一刻就定下来,默认大约每 16KB 空间给一个 inode,一块 100G 的盘大概六百多万个,这个数不能在线调大;xfs 的 inode 是动态分配的,一般不会因为文件数耗尽,但删文件后空间回收的表现和 ext4 不同(延迟分配与预分配)。
关键结论:inode 的消耗速度只跟"文件个数"有关,跟"文件大小"无关。一个 0 字节的空文件和一个 4G 的视频,占的 inode 完全一样。这就解释了为什么 du 扫不出异常——吃掉 inode 的那些文件,加起来可能只有几百兆,在几十 G 余量面前完全不值一提。
四、六十几万个小文件,从哪来的
按目录统计文件数(只读操作,inode 满的时候照样能跑):
du --inodes -d 1 /var | sort -rn | head
结果很干脆:/var/spool/postfix 一个目录占了六十几万个 inode,其余所有目录加起来不到它的零头。
postfix 是邮件服务。这台机器上跑着一套内部管理系统,邮件服务是当初装系统时按"默认全装"留下的——面板带了、系统带了,谁也没去关它。它平时安安静静,不占 CPU、不占内存,df -h 上看不出任何动静,所以从来没人注意过。
它是怎么把 inode 吃光的,三件事就够说清:
第一,邮件队列是一个"每封邮件一个文件"的地方。投递不出去就留在 deferred(延迟队列)里等重试。postfix 按哈希把它分成多层子目录,初衷就是"别让一个目录里堆几十万个文件"——这个设计本身就说明,队列堆积是被预料到的、需要防的问题。
第二,投不出去的邮件会一直重试。默认策略是隔一段时间重试一次,越往后间隔越长,一封邮件能在队列里躺好几天。
第三,也是最要命的:退信会生退信。系统里有定时任务,某些任务的输出会以邮件形式发给本地账号;如果这个账号不存在、或本地投递失败,邮件系统就生成一封"退信"通知原发件人;这封退信如果也投不出去(比如收件人是 root@主机名,主机名解析不了,或者外发被网络策略挡住),它又进队列、又重试、又生成新的退信。一封变两封,两封变四封。
这是典型的雪崩式增长,而且是无声的:没有告警、没有日志异常、CPU 内存都正常,只有 inode 在一点一点往下掉。等它掉到 0,所有需要"新建文件"的操作同时停摆——写不了会话文件,登录永远完不成;写不了日志,出错了连原因都留不下来;验证码图片生成不了;上传附件、导出报表、临时文件全部失败。
用户看到的仍然只是"登不上"。一个系统里最先坏掉的,往往是最不显眼的那个写操作。
把队列目录认清楚,下次一眼就能判断:
| 目录 | 含义 | 能不能删 |
|---|---|---|
| maildrop / incoming | 刚接收、还没落盘 | 不要删 |
| active | 正在投递的 | 不要删 |
| deferred | 投递失败、等重试的 | 可以删(先删最老的) |
| bounce | 退信处理中 | 谨慎,正在生成的别删 |
| corrupt / hold | 损坏或人工挂起的 | 确认后可删 |
平时看队列,三条命令够用:
mailq # 列出队列
postqueue -p | tail -1 # 只看汇总:-- 632847 Kbytes in 612304 Requests
postsuper -d ALL deferred # 清空延迟队列(谨慎,先看清数量)
那次汇总行给出的数字是六十多万封。这个数量不是一天涨起来的——起初每天几十封,投不出去的越攒越多,退信又生新的退信,最后每天新增几千封。它涨了几个月,而这段时间里没有任何一个监控项能看见它。
五、先把写入恢复,再谈根治
inode 满了只治一件事:删。但"删"在队列目录上有风险——直接 rm -rf 会把 postfix 自己维护的目录结构、属主和权限一起删掉,服务重启后大概率不正常;删正在投递的文件会把队列状态弄乱。当时的处置顺序是这样的:
第一步,留证据。先把 df -i、队列规模、目录清单各存一份,再动手。删完再想复盘,数据就没了。
df -i > /root/inode-before.txt
mailq | tail -3 > /root/mailq-before.txt
du --inodes -d 1 /var/spool/postfix | sort -rn > /root/postfix-inodes.txt
第二步,脚本分批清,不一把梭。不用 postsuper -d ALL,原因是它会把 active 和 incoming 里本来能正常送达的邮件一起删掉;几十万个文件一次删,IO 会把已经不健康的机器压到假死。做法是先删最没价值的:deferred 里最早的那批(躺了几天、早就没意义的退信),以及 corrupt(损坏队列)。
#!/bin/bash
Q=/var/spool/postfix/deferred
BATCH=2000
DRY=1 # 1=只统计不删;确认后改 0
echo "清理前:" && df -i / | tail -1
for d in "$Q"/*/; do
cnt=$(find "$d" -type f -mtime +2 | wc -l)
[ "$cnt" -eq 0 ] && continue
echo "$d 待清理 $cnt 个"
[ "$DRY" -eq 1 ] && continue
find "$d" -type f -mtime +2 -print0 | xargs -0 -n "$BATCH" rm -f
done
echo "清理后:" && df -i / | tail -1
几个细节:-mtime +2 只删两天前的,刚进来还在重试的不动;-print0 加 xargs -0 处理文件名里的特殊字符;-n 2000 分批,避免单次删除把 IO 打满;DRY=1 先跑一遍看清单,确认目录和数量合理再改 0 真删。执行过程中用 df -i 盯着,看到使用率往下走就说明有效。
第三步,立刻验证写入恢复。不用等用户反馈,自己造一个文件:touch /tmp/inode-check,能创建就说明写入链路通了,然后才去看登录。
第四步,停掉邮件服务,这才是根治。清队列只是把水位降下来,服务还在跑,退信还会继续生。新系统用 systemctl disable --now postfix;没有 systemd 的老系统用 chkconfig postfix off 加 service postfix stop。停完隔十几分钟再看 df -i,不应再有量级变化。
为什么这一步算根治:问题的源头不是"队列满了",而是"这台机器根本不需要一个邮件服务"。它不承担业务功能,只是安装时的默认产物,唯一作用是替定时任务发一些没人看的邮件,发不出去就攒着。停掉它,等于关掉一条没人使用、却在持续生产垃圾的流水线。
反面情况也要说清楚:如果业务确实要发信(告警通知、找回密码),就不能一停了之,而要做三件事——配一个能真正投递出去的中继、给队列设上限和清理策略、把队列长度纳入监控。能发出去的信不会堆队列,所有队列堆积本质都是"发不出去还在发"。
六、三组实测:把这件事复现出来
把关键机制单独复现一遍,三组都在容器里做,不动生产环境。
实验一:空间还剩一半,inode 用光,写入失败。用 50M、只给 800 个 inode 的临时文件系统(tmpfs 的 nr_inodes 正好能限定 inode 数):
mkdir -p /mnt/a
mount -t tmpfs -o size=50m,nr_inodes=800 tmpfs /mnt/a
for i in $(seq 1 900); do touch /mnt/a/f_$i 2>/dev/null || break; done
df -h /mnt/a ; df -i /mnt/a ; touch /mnt/a/zzz
建成的文件数: 799
Filesystem Size Used Avail Use% Mounted on
tmpfs 50.0M 0 50.0M 0% /mnt/a
Filesystem Inodes Used Avail Use% Mounted on
tmpfs 800 800 0 100% /mnt/a
touch: /mnt/a/zzz: No space left on device
空间使用率 0%、inode 使用率 100%、报错 No space left on device。三行放在一起,就是整篇文章要讲的事。799 个文件全是 0 字节,但它们把 800 个 inode 用掉了 799 个。
实验二:文件删了,空间没回来。这条和 inode 是两种不同的"删了也没用",排查时特别容易混:
mkdir -p /mnt/b
mount -t tmpfs -o size=10m tmpfs /mnt/b
dd if=/dev/zero of=/mnt/b/big.bin bs=1M count=5 2>/dev/null
sh -c 'exec 3</mnt/b/big.bin; sleep 30' &
sleep 1 ; rm -f /mnt/b/big.bin
df -h /mnt/b ; ls -l /proc/$!/fd | grep big
--- rm 之后(空间没回来)---
tmpfs 10.0M 5.0M 5.0M 50% /mnt/b
--- 谁还占着这个已删文件 ---
lr-x------ 1 root root 64 Oct 29 00:55 3 -> /mnt/b/big.bin (deleted)
文件被进程打开着,rm 掉,目录里看不到,但空间不释放,直到进程退出或关闭句柄。这正是"df 说满了、du 却找不到大文件"的最常见原因(lsof +L1 或 /proc/<pid>/fd 能看到 (deleted))。而 inode 耗尽时,删文件是立刻有效的,删一个回来一个——所以处置方式完全不同:前者要重启那个进程,后者只能删。
实验三:分批删,立即恢复。删掉一半后,使用率从 100% 掉到 50%,touch 退出码立刻变成 0。
--- 清理前 ---
tmpfs 800 800 0 100% /mnt/a
本轮删除 400 个文件
--- 清理后 ---
tmpfs 800 400 400 50% /mnt/a
--- 再写一个文件 ---
touch 退出码: 0
生产环境上就是这个效果:不是"等它自己好",是删下去就见效。
七、怎么提前发现,而不是等用户来报
这次故障之所以拖到"用户打来电话"才被发现,是因为监控里只有 CPU、内存、磁盘空间,没有 inode。三件事补上:
一,df -i 进监控,和 df -h 并列,使用率到 80% 告警。inode 耗尽的过程通常是缓慢爬升然后突然见底,80% 给的是处置窗口。
二,队列长度进监控。用邮件服务的机器,看 mailq | tail -1 的数量,或直接数 deferred 下的文件。这个数持续上涨就是"发不出去还在发"的信号,比 inode 更早暴露问题。
三,按目录建 inode 基线。记一次正常情况下各主要目录有多少文件,出问题时直接对比。/var/spool、/tmp、会话目录、缓存目录、上传临时目录是高发区。
还有一条容易被忽略:inode 告警不能用"空间够不够"来替代。一块盘空间用了 90% 但文件很少,inode 可能只用 5%;另一块盘空间用了 30%、inode 已经 95%。这两种状态在 df -h 上一个看着更危险、一个看着更安全,实际正好相反。
八、几个错误动作,和为什么不能那么做
rm -rf /var/spool/postfix。队列目录的结构、属主、权限都是 postfix 自己维护的。删掉整棵树,服务重启后大概率起不来,或者起得来但队列状态错乱。要清就清里面的文件,保留目录树。
直接删 active 和 incoming。这两个是正在投递和刚进来的队列,删这里会丢掉本来能正常送达的邮件。先删 deferred 和 corrupt。
一条 rm 删六十几万个文件。参数过长直接报 Argument list too long;就算用 find -delete 跑,删除动作本身要更新目录和日志,IO 被打满,本来 inode 满了就已经很脆,很容易拖到假死。
只想扩容。加盘、扩分区解决的是空间,不解决 inode。ext4 扩容会按固定比例补 inode,缺的是几百万个,扩一倍空间才多一倍,通常不够;而且扩容期间业务还在持续产生垃圾,等于一边加水一边漏水。
用 tune2fs 调 inode 参数。ext4 的 inode 总数在格式化时就定了,不能在线增加,tune2fs 能调的项解决不了"数不够"。要大幅提高只能重建文件系统,那是带停机的迁移,不是应急手段。
把服务留着、只挂个定时清理脚本。这是我们一开始差点做的选择:既然清一次能好,就每天清一次。这等于承认一条没人用的流水线继续生产垃圾,然后用扫地机器人应对。清理脚本该有,作为兜底,但根治是停服务。
九、这件事和"最小化安装"是同一件事
我们在应用环境搭建那篇里写过装系统的原则:最小化安装,不需要的服务不装;装完还要回头做减法,把默认带上来又用不上的东西关掉。这次故障就是那条原则的反面教材,而且它暴露了一个平时看不见的成本——多装一个服务,成本不止是内存和 CPU,还有它在无人看管时的行为。
邮件服务在 df -h 上完全隐形,在 top 上几乎看不见,但它每天在往队列里写文件。等到它把 inode 用光,表现出来的是"业务系统登录不上"——一个和邮件毫无关系的症状。
同一逻辑也适用于安全侧:机器上多一个监听端口、多一个能发信的服务,就是多一个可被利用的入口。安全加固里讲关闭不需要的服务、收敛攻击面,和这件事其实是同一个动作的两面——关掉不需要的服务,同时解决"资源被悄悄消耗"和"攻击面偏大"。
判断标准一句话:这个服务如果今天挂掉,业务会不会受影响?不会,就该考虑停掉;不确定,先查它最近有没有实际工作(邮件看队列和日志,其他服务看连接数和日志),确认没在干活再停。
十、下次遇到"写不进去",按这个顺序走
把这次的路径压成一张判据表。顺序很重要,因为前两步会把人带偏,第三步才是这次的真凶:
| 顺序 | 看什么 | 命令 | 结果说明什么 |
|---|---|---|---|
| 1 | 空间 | df -h | 满了 → 逐层找大文件 |
| 2 | 谁占着已删文件 | lsof +L1 | 有 (deleted) → 重启对应进程即释放 |
| 3 | inode | df -i | 100% → 本文这种情况,只能删 |
| 4 | 谁在造文件 | du --inodes -d1 /var | 定位目录,再往下钻 |
| 5 | 写入通了吗 | touch /tmp/x | 能建 → 链路已通,再查应用 |
| 6 | 会不会复发 | 10 分钟后再看 df -i | 继续涨 → 源头没停,回第 4 步 |
两个提醒:第 1 步和第 3 步要一起做,别因为 df -h 好看就跳过 df -i;应用日志里"没有报错"要当成线索,不当成结论。
这类故障单看每一环都不复杂,难的是它出现在错误的位置上——所有表面现象都在指向应用,而真凶在系统层、在一个没人记得装过的服务里。我们摩杰斯做这类应急处理时,第一步永远是先把"写入能不能成功"这件事确认清楚,再往上查,因为一个连日志都写不进去的系统,它自己说什么都不能信。这类问题通常一次现场处置加一轮服务收敛就能根治,剩下的功夫在监控上——把 inode、队列长度、目录文件数这三项看见,同类事故就不会等到用户打电话。




