为什么"删掉一个文件"之后磁盘还是满的
这是运维里最常见、也最容易让人怀疑人生的场景之一:日志把磁盘撑爆了,你顺手 rm -f /var/log/nginx/access.log,然后跑 df -h,发现可用空间一点没变,Use% 还是 100%。再 du -sh /var/log/nginx 一看,目录才几百 K。
这时候很多人会以为 df 在骗人,或者怀疑是 inode 用完了,或者干脆重启服务试试。其实答案很明确:df 统计的是文件系统里被分配的块,du 统计的是目录树上能看到的文件。一个有进程正打开着的文件,即使它的目录项被删掉了,它占用的磁盘块也不会释放——因为内核认为"还有人要用这个 inode"。
Linux 的 inode 里有一个 i_nlink 链接计数和一个 i_count 引用计数。删除文件实际上是 unlink 系统调用,它只做两件事:把目录项摘掉,把 i_nlink 减 1。只有当 i_nlink == 0 并且 i_count == 0(没有任何进程持有这个文件的文件描述符)时,内核才会真正回收数据块。你 rm 掉的那个 access.log,被 Nginx 的 master 进程和每个 worker 进程都 open 着,i_count 远大于 0,所以块一直占着。
第一步:用 lsof 精确定位"幽灵文件"
标准做法是 lsof | grep deleted,但在日志多的服务器上这条命令会跑很久,而且输出里有大量 noise。更精准的写法是限定文件系统:
lsof +L1 2>/dev/null | awk '$7 > 104857600 {print}'+L1 的意思是"link count 小于 1",正好对应已经 unlink 的文件。第 7 列是文件大小(字节),这里只打印大于 100MB 的,直接过滤掉小文件。输出会像这样:
nginx 1234 www-data 4w REG 253,0 10737418240 12345 /var/log/nginx/access.log (deleted)关键信息有四个:进程名、PID、文件描述符编号(这里是 4w 中的 4)、以及原始路径。有了 PID 和 FD,你就可以直接确认这是不是你要处理的文件。
如果服务器上没装 lsof,或者你不想装,另有一条纯 procfs 的路子。每个进程的 /proc/PID/fd/ 目录下是一个个符号链接,指向它打开的文件,已删除的文件链接目标会带上 (deleted) 后缀:
find /proc/*/fd -lname '*deleted*' -printf '%p -> %l\n' 2>/dev/null | head -20更省事的办法是用 du 直接对着 procfs 统计,它会顺着链接统计到真实文件大小:
du -sh /proc/*/fd 2>/dev/null | sort -rh | head -10哪几个进程的 fd 目录特别大,那它们就是占着已删除文件的元凶。这条命令的好处是不依赖额外工具,缺点是没有文件名,需要再用上面那条 find -lname 交叉确认。
第二步:三种回收方式,选对才不丢数据
找到之后,处理方式有三条,适用场景完全不同,选错会丢数据,务必看清楚。
方式一:清空而不删除(首选)
这是最安全、最常用的做法。不要去删文件,而是把文件内容截断为 0:
: > /var/log/nginx/access.log或者用 truncate -s 0 /var/log/nginx/access.log。: 是 shell 内置的空命令,> 重定向会在不关闭原有 fd 的前提下把文件长度置零。因为 inode 没变、fd 没变,Nginx 完全感知不到,接着往下写;磁盘块立刻释放;也不用 reload 或重启任何服务。这是唯一一个"既释放空间又不影响运行中进程"的写法。
注意 : > file 和 > file 的区别:单独一个 > file 在 bash 里也能工作(它会创建/截断文件),但语义不清晰,容易在别的 shell 里出问题。前面那个冒号是习惯写法,见到的都知道意图。
方式二:让进程重新打开日志文件
如果你确实需要删掉旧文件(比如想把老的 access.log 传到别处归档后再删),那删完必须让进程重新 open 一次。这里的坑在于:reload 不一定是"重新打开"。
Nginx 的 nginx -s reopen(或 kill -USR1 <master_pid>)才是重新打开日志文件的信号,它会让 worker 关掉旧 fd、按配置重新 open 一遍。而 nginx -s reload(SIGHUP)是重新加载配置、起新 worker 退老 worker,日志文件虽然通常也会被重新打开,但语义上是"改配置",用在纯日志场景是浪费,而且在配置有语法错误时会拒绝执行——你以为解决了,其实没有。正确的顺序是:
mv /var/log/nginx/access.log /var/log/nginx/access.log.old
kill -USR1 $(cat /run/nginx.pid)
# 处理完 old 文件后
gzip /var/log/nginx/access.log.old这里用的是 mv 而不是 rm。因为在同一个文件系统内 mv 只改目录项、不动 inode,原 fd 依然有效且能看到完整内容——这样即使你后面 USR1 操作失败,数据也还在。日志轮转工具 logrotate 就是这个原理,它从来不用 rm。
方式三:重启进程(最后手段)
systemctl restart nginx 当然能解决,但它是核弹。对 Nginx 意味着瞬间断连,对 MySQL 意味着缓冲池清空、有几百毫秒到几秒的不可用。除非是某个不认识的野进程占着几十 G 而你完全不在乎它,否则不要在线上用重启解决日志占盘问题。
顺带一个特殊情况:如果占着文件的是 systemd-journald,它持有的是 journal 文件,不要用 : > 去截断,正确做法是 journalctl --vacuum-size=500M 或 journalctl --vacuum-time=7d,让 journal 自己回收。直接截断 journal 文件会导致 journald 内部索引与文件不一致,后续查询会报"Corrupted file"。同理,MySQL 的 ibdata、binlog 也不要手工截断,那是玩火。
第三步:从根上治理,让这件事不再发生
手工救火一次两次可以,一年救五次就是系统设计有问题。真正的解法是把"日志占盘"变成一个永远不会失控的机制。
用 logrotate 做真正的轮转
很多人以为装完 Nginx 就有 logrotate 了,其实默认的那份 /etc/logrotate.d/nginx 只做轮转不做压缩、保留天数也不长,而且如果 Nginx 是编译安装到非标准前缀,那份配置里的路径根本不匹配。一份稳妥的配置:
/var/log/nginx/*.log {
daily
rotate 14
missingok
notifempty
compress
delaycompress
dateext
dateformat -%Y%m%d
sharedscripts
postrotate
[ -f /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
endscript
}几个参数值得说清楚。delaycompress 让最近一份保留未压缩状态,方便 tail 和 grep 正在写的那一轮;否则你在排查问题时还得先 gunzip。sharedscripts 让 postrotate 只执行一次而不是每个文件执行一次,对 Nginx 尤其重要——否则会连着发好几个 USR1。postrotate 里的 [ -f ... ] && 是防御性写法,避免 Nginx 没运行时 logrotate 报错退出。
dateext 用日期后缀代替默认的数字后缀,好处是文件名可读、方便按天归档,坏处是 14 天之后文件名会重复覆盖——如果一天内多次强制轮转,第二次的文件会被第一次覆盖。如果一天可能轮转多次,就别用 dateext。
用 tmpfiles.d 管 journal
# /etc/systemd/journald.conf
SystemMaxUse=2G
SystemKeepFree=5G
MaxRetentionSec=14day
SystemKeepFree 是我最推荐加的一条:它告诉 journald"文件系统上至少给我留 5G 空闲"。这条设置让 journald 变成一个不会把盘写爆的组件,即使其它日志配置全都失控,它也会主动退让。
加一个磁盘预警,别等到 100%
为什么总是"撑爆才发现"?因为没人盯着。一条最简单的 cron 就够:
#!/bin/sh
# /etc/cron.hourly/diskcheck
THRESH=85
df -hP | awk -v t=$THRESH 'NR>1 && $5+0 >= t {print $6" "$5}' | while read mnt pct; do
echo "disk $mnt at $pct" | mail -s "disk warn $(hostname)" admin@example.com
donedf -hP 里的 -P(POSIX 输出格式)很重要:不带它时,设备名过长会导致 df 换行,awk 的列位置全部错位,这是很多监控脚本"从来没报过警"的真正原因。$5+0 把 93% 转成数字 93 再比较,否则是字符串比较,100% 会小于 85%——这个 bug 在网上流传的脚本里极其常见。
更彻底一点是查 inode:df -i。小文件多的站点(比如缓存目录里有几十万个 4K 小文件)会先耗尽 inode 而不是空间,那时 df -h 显示还有几十 G 空闲,但就是写不进去。这个症状和"删了文件空间不释放"完全是两回事,排查时先看一眼 df -i 能省半小时。
把它写进流程,而不是记在脑子里
把上面的东西固化成三条团队(或者说,固化到自己未来的那一天的)规则:
- 永远用
: > file清空日志,不用rm。例外只有"确定没有进程持有"的归档文件。 - 任何创建了日志文件的守护进程,都必须配 logrotate。包括自己写的 cron 脚本、PM2 应用、容器内挂载到宿主的日志。判定方法很简单:
lsof | grep -c log能看到的都算。 - 任何"撑爆磁盘"的告警,都必须同时给出
df -h和du -sh /*的结果,并且顺手看一眼lsof +L1。这三条命令花十秒钟,比任何猜测都值钱。
最后说一句实话:这套东西不难,难的是在凌晨两点磁盘告警响起来的时候还能冷静地先跑 lsof +L1 而不是 rm。我自己的服务器上贴了一张便签,就写了三行:先 df,再 du,再 lsof +L1,然后 : >。三年下来没再因为这事重启过服务。