适用场景

这类问题常见于日志文件、临时文件或应用输出文件被手动 rm 删除后,df -h 仍然显示磁盘空间没有释放。典型环境包括:

  • 业务服务持续写入日志,日志文件被误删或被不规范清理脚本删除。
  • Nginx、Java、Python、Go 服务长期运行,进程仍持有旧文件句柄。
  • Docker 容器内日志或挂载目录文件被删除,但宿主机磁盘空间继续上涨。
  • 线上分区突然 100%,但 du -sh 统计目录大小明显小于 df -h

这篇文章重点讲 Linux 本机排查方法,容器场景可以先进入宿主机排查,再结合容器进程定位。

现象描述

线上告警提示根分区或数据分区空间不足:

df -h

示例输出:

Filesystem      Size  Used Avail Use% Mounted on
/dev/vda1        80G   79G  1.0G  99% /

但进一步查看目录占用时,发现文件大小对不上:

du -xhd1 / | sort -h

示例输出:

1.2G    /var
2.0G    /usr
3.5G    /data
8.9G    /

df 认为磁盘已经用了 79G,而 du 只统计到 8.9G。这个差异通常说明有文件已经从目录树删除,但仍被进程打开。

为什么 rm 后空间没有释放

Linux 文件系统中,文件名只是 inode 的目录引用。进程打开文件后,会持有文件描述符。执行 rm app.log 只是删除目录项,如果仍有进程打开这个文件,内核不能立即释放对应的数据块。

只有同时满足两个条件,空间才会真正释放:

  • 文件目录引用数为 0,也就是已经被删除或没有硬链接。
  • 没有任何进程继续持有该文件描述符。

所以线上经常出现这样的情况:日志文件被删掉了,但服务还在继续往“已删除文件”写入,磁盘空间不仅没有释放,还会继续增长。

快速判断是不是已删除文件占用

先看 dfdu 是否明显不一致:

df -h /
du -xsh /

关键点:

  • df 查看文件系统层面的空间使用,包括已删除但仍被打开的文件。
  • du 从目录树递归统计,只能看到仍有路径的文件。
  • 如果 df 远大于 du,优先怀疑 deleted open file、挂载遮挡或文件系统保留块。

然后用 lsof 查找已删除但仍被进程打开的文件:

lsof +L1

如果机器上文件很多,可以只关注某个挂载点:

lsof +L1 /var
lsof +L1 /data

也可以直接过滤 deleted:

lsof | grep deleted

不过生产环境更推荐 lsof +L1,因为它直接筛选链接数小于 1 的打开文件。

lsof 输出怎么看

示例输出:

COMMAND   PID USER   FD   TYPE DEVICE  SIZE/OFF NLINK NODE NAME
java     2381 app    12w   REG  253,1 21474836480     0 9876 /data/logs/app.log (deleted)
nginx    9120 root    5w   REG  253,1  5368709120     0 9910 /var/log/nginx/access.log (deleted)

字段说明:

  • COMMAND:持有文件的进程名。
  • PID:进程号,后续处理要用到。
  • USER:进程所属用户。
  • FD:文件描述符,12w 表示第 12 个 fd,以写方式打开。
  • SIZE/OFF:文件大小或偏移量,通常用于判断占用空间。
  • NLINK:链接数,0 表示目录引用已经不存在。
  • NAME:原始文件路径,末尾 (deleted) 表示文件已被删除。

如果 SIZE/OFF 很大,并且 NLINK 为 0,基本可以确认空间被这个打开文件占用。

没有 lsof 时的替代方法

有些精简系统没有安装 lsof,可以从 /proc 查:

find /proc/*/fd -lname '*deleted*' 2>/dev/null

输出示例:

/proc/2381/fd/12
/proc/9120/fd/5

查看文件描述符指向:

ls -lh /proc/2381/fd/12

查看进程命令:

ps -fp 2381

查看该 fd 对应的文件大小:

stat -Lc '%n size=%s bytes' /proc/2381/fd/12

如果要批量列出较大的 deleted 文件,可以使用:

find /proc/*/fd -lname '*deleted*' 2>/dev/null |
while read fd; do
  size=$(stat -Lc '%s' "$fd" 2>/dev/null || echo 0)
  pid=$(echo "$fd" | awk -F/ '{print $3}')
  cmd=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)
  printf '%12s  %-10s  %s\n' "$size" "$pid" "$cmd"
done | sort -nr | head

