磁盘满了,但 du 说没满
这是运维里最经典的"灵异事件"之一:监控告警磁盘使用率 100%,登录服务器执行 df -h 确实显示 /dev/vda1 已满,可用空间 0 字节;可是你从根目录开始 du -sh /* 一层层加下去,所有目录加起来离分区容量还差好几个 G。空间明明被占着,却找不到是谁占的。遇到这种情况,八成是被删除却仍被进程持有的文件句柄在作祟。
要理解这个现象,得先分清两个概念:du 统计的是目录项(文件路径),它靠遍历文件系统里的目录结构来累计大小;而 df 统计的是文件系统空闲块,是一份块分配账本。当一个进程用 open() 打开文件后,内核会维护一个引用计数。此时如果执行 rm,文件路径(dentry)会被删掉,du 再也看不到它;但只要还有进程持有这个文件的描述符,内核就不会释放底层数据块,df 里那份空间就一直是已用状态。
用 lsof 把幽灵文件揪出来
定位命令很直接,核心是 lsof 加上 +L1 参数(显示链接数小于 1 的文件,也就是已经没有任何目录项指向它、但仍被打开的文件):
# 方法一:直接列出所有"被删除但仍被占用"的文件
lsof +L1
# 典型输出(注意 TYPE 与 NAME 两列)
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
nginx 12345 www-data 4w REG 253,1 2147483648 0 131234 /var/log/nginx/access.log (deleted)
mysqld 23456 mysql 12u REG 253,1 5368709120 0 131300 /tmp/ibtmp1 (deleted)
# 方法二:只看某个分区的(更精准,避免噪音)
lsof +L1 2>/dev/null | awk '$6 ~ /253,1/ {print $2, $7, $9}'
# 方法三:没有 lsof 的极简环境,直接扫 /proc
for pid in /proc/[0-9]*; do
ls -l $pid/fd 2>/dev/null | grep -i deleted
done三个方法里,最后那个 /proc 扫描法是"零依赖"的兜底方案,很多精简的 Docker 镜像里没装 lsof,甚至 ps 都是阉割版,但 /proc 一定在。/proc/<pid>/fd/ 下的每个符号链接都是一个打开的文件描述符,内核会把它标注为 (deleted),一目了然。
需要注意的是,lsof +L1 在文件句柄极多的机器上(比如跑着高并发 Web 服务)输出会非常长,而且它本身开销不小。建议加上分区过滤,或者先用 /proc 扫描确认大致范围,再用 lsof 精确定位到 PID。
释放空间的三种做法与各自风险
找到目标进程后,释放空间的手段不止一种,但风险差别很大,千万别一律用 kill -9。
最优先:让进程自己重开文件。如果被占的是日志文件,绝大多数守护进程都支持 SIGHUP,收到信号后会重新打开日志文件,从而释放旧句柄。这是最安全的方式,业务不中断。
kill -HUP 12345
# 或者用 systemctl 重新加载
systemctl reload nginx次选:直接截断文件。如果你能接受当前文件内容丢弃,可以用 truncate 把文件清空(注意:必须通过 /proc/<pid>/fd/<n> 路径操作,因为原来的路径已经不存在了)。这会立即释放数据块,而且进程的偏移量会停在原处,后续写入会从原偏移开始,导致文件开头一段空洞。对于日志场景通常无伤大雅。
# 找到占用者的 fd 编号,例如 PID=12345 FD=4
: > /proc/12345/fd/4
# 或者
truncate -s 0 /proc/12345/fd/4最后手段:重启或终止进程。只有当前两种都不可行时才用。kill -9 会丢失进程内存中的状态,对数据库、消息队列这类有状态的进程可能造成数据不一致甚至需要恢复,务必先评估再动手。
别再让日志无限膨胀:三层治理
事后救火不如事前设限。第一层是 logrotate,它是 Linux 上标准且可靠的日志轮转方案,关键是配置里要用 copytruncate 还是 create 要选对:对于不支持重开日志的进程用 copytruncate,支持 SIGHUP 的用 postrotate 发信号,这样才不会留下幽灵句柄。
# /etc/logrotate.d/myapp
/var/log/myapp/*.log {
daily
rotate 14
missingok
notifempty
compress
delaycompress
create 0640 www-data adm
sharedscripts
postrotate
/bin/kill -HUP `cat /run/myapp.pid 2>/dev/null` 2>/dev/null || true
endscript
}第二层是应用层自我限制。如果是自己写的程序,务必接入滚动日志库(Python 的 logging.handlers.RotatingFileHandler / TimedRotatingFileHandler,Node 的 pino / winston-daily-rotate-file),并显式设置 maxBytes 和 backupCount。不要依赖"日志没那么大"这种假设,一个循环报错能在几小时内把磁盘写满。
第三层是文件系统级的看门狗。给关键分区加一个定时任务,使用率超过阈值就告警或者自动清理最老的日志。最简单的做法是直接复用系统工具:
# 检查根分区使用率,超过 85% 就发邮件提醒
#!/bin/bash
THRESHOLD=85
USAGE=$(df / | awk 'NR==2 {gsub(/%/,""); print $5}')
if [ "$USAGE" -gt "$THRESHOLD" ]; then
echo "警告:根分区使用率 ${USAGE}%" | mail -s "磁盘告警" admin@example.com
# 顺手把超过 7 天的 .gz 日志删掉
find /var/log -name '*.gz' -mtime +7 -delete
fi几个容易误判的细节
实战里还有几个坑值得单独说。一是 df -i 和 df -h 要分开看,空间没满但 inode 耗尽同样会导致"无法创建文件",那种情况 lsof 帮不了你,得用 find 统计目录文件数。两者症状相似但成因完全不同,别一上来就用错工具。
二是 tmpfs 不算磁盘。很多 MySQL 的临时表、PHP 的 session 会写到 /dev/shm 或者 /tmp(如果 /tmp 被挂成了 tmpfs),这部分占用的是内存不是磁盘,df -h 里会单独列出来,别和根分区混在一起算。MySQL 的 ibtmp1 是常见元凶,如果它异常膨胀,通常是某个超大查询产生了临时表,得从慢查询日志里找根因。
三是日志切割后别忘了压缩和清理策略。只轮转不清理,几周后 .gz 文件照样把磁盘堆满。轮转保留数量(rotate N)和清理期限(find -mtime)至少要有一个。个人站长资源有限,日志该扔就扔,需要长期留存的关键日志应该定期同步到对象存储,而不是无限期堆在系统盘上。
把这几条合起来,其实就构成了一个完整的闭环:出问题上 lsof +L1 定位、用 SIGHUP 或 truncate 释放、配好 logrotate 防复发、再加一个使用率告警兜底。做完这一套,磁盘写满这类事故基本可以被挡在门外。
别忽略安全层:被删除文件的另一种用途
被删除但仍被占用的文件不只是空间问题,它同时也是一个安全信号。很多 rootkit 和后门程序会故意采用「打开文件后立即删除路径」的手法来隐藏自己:文件不再出现在任何目录列表里,普通的 ls、find 甚至 du 都看不到,但进程依然能通过持有的文件描述符读取和执行它。这就是所谓的「无文件落地」持久化技巧。
所以运维时养成一个习惯:定期检查那些「匿名可执行」的映射。做法是扫描 /proc/*/maps 以及 /proc/*/exe,看有没有指向已删除文件的条目。
# 找出可执行文件本体已被删除的进程
for pid in /proc/[0-9]*; do
exe=$(readlink $pid/exe 2>/dev/null)
case "$exe" in
*deleted*) echo "PID $(basename $pid) -> $exe";;
esac
done
# 检查进程内存映射里有没有 deleted 的可执行段
grep -l 'deleted' /proc/*/maps 2>/dev/null | while read m; do
echo "suspicious: $m"; grep 'deleted' "$m" | head -3
done如果发现某个进程的可执行文件路径显示为 /tmp/xxx (deleted),那就值得高度警惕了——正常的系统服务几乎不会以这种方式运行。这时候不要急着 kill 进程,因为文件一关就彻底消失,取证线索全断。正确顺序是:先把 /proc/<pid>/exe 和 /proc/<pid>/fd/* 复制出来留存证据,再检查 crontab、systemd 单元、/etc/rc.local、用户 shell 配置文件里的持久化入口,最后再决定怎么清理。这个顺序和普通的磁盘清理恰恰相反,切勿混淆。
容器环境下的特殊性
容器给这类问题增加了一层复杂度。在 Docker 里,覆盖层文件系统加上日志驱动的组合,会让「被删除文件占用空间」变得非常常见,而且 du 与 df 的口径更容易错乱。具体来说,docker logs 读的是宿主机上 /var/lib/docker/containers/<id>/<id>-json.log 这个文件,默认的 json-file 日志驱动不做任何轮转,容器里应用疯狂打日志,这个文件就会无限膨胀。你进容器执行 du -sh / 完全看不到它,因为它躺在宿主机的目录里。
# 查每个容器的日志文件实际大小
du -sh /var/lib/docker/containers/*/*-json.log | sort -h | tail -5
# 找到后清空(注意:直接 rm 会导致容器继续写入已删除 inode,空间不释放)
truncate -s 0 /var/lib/docker/containers/<id>/<id>-json.log
# 一劳永逸:给 daemon 配置日志上限
# /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": { "max-size": "20m", "max-file": "3" }
}
# 改完需要重启 docker 才生效(已有容器要重建才应用新日志配置)注意上面那条 rm 的警告:这是容器场景最容易犯的错。你 rm 掉 json.log 后,容器里的进程仍然持有该 inode 的写句柄,空间一点都不会释放,df 依旧满着,而且日志彻底看不到了。必须用 truncate -s 0 或者 : > file 这种方式把它清空。这个坑和前面讲的 /proc/<pid>/fd 截断法是同一个原理,只是发生位置不同。另外,如果 /var/lib/docker 和根分区在同一个盘上,日志失控会直接拖垮整机;生产环境建议把 /var/lib/docker 单独挂一块盘或独立分区,把风险隔离出去。