
凌晨 1 点的告警群里突然弹出一条消息“磁盘空间使用率 95%”。打开终端df -h一看/data已经 100%再执行du -sh /data/*把所有目录加起来连 40% 都不到。这种时候如果脑子一热直接rm -rf找大文件删极有可能把真正的问题越埋越深。磁盘空间排障里最折磨人的从来不是简单的“满了”而是du和df对不上、inode 耗尽、已删文件不释放这类“几条命令互相打架”的场景。这篇就把我这些年被磁盘告警折腾过的经历拆开按七类典型深坑逐个复盘。包括du和df统计口径差异、进程持有已删除文件、inode 耗尽、日志轮转假成功、容器 overlay 空间幻影、磁盘配额隐性问题、以及挂载点导致的误判。不论你是刚入行的运维新人还是被线上告警压过几年的老手这七个坑多半都值得从头到尾过一遍。1. 告警不是终点先建立“空间账本”的两种视角1.1 一条告警背后可能藏着七种病磁盘告警是表象但“磁盘空间没了”这句话本身就不够精确。同样是使用率 100%根因可能是某个目录确实堆了大量文件du能直接看到。文件被删除了但进程没释放文件句柄空间还被锁着。inode 被小文件吃尽df -h还有余地但新文件根本创建不出来。日志轮转动作成功了但服务进程还在写旧 inode。容器删了、镜像删了底层文件系统却始终不回收。文件系统启用了用户配额或目录配额某个用户/项目触顶。你以为的大分区底下还挂了个小分区df查的压根不是你du的这个路径。我在处理告警时第一步永远是先确认“到底是哪一层满了”而不是急着删。1.2 df 和 du 的统计口径决定了你会不会误判很多人对du和df的认知是“都是查磁盘大小”其实这是两个完全不同的账本。df是文件系统层面的统计它直接读取文件系统的超级块看这个块设备上已用块、可用块、保留块和 inode 占用情况。换句话说它关心的是“这个文件系统被用掉了多少空间”。du是目录树层面的统计它从你指定的路径出发递归遍历所有目录项把每个文件的大小累加起来。它关心的是“从这个目录往下看所有可见文件加起来有多大”。这两个账本之间有正常差值也有异常差值。比如文件系统元数据本身占用、ext 系列文件系统的保留块默认通常为 5%、以及磁盘格式化时的少量损失这些都会让df显示的使用量大于du的统计结果。但真正让人抓狂的是异常差距——进程打开的已删除文件、跨挂载点统计、稀疏文件、日志文件被 write 但未同步等这些都会造成“du 看着没多少df 却爆了”的诡异现场。1.3 排查前先记录现场别一上来就 rm我个人踩过最大的坑就是接到告警后立刻去删文件。有一次线上/tmp满了我直接清了/tmp/cache结果磁盘报警确实解除了但业务方过来说缓存数据全丢了查询性能暴跌。正确的做法是先保存现场记录当前df -h、df -i、du -xsh --max-depth1的结果再进入排查链路。空间是能释放的但如果你不知道是谁占用了它贸然删除只会换来“告警解除但业务受损”的更差结果。2. 深坑一du 和 df 对不上——两个账本天然记的不是同一笔账2.1 差异来源已删未释放、元数据、保留块、稀疏文件du和df对不上不代表系统出了问题但也不代表你可以直接忽略。常见的正常差值来源有第一文件系统元数据开销。inode 表、块位图、日志journal都会占用空间ext 系列尤其明显。这部分空间du永远统计不到。第二ext 文件系统的保留块。mkfs.ext4默认会保留 5% 的块给 root 用户用于防止文件系统碎片化严重或 disk full 时 root 无法登录进行修复。df会把它算进“已使用”但du完全不知道这部分存在。第三稀疏文件。du按实际分配的块统计df按文件系统记账两者对稀疏文件的处理方式不同也可能出现差异。第四已删除但仍被进程持有的文件。这是最常见、也最凶险的异常差异。你rm掉了一个 20G 的日志文件但某个进程还握着它的文件句柄不撒手块设备上的空间就始终不会真正释放。此时du已经看不到这个文件了df却依然顶着 100%。2.2 实战定位用 lsof 揪出隐藏占用当du -sh /data与df -h /data相差悬殊时第一步就是用lsof查已删除但仍被打开的文件lsof L1 /dataL1表示列出 link count 小于 1 的文件也就是“路径已被删除但 inode 仍被进程引用”的文件。如果系统没有lsof也可以直接遍历/procls -l /proc/*/fd/* 2/dev/null | grep (deleted) | head -20看到(deleted)字样就说明找到了问题源。接着用ls -l /proc/PID/fd检查具体是哪个文件、哪个进程。ls -l /proc/12345/fd | grep deleted ps -fp 12345正常情况下你会看到类似这样的输出lrwx------ 1 root root 64 2月 9 12:00 9 - /var/log/app/access.log (deleted)这就说明PID 12345 的进程打开了一个已经被删除的日志文件文件原始大小仍然被挂在文件系统账本上。2.3 为什么不建议只靠 du 去找大目录du慢是出了名的尤其在根目录或海量小文件的目录下一条du -sh /可能跑几分钟甚至更久。原因是它要遍历目录树中的每个文件每次 stat 都要经过磁盘 IO。想快速定位大目录时我习惯这样用du -xsh --max-depth2 /data 2/dev/null | sort -rh | head -20-x/--one-file-system很关键避免du跨过挂载点递归到别的文件系统里否则统计结果会包含子挂载点的文件进一步混淆判断。如果嫌慢可以先从/proc/mounts里排除掉 tmpfs、proc、sys 等虚拟文件系统。这里要补充一句du看到的小文件多系统调用开销就大scan 时间翻倍。如果确定是文件数量级很大可以考虑用ncdu这类交互式工具它会缓存目录条目二次定位更快。3. 深坑二已删文件不释放——进程把 inode“含在嘴里”3.1 现象重现df 满、du 空有一次客户的 redis 主从节点数据目录满了df -h显示/data100%但du -sh /data/*统计出来的总和不到 20G特别诡异。排查到最后发现是备份脚本用rm删了一个超大 RDB 文件而 redis 进程还在持续向旧文件写入。因为 Redis 在启动时已经保存了 master 的 socket 句柄严格说不是而是 dump.rdb 的 fd 被一直打开着RDB 文件虽然被rm删除了进程却依然持有 fd空间一直留在磁盘上。这个现象在 Linux 下非常典型文件是否真正释放空间取决于 open 文件的引用计数是否归零而不取决于路径是否还存在。进程打开文件后文件里的 inode 被标记为 in use当unlink删除路径时如果还有进程持有 fd磁盘块不会立刻回收要等最后一个打开该文件的进程关闭或退出。3.2 完整排查链路lsof L1、/proc 目录、确认与处置针对“df 满、du 空”的情况推荐按下面流程走确认是哪个文件系统满df -hT快速确认是不是 inode 问题df -i查已删除但未释放的文件lsof L1 /data如果lsof没装直接看/procls -l /proc/*/fd/* 2/dev/null | grep (deleted) | grep /data这里注意区分(deleted)表示文件已被删除但句柄没释放如果是/data/.nfs*这类 NFS 特殊文件也是类似状态处理方式不完全一样。识别进程并确认归属ls -l /proc/PID/fd | grep deleted ps -fp PID pstree -p PID与业务方确认后通过kill或重启进程完成释放再用df -hT /data验证。3.3 处置不是只有 kill清空 fd、优雅重载与优雅重启很多人一看到 deleted 就直接 kill 进程这在生产环境中很危险。正确的处置是根据进程类型分级处理对于日志类文件如果进程支持重载信号优先使用优雅重载。比如 nginx 可以执行nginx -s reopensyslog 可以发kill -USR1。如果进程不支持重载但业务允许重启走重启流程。如果是数据库这类绝对不能随便重启的进程就要用更保守的方案。有一个技巧是清空该 fd 对应的文件内容让文件长度归零空间立刻释放进程还感知不到: /proc/PID/fd/FD但这对日志文件可行对数据库数据文件绝对不能这么做否则等于把在线文件截断成 0后果堪比删库。遇到数据库时宁可协调维护窗口重启也不要走这种捷径。3.4 经典现场nginx 日志轮转后不重开文件这个坑我遇到过很多次logrotate 每天切 nginx 的 access.log日志成功变成了 access.log.1但业务方说磁盘空间还在上涨。一查lsof发现 nginx worker 进程仍持有access.log的旧 inode新的 access.log 虽然存在但 nginx 根本不往它里面写。原因是 logrotate 只负责 rename没有触发 nginx 重新打开日志文件。nginx 在启动时会持有日志文件的 fd后续写入都走这个 fd即使原路径被 rename写入还是进旧文件。只有收到USR1信号后nginx 才会重新按当前配置打开日志文件。所以 logrotate 对 nginx 必须配置postrotate /bin/kill -USR1 $(cat /run/nginx.pid 2/dev/null) 2/dev/null || true endscript同类问题在 java 服务、Python 后台任务里也经常出现只要应用没有按日志文件路径重新 open光做 rename 等于白做。4. 深坑三inode 耗尽——磁盘明明有剩余却没地方“登记”新文件4.1 inode 到底是个啥inode 就是索引节点它保存了一个文件的元数据权限、属主、大小、时间戳、数据块指针等。可以用小区门牌号来类比小区里楼栋和房间再多只要门牌号用完了就没办法再登记新住户。文件系统创建时inode 的数量就基本定死了ext 系列每个文件、目录、软链接都要占用一个 inode哪怕内容只有 0 字节。所以会出现一种非常反直觉的现象df -h显示还有 50G 空间但touch newfile却报No space left on device。因为文件系统已经没有空闲 inode 了。4.2 怎么快速确认是不是 inode 满一条命令就够df -i输出里IUsed%达到 100%就说明 inode 耗尽。如果df -h还有余量但要往这个分区写文件却失败基本可以锁定 inode 问题。进一步查看具体 inode 资源tune2fs -l /dev/sda1 | grep -i inodexfs 文件系统用xfs_info /data4.3 谁在制造海量小文件从 /tmp 到缓存目录inode 耗尽的前置条件通常不是大文件而是“海量小文件”。我处理过的典型案例来自几个地方/tmp目录下监控脚本每 10 秒生成一个临时结果文件不清理跑几天上百万个文件。/var/spool/mqueue或消息队列目录因为投递失败堆积了巨量待发送文件。PHP/JSP 的 session 目录因为 session 没回收文件数持续膨胀。Elasticsearch 分段文件过多这个通常是大文件不至于耗 inode但 Lucene 在异常崩溃后留下大量.cfs碎片文件时也可能引发。容器或应用缓存目录比如商品图片缩略图按用户维度生成一天几百万个小图。定位哪个目录小文件最多我习惯用新版 GNU coreutils 提供的du --inodes -h /data 2/dev/null | sort -rh | head -20如果du版本不支持--inodes可以用一个近似脚本find /data -xdev -type f | awk -F/ {print $1/$2/$3} | sort | uniq -c | sort -rn | head -204.4 修复与预防删文件、迁移、mkfs 时的 inode 预留定位到目录后和业务方确认哪些文件可清理然后删除。删除时注意不要一条rm -rf硬删文件数量太大时rm也会很慢建议分批find /data/cache -type f -mtime 7 -delete如果问题反复出现根治手段有两个方向一是调整文件系统参数。ext 系列在mkfs时可以指定字节与 inode 比例比如mkfs.ext4 -i 8192 /dev/sdb1-i 8192表示每 8192 字节分配一个 inode。对大量小文件场景可以调成-i 4096甚至更小换取更多 inode 容量。但注意在已经格式化好的文件系统上没法直接调只能重新格式化或迁移数据。二是迁移到 xfs。xfs 的 inode 是动态分配机制文件系统不会因为 inode 数量耗尽而锁死这是很多生产环境选择 xfs 的原因之一。不过 xfs 也有自己的上限受 AG分配组结构影响真到海量小文件的极端场景同样要关注 inode 告警监控。4.5 补充为什么 xfs 很难 inode 耗尽ext4 在格式化时一次性划出 inode table数量固定xfs 则是按需分配 inode chunk每 64 个 inode 一组动态增长。所以同样容量的盘xfs 能支持的文件总数通常远大于 ext4 的固定上限而且不会出现“inode 满了但空间还剩一半”的尴尬局面。但这不代表用 xfs 就可以高枕无忧。极长路径、数百万级小文件的目录xfs 的元数据操作也会变慢最终瓶颈移到 CPU 和 IO 上。监控层面还是建议把df -i纳入告警而不是等到报错。5. 深坑四logrotate 的“假成功”——轮转动作完成空间照样被吃光5.1 轮转成功和空间释放为什么是两回事日志轮转的失效模式和坑二强相关但很多人单独排查时会绕进去。现象是日志目录下文件数量正常老日志也轮转成了.1、.2.gz可df显示磁盘使用率还在涨。一看lsof一堆(deleted)文件大得惊人。原因我在 3.4 里讲过logrotate 默认策略是 rename 旧文件再 create 新文件应用进程如果不重新打开日志文件写入就还指向旧 inode。旧文件已经被 rename 成.1路径还在但它还在持续增长如果日志文件名包含日期且旧文件随后被 gzip 压缩gzip 进程可能读到一半发现文件“变了”或者还在涨压缩结果也异常。这就是“假成功”logrotate 日志里显示轮转成功但真正吃空间的文件根本不是你能看到的那个。5.2 正确姿势一向进程发送重开信号对于大多数主流服务logrotate 的标准配置是在轮转后通知进程重新打开日志文件。nginx、apache、rsyslog、docker 容器内进程都支持类似信号。写配置时务必包含postrotate/var/log/nginx/*.log { daily rotate 14 compress missingok notifempty sharedscripts postrotate [ -f /run/nginx.pid ] kill -USR1 $(cat /run/nginx.pid) endscript }Java 应用通常不支持信号重开如果日志框架是 log4j2 的 RollingFile建议直接让应用自己管理轮转不要再用系统 logrotate 去切否则很容易踩到句柄不释放的坑。Golang 服务则常用 file 库自己做 reopen交给 logrotate 时同样要确认实现了 SIGHUP 重开逻辑。5.3 正确姿势二copytruncate 的取舍如果应用完全没有重开日志的能力可以选择copytruncate/var/log/app/*.log { daily rotate 7 compress copytruncate }copytruncate的策略是先复制当前日志内容到目标文件然后把原文件长度截断为 0。因为原 inode 没变应用的 fd 也没变它继续往原文件写完全没有问题。代价是它可能在复制和截断之间丢失极少量的日志行并且每次轮转都要完整复制一遍大文件性能开销高。日志量特别大的场景不建议用否则轮转期间磁盘 IO 会被吃满。折中方案是给应用日志直接接入标准日志库的滚动能力或者上日志采集组件集中收集别让 logrotate 成为唯一手段。5.4 一个容易忽略的组合坑日切脚本 gzip 进程还有一种少有人提的坑轮转后 gzip 压缩旧日志压缩进程打开旧日志文件读取。如果日志文件又大gzip 可能要跑很长时间。这段时间里如果磁盘在继续写入其他日志也会造成“临时双倍占用”。比如一个 10G 的 access.log 轮转出来后gzip 期间磁盘上同时存在 10G 原文件 10G 未压缩内容 压缩产生的临时空间瞬间又触发一次告警。应对办法是delaycompress让压缩延迟到下个周期执行错峰占用并且给 logrotate 的 gzip 进程设置ionice避免和业务 IO 抢资源。6. 深坑五容器里的空间幻影——镜像删了、层还在、宿主 df 照样满6.1 容器场景和传统差别在哪宿主机上跑着 docker 和一堆容器时磁盘空间排查会比传统环境复杂一截。du -sh /可能半天扫不完而且/var/lib/docker的目录层级和 overlay 文件系统结构不直观你很难一眼看出哪个 overlay2 目录对应哪个镜像或容器。更麻烦的是容器本身也会产生大量“已删除但未释放”的文件。比如容器里的进程写入了日志日志文件在容器内被轮转删除但容器进程还持有 fd又比如容器停止、删除但宿主机上的 docker 进程或 containerd-shim 还保留着对挂载点和镜像层的引用导致空间迟迟不回收。6.2 排查路径docker system df、/var/lib/docker、lsof第一步先看 docker 自己的统计docker system df它会分成 Images、Containers、Local Volumes、Build Cache 几类告诉你哪部分空间“可回收”。第二步找/var/lib/docker下面的大头du -h --max-depth1 /var/lib/docker 2/dev/null | sort -rh通常/var/lib/docker/overlay2和/var/lib/docker/containers是主要占用。containers目录下每个容器有一个-json.log这是容器 stdout/stderr 的日志文件可能会长到几十 G而且 docker 默认不轮转。第三步用老办法查 deleted 文件lsof | grep /var/lib/docker | grep deleted如果看到 containerd-shim 或 dockerd 引用了已删除的日志文件或 overlay 层文件说明容器虽然没了但 shim 进程还没退出空间自然不释放。6.3 最隐蔽的占用者容器 stdout 日志无限增长容器日志是独立于容器文件系统的。docker logs输出默认由 json-file 驱动写到宿主机即使容器内文件被删除只要宿主机上这个 json 文件存在空间就一直被占住。很多团队会直接rm -f /var/lib/docker/containers/xxx/xxx-json.log这是非常危险的操作docker 引擎本身可能还持有这个文件的 fdrm之后空间不会真正释放甚至导致 docker 引擎日志错乱。正确的清理姿势是truncate -s 0 /var/lib/docker/containers/container-id/container-id-json.log根治方案是在/etc/docker/daemon.json里配置日志滚动{ log-driver: json-file, log-opts: { max-size: 50m, max-file: 5 } }然后重启 docker 引擎或逐个重建容器。没有重启前新配置不会对已有容器生效。6.4 清理顺序停容器、清构建缓存、限制日志大小如果宿主机空间已经告急我建议按以下顺序操作能最大化释放空间且降低误伤先清理已退出的容器docker container prune再清理悬空镜像docker image prune如果构建缓存很大docker builder prune最后统一看docker system df是否恢复这里要特别注意不要在一开始就执行大范围 prune因为有些容器/镜像可能还要回滚。确认告警解除后再做最终清理会更稳。7. 深坑六quota 的隐形天花板——df 显示空闲写入报 No space7.1 现象有空间但“No space left on device”另一类让人迷惑的问题是df -h明明还有 30G应用却报磁盘满。排查 inode 也没问题数据目录也正常。这种场景很可能就是磁盘配额。quota 是文件系统层面的“用户级限制”在/etc/fstab里给挂载点加上usrquota或grpquota后系统会按 user 或 group 维度限制其在某个文件系统上的可用空间。df显示的是文件系统全局使用率而 quota 显示的是某个用户/项目已用的额度两者完全是两个维度。触发后的表现因程序而异有的程序会明确提示Disk quota exceeded程序一般直接写失败有的程序把 EDQUOT 映射成No space left on device让排查绕一大圈。7.2 定位 quota 的三个命令先看当前用户配额quota -u username看整个文件系统上所有用户的配额repquota -arepquota -a能看到 Blocks 和 Inodes 的 soft limit / hard limit以及当前使用量。如果某个用户grace列显示超限或者 blocks 接近 hard limit基本可以锁定。再确认是不是 quota 导致写入失败可以用dmesg看内核日志dmesg | tail -20如果出现Quota exceeded字样问题明确。7.3 提额和清理之外还要注意 NFS 的配额隐藏处理方式无非两条找业务方确认后可清理临时文件或者由管理员调整配额edquota -u username setquota -u username 80G 90G 0 0 /data但有一点值得单独讲NFS 服务端挂载时配额经常被忽略。客户端df看到的是 NFS 导出目录在服务端的容量quota 限制却可能在服务端某个用户上。当 NFS 客户端写入报 ENOSPC 但服务端df还有空间时一定要去 NFS 服务端检查用户配额而不是在本机排查。这类问题最坑的地方在于没有quota相关监控的话平时根本察觉不到配额已到顶只有业务报错时才暴露。如果公司内部有共享存储建议把 quota 使用率也纳入监控大盘。8. 深坑七挂载点盖住真相——df 看到的可能是“另一个分区”8.1 路径和文件系统不一致的迷惑现场还有一种场景和 quotal 无关但同样容易让人原地打转某条命令告诉你/data满了可你du了整个/data才用了 20%误差巨大。这时候你要怀疑的不是 du 统计错误而是/data下面存在挂载点把真正的数据藏到了另一个文件系统里。举个例子/data是一个 500G 的大分区/data/mysql单独挂载了一个 50G 的小分区。df -h /data/mysql显示 100%但du -sh /data/mysql只有 10G说明这个 50G 的小分区里还有大量你看不见的历史文件。如果你只看df -h /data看到的是 500G 大分区的状态完全不知道底下的子挂载点已经满了。反过来也一样/data满了但du -sh /data/*加起来很小可能是因为/data/tmp挂载了 tmpfs 或另一个独立分区真正的空间消耗发生在那个子设备上而你只对着大分区做排查。8.2 findmnt 和 df -hT 的正确姿势排查路径对应的真实文件系统我习惯用df -hT /data/mysql findmnt -T /data/mysqlfindmnt -T会沿着目录树向上找到实际挂载点输出设备、挂载选项、文件系统类型。另外用lsblk看全局设备树也很直观lsblk -f如果有人之前用 bind mount 或子目录挂载遮挡了原目录mount命令的输出里能看到类似/dev/sdb1 on /data/mysql type ext4 (rw,relatime)8.3 bind mount、tmpfs 与 loop 设备的干扰bind mount 是另一个容易制造“空间幻影”的手段。它可以把任意目录挂到另一个位置比如把/var/lib/redisbind mount 到/data/redis。此时你du /data/redis看到的是 redis 数据但实际占用的底层块设备还是/var/lib/redis所在分区的如果和预期不符很容易查错方向。tmpfs 的统计口径也和普通磁盘不一样df -h /tmp显示的是内存容量tmpfs 内的文件删除后空间立刻归还内存但由于它同时使用内存和 swapfree看到的缓存数据往往被误解为“内存泄漏”。很多桌面环境下报“磁盘空间不足”但内存还有余量其实是因为/tmp挂的是 tmpfs 且已经到了 tmpfs 的上限。所以遇到任何磁盘异常先问一句这个路径的挂载结构到底是什么用findmnt确认比凭空猜测高效得多。9. 从告警到收尾一套不容易漏项的空间排查顺序9.1 我推荐的排查动作清单按序执行踩过的坑多了以后我给自己整理了一条固定排查动作每次磁盘告警按这个顺序走基本不会漏项df -hT确认哪个文件系统满记录告警前的所有输出。df -i排除 inode 耗尽。用findmnt -T确认目标路径真实挂载的设备。du -xsh --max-depth1快速定位大目录目录结构复杂时用ncdu或du --inodes辅助。如果du结果远小于df执行lsof L1找已删除未释放文件。若环境涉及容器执行docker system df并检查容器 json 日志大小。若怀疑配额执行repquota -a或quota -u确认。确认根因后再启动物理清理或协调业务方操作。这套清单看着基础但正是这些基础操作能把你从“告警 → 找不到原因 → 乱删文件”的循环里拉出来。9.2 一些可以让下次排查更快的小习惯我个人的习惯是把下面几项做成日常监控层面除了df -h使用率把df -i和每个容器的日志文件大小也加进去。很多 inode 问题和容器日志问题在变成线上故障之前其实都有几十个小时的缓冲期。在/etc/logrotate.d/里为所有关键服务写好轮转配置并确认 postrotate 信号是有效的。可以用logrotate -d做 dry-run 验证。对/tmp、/var/tmp、缓存目录建立定期清理任务同时考虑用 systemd-tmpfiles 或 cron 做过期文件回收。大文件删除后用df -h确认空间是否真正释放。如果没释放马上进lsof L1流程别等业务来报障。9.3 写在最后的经验优先保业务再查根因复盘这些年处理磁盘告警的经历最深的体会是告警处理第一条永远是保业务第二条才是查根因。空间不够了先确认是不是有明确的临时文件可清是不是有安全的日志截断空间可腾然后再去定位 du/df 不一致、inode、配额这类隐蔽问题。有些问题在深夜两三点不一定能立刻根治能先把水位降下来、让服务恢复已经算成功。但底线是不能在没搞清楚谁占用了空间之前就盲删。一次盲删可能让告警瞬间解除也可能让业务缓存、历史日志、数据库备份灰飞烟灭。每一条命令执行前问自己一句这个文件是谁的删了之后有没有人在五分钟内给我发消息排查工具永远是那几条命令但怎么组合、怎么判断优先级才是磁盘空间管理真正考验人的地方。希望这套复盘能让你下次再看到“磁盘空间告警”时少走一段弯路。