监控告警说根分区 100%,登录上去 du -sh /* 加起来只有 12GB,df 显示 40GB 已用。差了快 30GB,du 找不到任何东西。
这种情况基本都是"文件被删了但进程还开着",但也可能是别的原因。这篇按可能性排一下顺序。
先确认是哪个分区
bash
df -h
df -i
两条都要看。df -h 看块使用率,df -i 看 inode 使用率。有一种情况是块还有空间但 inode 满了,表现是创建文件报 No space left on device,但 df -h 显示还有余量。
text
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vda1 2621440 2621440 0 100% /
inode 满了通常是目录里有海量小文件,比如 session 文件、日志分片、邮件队列。定位:
bash
for d in /var/*; do echo "$(find $d -xdev 2>/dev/null | wc -l) $d"; done | sort -rn | head
情况一:文件被删但进程还持有句柄
这是最常见的一种,占八成以上。
bash
lsof +L1
# 或者
lsof -n | grep -i deleted
输出里会有这样的行:
text
java 18342 app 3w REG 253,1 28901376 /var/log/app/access.log (deleted)
nginx 2041 root 5w REG 253,1 8912896 /var/log/nginx/access.log.1 (deleted)
文件被 rm 了,但进程的 fd 还指着那个 inode,空间不会释放。日志轮转脚本用 mv 或者 logrotate 配错时最容易出现------按理 logrotate 会发信号让进程重开文件,但如果进程不响应 SIGHUP 或者配置里忘了 postrotate,就漏了。
释放空间有两个办法。一,重启进程。二,不重启,直接截断:
bash
: > /proc/18342/fd/3
注意是往那个 fd 里写空内容,不是 rm。这样 inode 立刻释放,进程继续往同一个 fd 写,日志接着记。对 Java 的 logback 有时候不生效,因为写缓冲还在,需要配合触发一次 flush。
用 truncate 更稳:
bash
truncate -s 0 /proc/18342/fd/3
这个操作对正在写的进程有点风险,最好在业务低峰做。而且如果进程用的是追加模式(O_APPEND),截断后偏移量会归零,不会有空洞;如果是随机写,可能产生稀疏文件,看起来空间又回来了。
真正的修复是改 logrotate 配置:
text
/var/log/nginx/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0640 www-data adm
sharedscripts
postrotate
[ -f /var/run/nginx.pid ] && kill -USR1 $(cat /var/run/nginx.pid)
endscript
}
```
copytruncate 也能用,但它有丢日志的窗口(拷贝和截断之间有写入会丢)。对审计日志不能接受。
## 情况二:挂载点覆盖了目录
du 统计的是目录树,如果某个目录下挂了一个文件系统,du 默认会跨过去(除非加 -x 反而会限制)。反过来,如果挂载点被覆盖了,原来的内容看不见但仍占空间。
典型场景:容器里往 /data 写了一堆东西,然后又把一个卷挂到 /data 上,原来的文件还在底层文件系统里,新挂载遮住了它。df 算在根分区,du 看不到。
判断方法是对比总用量:
```bash
du -xsh /* 2>/dev/null | sort -h | tail
df -h /
-x 表示只统计同一文件系统,不跨挂载点。加上之后如果总额和 df 差很多,就说明有被遮住的数据。
处理办法是临时把挂载卸掉,清理完再挂:
bash
umount /data
du -sh /data
# 清理后
mount /data
生产上要小心,umount 失败多半是有进程占着,用 lsof +f -- /data 找。
情况三:稀疏文件和预分配文件
有些程序会预分配整个文件大小,比如 MySQL 的 innodb 表空间、Kafka 的日志段、虚拟机的磁盘镜像。
bash
ls -lh /var/lib/mysql/ibdata1
du -h /var/lib/mysql/ibdata1
ls 显示 12G,du 显示 200M。这种文件的实际占用小于逻辑大小,du 报的是实际块数,所以不会造成"du 查不出但 df 满"。
反过来有一种假稀疏:文件逻辑大小很小但占用很大,几乎不会出现。
情况四:tmpfs 和 /dev/shm
bash
df -h | grep -E 'tmpfs|shm'
tmpfs 占的是内存,但 du 遍历 / 的时候会统计到它(除非加 -x)。如果某个程序往 /dev/shm 写了几个 G,df 显示的是 tmpfs 自己的使用率,不影响根分区。但如果 /tmp 挂了 tmpfs,容器内存会被吃掉------这个和磁盘满无关,但现象很像,值得一起查。
bash
du -sh /dev/shm/* 2>/dev/null
情况五:被删的目录里还有打开的文件
lsof +L1 只列出 link count 为 0 的文件。如果整个目录被删了,里面的文件 link count 还是 1,lsof 抓不到。
bash
lsof -n | grep -i deleted
不加 +L1 会列出所有带 deleted 标记的项,包括那些"文件删了但 fd 还在"但 link count 不为 0 的情况。
更彻底的办法是遍历 /proc/*/fd:
bash
find /proc/*/fd -lname '* (deleted)' -printf '%p -> %l\n' 2>/dev/null
想按大小排序:
bash
for p in /proc/[0-9]*; do
pid=$(basename $p)
for fd in $p/fd/*; do
target=$(readlink $fd 2>/dev/null)
case "$target" in
*"(deleted)"*)
size=$(stat -Lc %s $fd 2>/dev/null)
[ -n "$size" ] && echo "$size $pid $target"
;;
esac
done
done | sort -rn | head -20
```
## 一份能直接跑的排查脚本
```bash
#!/usr/bin/env bash
set -uo pipefail
echo "=== 1. 分区使用率 ==="
df -hP | awk 'NR==1 || $5+0 >= 80'
echo "=== 2. inode 使用率 ==="
df -iP | awk 'NR==1 || $5+0 >= 80'
echo "=== 3. 被删除但仍被占用的文件 ==="
lsof -n 2>/dev/null | awk '/deleted/ {print $1, $2, $7, $NF}' | sort -k3 -rn | head -20
echo "=== 4. 前十大目录(不跨挂载点)==="
du -xhd1 / 2>/dev/null | sort -h | tail -10
echo "=== 5. 挂载点 ==="
findmnt -t ext4,xfs,btrfs -o TARGET,SOURCE,SIZE,USED,USE%
注意 set -uo pipefail 没加 -e,因为 lsof 在某些系统上返回非零,加了 -e 脚本会提前退出。
一次真实的时间线
某次线上:df 显示根分区 98%,du 只有 15G。lsof +L1 发现一个 nginx worker 持有 22G 的 access.log.1(deleted)。原因是 logrotate 配了 create 但 postrotate 里的 kill -USR1 没生效------pid 文件路径写错, -f 判断为假,命令静默跳过,sharedscripts 也不报错。
修复:改 pid 路径,加上 logrotate -d 干跑验证,再在 postrotate 里加一行检查返回值。之后每次轮转都正常。
几个预防措施
日志目录单独分区,写满不影响业务;logrotate 配 maxsize 而不是只看 daily;给关键进程加 file descriptor 泄漏监控;容器里限制日志文件大小,Docker 的 json-file driver 默认不轮转,一天能写几十 G。