磁盘满了空间不释放:已删除文件与inode排查

一、磁盘里的文件还在,为什么空间没回来

这几乎是每个站长都会遇到的场景:服务器报警磁盘使用率超过 90%,你 du 了一遍,找到几个几十 GB 的日志文件,删掉之后 df 一看——使用率一点没变。

这时候大多数人会怀疑是不是 df 显示错了,或者去重启服务。实际上原因非常确定:Linux 里文件有两个名字,一个是目录项,一个是 inode;删除操作只是删掉目录项,inode 和数据块要等所有打开该文件的进程都关闭之后才释放。如果某个进程还持有着这个文件的句柄(最常见的是正在写入的日志被 rm 掉了),那块空间就一直在被这个进程占着,df 会诚实地告诉你"还在用",du 却已经看不到它了。

本文就把这条最常见的误判讲清楚:怎么在十分钟内定位是哪个进程占着已删除的文件,怎么安全地释放它,以及怎么从根上避免这个坑反复出现。这套排查在高负载的 web 服务器上几乎每月都要用一次。

二、先分清三个概念:容量、inode、已删除文件

"磁盘满"这个说法太笼统,实际上有三种完全不同的"满",处理方式互不相同。排查时第一步永远是把三个数一起看:

# 1. 容量使用率
df -h

# 2. inode 使用率(小文件多的站必看)
df -i

# 3. 目录占用,排除挂载点和已删除文件
du -xh --max-depth=1 / 2>/dev/null | sort -rh | head -15
  • 容量满:df -h 显示 100%,df -i 正常。最常见,通常是日志、备份、临时文件。
  • inode 满:df -h 还有空间,但 df -i 显示 100%,此时新建文件会报 No space left on device,尽管还有几十 GB 空闲。成因是海量小文件——session 文件、缓存碎片、上传的缩略图、邮件队列。
  • 已删除未释放:df -h 显示满,du 加起来却远小于这个数。这就是本文的主题。

