周五下午四点半,离下班还有一个半小时,我正在规划周末去哪儿。监控群里弹出一条告警:订单服务所在机器,/data 分区使用率 100%。紧接着,应用日志里开始刷 No space left on device,接口开始 500。
我熟练地登上机器,熟练地敲下 du -sh /data,然后愣住了:du 说 /data 只有 46G,df 说 100G 的盘用了 100G。那 54G 是被外星人拿走了吗?这篇把整个排查过程记下来,顺便把磁盘满了的几种常见姿势(被删文件占用、inode 耗尽、大文件定位、logrotate 配置)一并整理成备忘录。
一、现象:两个工具,两个答案
先别急着 rm -rf 任何东西。第一步永远是把 df 和 du 的数字都留下来,后面要拿来对账:
$ df -h /data
Filesystem Size Used Avail Use% Mounted on
/dev/vdb1 100G 100G 0 100% /data
$ du -sh /data
46G /data差了 54G。这个差值本身就是线索:df 读的是文件系统超级块里“已分配的块数”,du 是沿着目录树逐个 stat 文件累加。两者不一致,说明有一部分空间被“分配”了,但目录树里已经找不到对应的名字。
能造成这种情况的,大致有这几类:
- 文件被删除(unlink)了,但还有进程打开着它——最常见,本文主角;
- 挂载点下面被“盖住”的文件:先往 /data 目录里写了东西,后来才把磁盘挂到 /data,旧文件被盖在挂载点下面,du 看不到;
- 文件系统自身的元数据、journal、预留块(ext4 默认给 root 预留 5%);
- LVM/快照/容器 overlay 的额外占用。
先从概率最高的开始查。这里我先说结论:这次就是第一种。
二、排查路线图
我的固定动作是“先看空间,再看 inode,再对账 df/du,最后找被删文件”。画成图:
三、动手:一步步把“幽灵空间”揪出来
第 1 步:确认是空间满,还是 inode 满
“No space left on device”这句报错有个坑:它既可能是块空间满了,也可能是 inode 用完了。所以两个都要看:
$ df -i /data
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vdb1 6553600 184213 6369387 3% /datainode 才用了 3%,排除。顺便说一下 inode 耗尽长什么样,后面会用到:当 IUse% 到 100% 而 df -h 还有大把空间时,说明这个分区被海量小文件塞满了(session 文件、临时文件、邮件队列都是惯犯)。
第 2 步:用 du 逐层下钻,先看“看得见”的
找大目录我固定用下面这条,-x 表示不跨文件系统(避免把别的挂载点也统计进来),sort -h 能按 K/M/G 的人类可读单位排序:
$ du -xh --max-depth=1 /data 2>/dev/null | sort -h | tail -6
1.2G /data/backup
3.4G /data/tmp
8.9G /data/app-data
14G /data/logs
17G /data/mysql
46G /data一层一层往下钻(/data/logs → 具体服务目录),最终看得见的日志加起来就是那 46G。如果机器上能装工具,ncdu /data 是更舒服的交互式方案,方向键进目录、d 删除,一眼看清谁最胖;线上不方便装就用上面这条组合拳。
此时的判断:看得见的文件只有 46G,df 却说 100G——54G 是“隐形”的。该 lsof 出场了。
第 3 步:lsof +L1,找被删掉却还被打开的文件
lsof 手册里对 +L 的说明是:启用文件链接数(link count)的列出,其中 +L1 表示“选出链接数小于 1、也就是已经被 unlink 的打开文件”。这正是我们要的:
$ lsof +L1 /data
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
java 8231 app 1w REG 253,17 57982058496 0 393218 /data/logs/order/stdout.log (deleted)
java 8231 app 2w REG 253,17 57982058496 0 393218 /data/logs/order/stdout.log (deleted)逐列解释一下:
FD = 1w / 2w:文件描述符 1 和 2,也就是 stdout 和 stderr,w表示只写;SIZE/OFF = 57982058496:约 54GB——和 df/du 的差值严丝合缝;NLINK = 0:链接数为 0,目录里已经没有任何名字指向它;NAME末尾的(deleted):内核在/proc/PID/fd的符号链接目标上追加的标记。
没有加 +L1 的老写法同样有效,只是输出会很吵:lsof /data | grep deleted。想按大小排序找最大的:lsof +L1 | sort -k7 -rn | head(第 7 列是 SIZE/OFF)。
案发经过也水落石出了:几天前有同事发现 /data 快满了,登上来看见一个 50 多 G 的 stdout.log,果断 rm 了它——空间没释放,他还以为是缓存延迟,隔天又加了一个新的日志开关,磁盘就彻底满了。
第 4 步:从 /proc 看同一件事
没有 lsof 的精简机器/容器里,直接看 /proc 也行。每个进程的 /proc/PID/fd/ 目录下,每个打开的文件都是一个符号链接,指向真实路径;文件被删后,链接目标后面会多出 (deleted):
$ ls -l /proc/8231/fd | grep deleted
l-wx------ 1 app app 64 Sep 26 09:12 1 -> /data/logs/order/stdout.log (deleted)
l-wx------ 1 app app 64 Sep 26 09:12 2 -> /data/logs/order/stdout.log (deleted)
# 对所有进程一网打尽
$ ls -l /proc/*/fd 2>/dev/null | grep deleted这里有个很反直觉、但非常有用的事实:这个符号链接是“魔法链接”,即使目标文件已经没有名字,你依然可以直接 cat /proc/8231/fd/1 把内容读出来——很多人就是靠这招把误删的日志/文件“找回来”的。
四、根因:unlink 只是删了“名字”
要理解这件事,得知道 Linux 文件系统的模型:文件名(目录项)和文件本体(inode + 数据块)是分开的。rm 调用的是 unlink(),它只做两件事:删掉目录里的这个名字、把 inode 的链接数减 1。
数据块什么时候真正释放?当且仅当“链接数为 0 且没有任何进程打开着它”。Java 进程启动时被重定向到了 stdout.log(比如 nohup java -jar app.jar > stdout.log 2>&1 &),文件描述符 1 和 2 一直握着这个 inode。你 rm 了文件名,进程毫不知情,继续往这个已经没有名字的 inode 里写,磁盘继续被占用,而 du 从目录树上永远遍历不到它。
再补一个延伸:既然进程还在写,这个隐形文件还会继续长大。所以“rm 完空间没降,过几天更满了”是这类问题的典型剧本。
顺便提一下 logrotate 的一个经典关联坑。对于不支持“收到信号后重新打开日志文件”的程序,logrotate 有两种处理方式:默认是“把旧文件 mv 走,再创建新文件”,但程序仍然写着被改名的旧 inode;另一种是 copytruncate:先复制一份,再把原文件截断为 0,程序继续往原文件写。官方手册对它的定义是“复制之后在原地把日志截断到零大小,而不是移走旧日志再(可选地)创建新的”,同时明确警告:复制和截断之间有一个很小的时间窗口,可能丢失一部分日志。所以 copytruncate 是“能用但不完美”的折中,能让程序 reopen 的,就别用它。
五、止血与修复
止血:不重启,把空间还回来
有两个选择,按业务允许程度取舍:
- 截断文件描述符指向的文件(不停服务):对追加写的日志,可以直接把它清零,空间立刻释放:
fd 1 和 fd 2 指向同一个 inode,截断一次即可。注意:只对日志这类你愿意丢内容的追加文件这么做,别对数据库 WAL、正在使用的数据文件动手,会把数据搞坏。如果内容还想保留,先$ : > /proc/8231/fd/1 # 或者 $ truncate -s 0 /proc/8231/fd/1 $ df -h /data Filesystem Size Used Avail Use% Mounted on /dev/vdb1 100G 46G 54G 46% /datacp /proc/8231/fd/1 /somewhere/else/(前提是别处有空间)再截断。 - 重启进程:进程退出,所有 fd 关闭,内核立刻回收块。需要停服务,但最干净。多实例部署时滚动重启即可。
那天我选了第 1 种:先确认那是 stdout 日志(业务日志本身走 logback,另有文件),截断,df 立刻回到 46%,接口恢复。整个过程没摘流量。
根治:让“删日志”这件事不再需要人
1)应用日志走 logback / log4j2 的滚动策略,别靠 shell 重定向。stdout 里只留启动信息,业务日志自己按天/大小滚动,并限制总量:
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/data/logs/order/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>/data/logs/order/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>200MB</maxFileSize>
<maxHistory>7</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
<encoder><pattern>%d %-5level [%thread] %logger{36} - %msg%n</pattern></encoder>
</appender>2)对必须留下的 stdout.log,用 logrotate,并且理解 copytruncate 的取舍:
$ cat /etc/logrotate.d/order-stdout
/data/logs/order/stdout.log {
daily
rotate 7
compress
delaycompress
missingok
notifempty
copytruncate # 进程无法 reopen 日志时才用;有丢几行日志的风险
}
# 先空跑看看会做什么
$ logrotate -d /etc/logrotate.d/order-stdout3)以后清理磁盘,别再手抖 rm 正在写的日志。正确姿势是: > file 或 truncate -s 0 file——原地清零,inode 不变、进程继续写、空间当场释放。
4)磁盘告警要设两档,还要盯 inode。比如 80% 预警、90% 紧急,同时监控 node_filesystem_files_free(inode)。别等 100% 才知道。
六、顺便:另外三种“磁盘满”的姿势
1. inode 耗尽
症状:df -h 还有几十 G,但 touch a 报 No space left on device。排查:
$ df -i /data
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vdb1 6553600 6553600 0 100% /data
# 找出哪个目录小文件最多
$ for d in /data/*; do echo "$(find "$d" -xdev | wc -l) $d"; done | sort -rn | head处理:清理海量小文件(find /data/tmp -type f -mtime +7 -delete,目录太大时 find -delete 比 rm -rf 通配符稳,别让参数列表爆掉)。ext4 的 inode 数在格式化时就固定了,长期要靠调整业务(合并小文件、换 XFS 动态 inode)。
2. 挂载点盖住的旧数据
症状:df 比 du 大,lsof 又找不到。可以用 bind mount 把根分区“重新挂一遍”看看盖在下面的东西:mount --bind / /mnt/root_view; du -sh /mnt/root_view/data,看完记得 umount。
3. Docker 的 overlay2 与容器日志
容器里的 json-file 日志默认不限大小,会把 /var/lib/docker 吃光,用 docker system df 看概览,并在 daemon.json 配 log-opts 的 max-size / max-file。这是另一个故事,容器分类里会单独讲。
七、复盘
| 做法 | 为什么 |
|---|---|
df 与 du 对账,差值大就 lsof +L1 | 差值 = 被删除但仍被进程持有的文件大小,一步定位 |
清理日志用 : > file / truncate -s 0,不用 rm | rm 只删名字,进程还握着 inode,空间不释放 |
已经 rm 的,用 /proc/PID/fd/N 截断,或重启进程 | fd 是通往那个 inode 的唯一入口,截断或关闭才会真正回收块 |
| 日志走 logback/log4j2 滚动,设 maxHistory 与 totalSizeCap | 让“不会满”成为默认,而不是靠人手 |
| logrotate 优先用 postrotate 通知程序 reopen,不得已再 copytruncate | copytruncate 有复制与截断之间的丢日志窗口 |
| 监控同时盯磁盘使用率与 inode 使用率,80%/90% 两档 | “No space left”有两种原因,别只看一种 |
一句话备忘:df 大 du 小 →
lsof +L1找 (deleted) →: > /proc/PID/fd/N截断或重启;df 正常还报满 →df -i看 inode;清日志用截断,别用 rm。
后来那位同事请我喝了杯奶茶,还把这条备忘写进了值班手册第一页:“文件删了不代表它走了,可能只是换了个身份继续住在你家。”——像极了某些不肯搬走的房客。
参考资料
- lsof(8) 手册:说明
+L/+L1选项可选出链接数为 0(已被 unlink)的打开文件,以及 “(deleted)” 标记的含义。 - proc_pid_fd(5) 手册:说明 /proc/PID/fd 下每个打开的文件以符号链接形式呈现。
- logrotate(8) 手册:copytruncate 的行为定义,以及复制与截断之间可能丢失日志的官方警告。
- Your Disk Is Full but du Says It's Empty(DEV Community):社区对 df/du 不一致原因的案例分析,提到 lsof +L1、重启与 truncate /proc/PID/fd 的处理方式。
一起聊聊