这个脚本的关键逻辑:

  • find /proc/*/fd -lname '*deleted*' 找出所有指向已删除文件的 fd。
  • stat -Lc '%s' 读取文件大小。
  • 从路径中提取 PID,再读取 /proc/$pid/cmdline 辅助确认进程。
  • 按大小倒序,优先处理占用最大的进程。

修复方案一:重启或 reload 对应服务

最稳妥的处理方式是让持有文件的进程关闭旧 fd。对于多数服务,优先选择服务级别的 reload 或 restart。

例如 Nginx 日志文件被删除:

nginx -t
systemctl reload nginx

reload 后 Nginx master 会重新打开日志文件,旧 fd 通常会被释放。

如果是 systemd 管理的业务服务:

systemctl status app.service
systemctl restart app.service

重启后验证:

df -h /data
lsof +L1 /data

如果空间释放,dfAvail 会增加,lsof +L1 中对应记录会消失。

修复方案二:清空进程 fd

如果服务暂时不能重启,可以对进程持有的 fd 做截断。假设 lsof 显示 PID 为 2381,FD 为 12:

: > /proc/2381/fd/12

或者:

truncate -s 0 /proc/2381/fd/12

注意事项:

  • 只适合日志类、临时输出类文件。
  • 不要对数据库数据文件、队列文件、状态文件随意截断。
  • 执行前必须确认 PID、FD 和文件来源,避免清空仍有业务意义的数据。
  • 截断后仍要检查应用是否会继续写入这个 deleted fd,必要时安排窗口重启。

验证:

df -h /
lsof -p 2381 | grep deleted

修复方案三:容器场景定位宿主机进程

如果问题发生在 Docker 宿主机,先看容器日志是否异常增长:

docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Status}}'
docker inspect --format='{{.LogPath}}' <container_id>
ls -lh $(docker inspect --format='{{.LogPath}}' <container_id>)

如果是容器内进程持有 deleted 文件,可以从宿主机用 lsof +L1 看到对应进程。再通过进程 cgroup 判断容器:

cat /proc/<pid>/cgroup

也可以查看进程树:

ps -ef --forest | less

处理方式通常是:

docker restart <container_name>

如果不能重启,同样可以谨慎截断 /proc/<pid>/fd/<fd>,但要先确认文件只是日志或临时输出。

实践排查流程

线上收到 /data 分区空间不足告警后,可以按这个顺序执行:

df -h /data
du -xhd1 /data | sort -h
lsof +L1 /data

发现 Java 进程持有 20G deleted 日志:

COMMAND PID  USER FD  TYPE DEVICE SIZE/OFF NLINK NAME
java    2381 app  12w REG  253,1      20G     0 /data/logs/app.log (deleted)

确认进程:

ps -fp 2381
readlink /proc/2381/fd/12

如果业务允许重启:

systemctl restart app.service

如果暂时不能重启,且确认是日志:

truncate -s 0 /proc/2381/fd/12

最后验证:

df -h /data
lsof +L1 /data

预防措施

使用 logrotate,不要直接 rm 正在写入的日志

日志清理建议交给 logrotate

/data/logs/*.log {
    daily
    rotate 7
    missingok
    notifempty
    compress
    copytruncate
}

copytruncate 会先复制再截断原文件,适合无法优雅重新打开日志文件的程序。更推荐的方式是应用支持收到信号后重新打开日志,然后在 postrotate 中执行 reload。

Nginx 示例:

/var/log/nginx/*.log {
    daily
    rotate 14
    missingok
    notifempty
    compress
    sharedscripts
    postrotate
        [ -s /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
    endscript
}

USR1 会让 Nginx 重新打开日志文件,这是比直接删除日志更安全的方式。

给磁盘告警增加 df 和 inode 监控

只看目录大小不够,应同时监控:

df -h
df -ih
  • df -h 看数据块是否耗尽。
  • df -ih 看 inode 是否耗尽。

如果 inode 满了,即使磁盘还有空间,也无法创建新文件。

定期巡检 deleted open file

可以把下面命令加入巡检脚本:

lsof +L1 2>/dev/null | awk 'NR==1 || $7 > 1073741824'

含义是只打印大于 1G 的已删除打开文件,避免小文件噪声太多。注意不同版本 lsof 的字段可能略有差异,落地脚本前要在目标系统验证一次。

总结

rm 删除文件后磁盘空间没有释放,最常见原因是进程仍然持有文件描述符。排查时先比较 dfdu,再用 lsof +L1/proc/*/fd 找到 deleted 文件对应的进程和 fd。

处理上优先 reload 或 restart 服务,让进程重新打开文件;不能重启时,可以谨慎截断 /proc/<pid>/fd/<fd>。预防上不要直接删除正在写入的日志,使用 logrotate、服务 reload 和磁盘巡检机制,才能避免同类问题反复出现。