du 的命令里那两个参数的用意值得单独说:-x 表示不跨越文件系统边界,否则 du / 会把挂载的 NFS、对象存储、tmpfs 都算进去,得到荒谬的大数;--max-depth=1 配合 sort -rh 是快速定位"哪个一级目录在吃空间"的高效组合。不要一上来就跑 du -sh /*,在有几百万文件的服务器上它可能要跑十几分钟。

三、定位持有已删除文件的进程:lsof 与 /proc 两条路

判断"是不是已删除文件占着空间",最快的办法是直接问内核:有哪些文件被标了 (deleted) 但仍有进程持有。

# 方法一:lsof(需要安装 lsof 包)
lsof +L1

# 更精确:只看已删除且 size 大于 100MB 的
lsof -nP +L1 2>/dev/null | awk '$7 > 104857600 {print $1, $2, $7/1024/1024"MB", $9}'

+L1 的含义是"link count 小于 1",也就是目录项已被删除。第 7 列是文件大小,第 9 列是路径(通常显示成 /var/log/nginx/access.log (deleted))。

如果服务器上没装 lsof(很多精简镜像里没有),可以走 /proc 这条路,不需要安装任何东西:

# 遍历所有进程的 fd,找出指向已删除文件的
for pid in /proc/[0-9]*; do
  for fd in $pid/fd/*; do
    target=$(readlink "$fd" 2>/dev/null)
    case "$target" in
      *"(deleted)"*) echo "${pid#/proc/} $target" ;;
    esac
  done
done 2>/dev/null | head -50

这两条命令在几百个进程的机器上都在一秒内出结果。拿到 pid 之后先别急着动手,先确认这是什么进程:ps -o pid,ppid,user,etime,cmd -p <pid>。很多情况下你会看到 nginx: worker process、php-fpm: pool www、mysqld 这类常驻进程——它们持有着日志文件,是因为日志被 rm 而不是被 truncate。

还有一个更隐蔽的来源:被删除但仍在被写入的大文件,可能是某个进程 mmap 进来的。这种情况 lsof +L1 也会显示,但即使进程关闭了 fd,只要还有 mmap 映射,空间依然不释放。判据是 lsof 输出里 TYPE 列是 REG 而 FD 列显示 mem 或 DEL。遇到这种,唯一办法是重启那个进程。

四、释放的三种方式,选错了会丢数据

找到了进程,接下来是释放。三种方式按风险从低到高排列:

方式一:truncate 文件(最安全,不中断服务)。如果你能通过 /proc/<pid>/fd/<n> 找到那个句柄,直接对句柄做截断:

# 找到目标进程与 fd 编号
ls -l /proc/12345/fd | grep deleted

# 截断(不是删除),空间立刻释放,进程继续正常写入
: > /proc/12345/fd/3

这是唯一不影响服务的方式,也是我推荐的默认做法。截断后进程的文件偏移量会保持在原位(文件变成带空洞的大文件),所以你可能看到 ls 显示的大小没变、但 df 的空间回来了。这是正常的——空洞不占磁盘块。若要彻底收拾,后续正常重启一次即可。

方式二:reload 进程。对 Nginx 这类支持平滑重载的进程,nginx -s reload 会启动新 worker(打开新的日志文件)、优雅关闭旧 worker(关闭旧句柄),空间随之释放。前提是日志路径已经重新创建了同名文件,否则新 worker 打开失败,日志就彻底丢了。

# reload 前必须做的一步:先创建同名空文件
touch /var/log/nginx/access.log /var/log/nginx/error.log
nginx -t && nginx -s reload

方式三:重启进程。最粗暴,会中断请求。只在前两种都不可行时使用。对 MySQL 这类进程,重启前要确认没有长事务,否则会导致回滚风暴。

有两个坑必须提醒。第一,不要用 rm 然后 touch 一个新文件就以为完事了——如果进程没有重新打开文件(很多程序只在启动时打开日志),它会继续往已经删除的 inode 里写,空间永远不释放,而新文件一直是空的,你还会以为日志坏了。第二,截断大文件时如果进程正在高速写入,先用 copytruncate 思路处理:先 copy 一份出来留证,再 truncate,否则当场丢失最近的日志。

五、日志切割为什么必须用 logrotate,不能写 crontab rm

这个坑之所以反复出现,是因为"每天凌晨 rm 一下日志"是很多老教程教的做法。它在两种情况下一定出问题:

  • Nginx/PHP-FPM 这类常驻进程持有句柄,rm 之后空间不释放(就是本文的主题)。
  • rm 之后如果没人 touch,新请求可能写进不存在的文件,或者进程写进被删除的 inode,日志永久丢失,出故障时没有证据。

正确的做法是 logrotate,它的机制是 rename + 通知进程重开:把 access.log 改名为 access.log.1,然后发信号让进程重新创建并打开 access.log。因为 inode 没被删除(只是改了名),进程和文件名始终对得上。

/var/log/nginx/*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    create 0640 www-data adm
    sharedscripts
    postrotate
        [ -f /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
    endscript
}

几个参数值得解释:create 0640 www-data adm 保证新文件属主正确,否则下次切割时 logrotate 会因为无法写入而失败(这个错误很隐蔽,只在 /var/lib/logrotate/status 里体现);missingok 让文件不存在时不报错;notifempty 避免空文件占用轮转名额。

PHP-FPM 是另一个高频踩坑点:它需要 USR1 信号而不是 Nginx 的 USR1,且 pid 文件路径因安装方式而异(/run/php/php8.2-fpm.pid 或 /run/php-fpm.pid)。如果不发信号,FPM 会继续往旧 inode 写,慢日志和错误日志会一直"不更新"。

怎么验证 logrotate 真的生效了:用 -d(dry run)预先看它要做什么,用 -f 强制跑一次再看结果。

logrotate -d /etc/logrotate.d/nginx    # 试运行,只打印不执行
logrotate -f /etc/logrotate.d/nginx    # 强制执行一次
ls -lh /var/log/nginx/                 # 应看到 access.log 与 access.log.1

六、inode 满:另一个方向的"磁盘满"

虽然本文主题是已删除文件,但既然排查逻辑相通,把 inode 满也一起说清楚,能省你下次的一轮搜索。

df -i 显示 IUse% 接近 100% 时,症状是"明明有空间却写不进任何文件",报错是 No space left on device。定位哪个目录里小文件最多:

# 统计每个一级目录的 inode 数
for d in /var/* /home/*; do
  echo "$(find "$d" -xdev 2>/dev/null | wc -l) $d"
done | sort -rn | head -10

最常见的三大来源:

  • PHP session 文件:/var/lib/php/sessions 里几百万个小文件,通常是 gc_maxlifetime 没生效或目录权限不对导致 GC 清理失败。检查 gc_probability 与 gc_divisor 的乘积,默认 1/100 意味着每 100 个请求清一次,访问量低时几个月不清。
  • 缓存碎片:Typecho、WordPress 的缓存目录如果按 URL 哈希生成文件,很容易上百万个。
  • 邮件队列:/var/spool/postfix 下的 deferred 队列,发信被大量退信时会堆积成海量小文件。

注意 find | wc -l 这条路本身很慢,几百万文件要跑几分钟。更快的办法是 df -i 对比挂载点,先定位到具体分区,再对该分区跑 find,不要一上来全盘扫。

七、把它做成常态化监控,而不是救火

磁盘问题真正的成本不是解决,是"发现得晚"。午夜磁盘写满,MySQL 无法写入,站点 502,第二天早上才知道。所以最后一步是把判据变成监控项,让它在 80% 就报警。

#!/bin/bash
# disk-guard.sh:容量 + inode + 已删除文件 三项检查
WARN=80
while read -r fs size iuse mnt; do
    s=${size%\%}; i=${iuse%\%}
    [ "$s" -ge "$WARN" ] && echo "WARN space ${s}% $mnt"
    [ "$i" -ge "$WARN" ] && echo "WARN inode ${i}% $mnt"
done < <(df -Ph | tail -n +2 | awk '{print $1,$5,$6,$7}')

# 已删除文件超过 1GB 就报
lsof -nP +L1 2>/dev/null \
  | awk '$7 > 1073741824 {printf "WARN deleted %s pid=%s %dMB %s\n",$1,$2,$7/1048576,$9}'

这个脚本挂到 cron 里每小时跑一次,输出接到邮件或者你的告警渠道。关键在于"已删除文件"这项指标几乎没人监控,而它恰恰是最容易在没有明显征兆的情况下把磁盘吃满的那一类。

另外两个工程习惯也值得养成:日志目录独立分区(/var/log 单独挂载)、备份目录与站点目录分离。这样即使日志暴涨,也不会把数据库写挂——一个分区满只影响一个服务,比全盘 100% 好处理得多。

八、小结

df 显示满而 du 加不起来,几乎可以断定是有进程持有已删除的文件。用 lsof +L1 或遍历 /proc/*/fd 定位 PID,优先用 : > /proc/PID/fd/N 截断释放,其次 reload,最后才重启。根上的解法是停止用 rm 切日志,改用 logrotate 的 rename + 信号机制,并把容量、inode、已删除文件三项都纳入监控。这套排查一次学会,以后每个月都用得上,而且每次都能在十分钟内解决。

Last modification:November 2nd, 2026 at 09:28 pm

Leave a Comment