日志已经删了,目录里也找不到大文件,磁盘告警却一点没降。再执行一次 rm 没用,du 算出来的总量也明显小于 df。
这类现象容易让人怀疑统计工具出了问题。更值得先查的是:文件的名字没了,是否还有进程拿着它?
本文围绕普通 Linux 文件系统上的“已删除但仍被打开的文件”展开。后面用一个实测过的 8 MiB 小实验复现,不需要把磁盘写满,也不需要停止业务进程。示例中的业务路径、PID 和 FD 都是假设值,执行排查命令时要替换成实际对象。
一、先确认 df 和 du 查的是同一块盘
df 报告文件系统的空间状态;du 遍历指定目录下能访问到的文件,估算它们占用的空间。这两个工具的观察范围不同,不能拿任意两个数字直接相减。(GNU df、GNU du)
假设告警涉及 /var/log,先查它实际所在的挂载点:
df -hT /var/log
findmnt -T /var/log
假如结果显示 /var/log 属于根文件系统,检查整个根文件系统时可以用:
sudo du -x -h --max-depth=1 /
-x 限制在同一个文件系统内遍历。若 /var/log 本来就是独立挂载点,应从它自己的挂载根目录统计,不能把根分区和日志分区混在一起。
扫描大目录会产生读取开销,先看一级,再沿着占用大的目录向下查。不要在繁忙机器上反复全盘扫描;也不要直接加 2>/dev/null,把权限错误一起藏掉之后,再断言“这里没有大文件”。
另一个容易误判的地方是文件大小口径。ls -l 的逻辑长度与实际分配块数可能不同,稀疏文件尤其明显;du --apparent-size 改为统计逻辑大小,也不会因此变成 df。比较时先统一范围和口径,再调查剩下的差距。
二、rm 移除的是名字,打开的文件不会立即失效
可以把这段生命周期拆成三件事:
- 目录项把文件名关联到 inode。
- 进程打开文件后,持有一个可用于读写的文件描述符。
- 删除最后一个文件名,不会替进程关闭已经打开的描述符。
Linux 的 unlink() 文档明确区分了“移除名字”和“释放文件”:最后一个链接移除后,如果仍有进程打开它,文件会继续存在,直到相关的最后一个打开引用关闭。(unlink(2))
所以,删除一个正在写的日志后,可能出现这样的状态:
目录里的 app.log:已经不存在
进程里的 FD 7:仍然指向原来的文件
原文件的磁盘块:仍然被占用
进程继续写日志:原文件可能继续增长
这时,按目录遍历的 du 找不到原来的名字,文件系统却仍然把那些块算作已用空间。
还有一个细节:重新创建一个同名 app.log,不会把旧 FD 自动接到新文件上。新的目录项和进程手里的旧文件可以同时存在,名字看起来一样,指向的对象却已经不同。
三、用 lsof 找持有人,再用 /proc 核对对象
1. 先找链接数为零的打开文件
系统安装了 lsof 时,可以先执行只读查询:
sudo lsof -nP +L1
+L1 选择链接数小于 1 的打开文件,也就是已经解除链接的文件。-nP 避免地址和端口名称解析。确认目标挂载点后,还可以缩小范围:
# 假设目标文件系统挂载在 /var
sudo lsof -nP +aL1 /var
下面是解释字段用的示意,不是某次线上故障的真实日志:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
java 12345 app 7w REG 8,1 8388608 0 456789 /var/log/app.log (deleted)
先看 TYPE 是否为普通文件 REG、NLINK 是否为零,再看进程和原路径。FD 中的 7w 表示编号 7 的写描述符,核对 /proc 时只用数字 7。具体列名和显示方式会随平台、版本及选项变化。(lsof manual)
不要把 SIZE/OFF 一列直接全部求和。 同一个文件可能被多个 FD、多个进程持有,而且逻辑长度也不等于分配空间。评估普通文件时,应按设备号和 inode 识别同一对象,避免重复计算。
2. 顺着 FD 看它实际指向什么
确认示例 PID 和 FD 后:
PID=12345
FD=7
sudo readlink "/proc/$PID/fd/$FD"
sudo stat -L -c 'device=%d inode=%i links=%h size=%s blocks=%b block_unit=%B' "/proc/$PID/fd/$FD"
Linux 的 /proc/<pid>/fd 按描述符编号列出符号链接,stat -L 会跟随链接读取对应文件的状态。(proc_pid_fd(5))
在 GNU stat 的这组输出里,size 是逻辑字节数,blocks × block_unit 是所报告分配块的字节数。这里的 block_unit 不应误当成文件系统的最佳 I/O 大小。(GNU stat)
采样过程中,进程可能关闭或重新使用这个 FD。若两次结果不一致,要重新查询;权限不足、进程退出或者 FD 消失,都不能直接解释成“已经没有占用”。
另外,看到 (deleted) 并不自动等于异常。程序也可能有意删除临时文件的名字,再通过已有句柄使用它。要判断的是它的大小、持有时间和业务用途。
四、用 8 MiB 复现:名字没了,文件还能读
下面的实验需要 Linux、Python 3 和 GNU du。它只创建自己的临时目录和一个 8 MiB 文件,读写完成后自动清理,不操作任何业务文件。
保存为 deleted_file_lab.py:
import os
import subprocess
import tempfile
def disk_usage(path):
output = subprocess.check_output(["du", "-B1", "-s", path])
return int(output.split()[0])
with tempfile.TemporaryDirectory(prefix="deleted-file-lab-", dir="/tmp") as directory:
path = os.path.join(directory, "demo.log")
with open(path, "w+b", buffering=0) as stream:
for _ in range(8):
stream.write(b"x" * 1024 * 1024)
os.fsync(stream.fileno())
print("du_before_unlink=", disk_usage(directory))
fd_path = "/proc/{}/fd/{}".format(os.getpid(), stream.fileno())
os.unlink(path)
info = os.fstat(stream.fileno())
print("pathname_exists=", os.path.exists(path))
print("fd_target=", os.readlink(fd_path))
print("link_count=", info.st_nlink)
print("logical_bytes=", info.st_size)
print("allocated_bytes=", info.st_blocks * 512)
print("du_after_unlink=", disk_usage(directory))
stream.seek(0)
print("read_after_unlink=", stream.read(1))
assert info.st_nlink == 0
assert info.st_size == 8 * 1024 * 1024
assert info.st_blocks > 0
assert not os.path.exists(path)
print("fd_exists_after_close=", os.path.lexists(fd_path))
assert not os.path.lexists(fd_path)
print("temporary_directory_removed=", not os.path.exists(directory))
运行:
python3 deleted_file_lab.py
在本次 Linux 验证中,关键输出如下;临时目录的随机后缀已省略:
du_before_unlink= 8392704
pathname_exists= False
fd_target= /tmp/deleted-file-lab-.../demo.log (deleted)
link_count= 0
logical_bytes= 8388608
allocated_bytes= 8388608
du_after_unlink= 4096
read_after_unlink= b'x'
fd_exists_after_close= False
temporary_directory_removed= True
8 MiB 等于 8,388,608 字节。删除前,目录统计还包含 4 KiB 的目录自身占用;删除后,du 只剩这 4 KiB,但 fstat() 仍能看到文件的 8 MiB 分配空间,读操作也能取得原来的字节。
离开 with open(...) 后,程序关闭了最后一个文件描述符,对应 /proc 条目消失。实验没有读取或写入真实日志,临时目录也已清理。
这里刻意不拿繁忙服务器两次 df 的差值当作精确实验结果。其他进程也在分配和释放空间,文件系统的统计更新还可能有延迟;小实验要证明的是生命周期,而不是制造一个全局空间数字恰好相等的巧合。
五、找到占用之后,释放路径要由持有人决定
对于一个仍在工作的日志写入进程,修复通常应该让它关闭旧文件并打开正确的新文件。进程支持什么操作,要查它自己的文档。
以 Nginx 为例,官方文档规定 USR1 用于重新打开日志;轮转时先重命名旧日志,再通知 master,master 和 worker 会重新打开日志并关闭旧文件。(Nginx 日志轮转)
确认使用的 Nginx 实例、配置前缀和 PID 文件后,可按该实例的运维方式执行重新打开日志,例如:
# 操作对象必须是已经确认的 Nginx 实例
sudo nginx -s reopen
随后重新查 lsof、检查新日志是否持续写入,再观察目标文件系统的空间。命令返回成功只是操作结果,旧引用是否消失才是释放证据。
Java 日志框架是否支持重新打开文件,取决于实际 Appender 和轮转配置;不能把 Nginx 的信号约定直接套给 Java。若只能重启服务,应先安排流量摘除和恢复验证,不要看到某个 PID 就直接结束进程。
把 /proc/<pid>/fd/<fd> 截断为零也不是通用修复。截断会丢弃内容,却不会自动重置原描述符的文件偏移;某些后续写入可能重新形成较大的逻辑长度。(truncate(2))
尤其不要把数据库文件、WAL、binlog 或用途不明的文件当成普通日志清空。空间问题应通过对应组件的保留策略和维护操作处理。
六、为什么日志轮转了,还会留下旧文件
轮转的关键不只是把文件改名,还包括通知写入方切换。
一种常见错误是:外部脚本改名或删除旧日志,应用却一直通过原 FD 写入。目录里的新日志很小甚至为空,旧文件则在看不见的位置继续增长。
logrotate 的 copytruncate 是另一种方式:先复制,再在原文件上截断,适用于无法通知程序重新打开日志的情况。但复制与截断之间存在时间窗口,官方手册明确指出可能丢失期间写入的数据。它也会产生复制所需的额外空间。(logrotate(8))
因此,配置里有 rotate、compress,并不能证明轮转链路完整。上线后至少要实测一次:
- 轮转前后,应用写入的是哪个文件对象?
- 新日志有没有继续收到内容?
- 旧文件是否还被持有,持有多久?
- 保留与压缩任务是否真正执行,失败有没有被发现?
如果应用自身已经轮转日志,外部脚本又独立移动同一批文件,还应确认两套机制不会争抢同一个文件的生命周期。
七、没找到大的 deleted 文件,就换一条调查路径
df 和 du 的差距不能只用这一种原因解释。前面的查询没有足够证据时,应继续缩小问题:
| 现象 | 下一步核对 |
|---|---|
| 创建文件失败,但容量还有余量 | df -i,确认是否耗尽 inode |
| 某个用户写入失败,其他用户正常 | 用户或项目配额、错误码及实际写入身份 |
| 目录统计遗漏一大块数据 | 统计范围、权限错误、挂载覆盖和容器挂载命名空间 |
| 文件已删除,快照仍保留历史 | 对应存储系统的快照与保留策略 |
| 逻辑大小很大,实际占用很小 | 稀疏文件、压缩和共享块等文件系统特性 |
这些是后续调查方向,不是看到一个数字差就能成立的结论。文件系统元数据和保留空间也会影响统计,不能把所有差值都标成泄漏。
八、留下能复核的证据,比留下一个清理命令更有用
一次完整排查,至少应记录目标文件系统、占用对象的设备号与 inode、持有者 PID 和启动时间、相关 FD,以及采取操作前后的状态。
其中有三条最值得保留:
- 删除路径之前,先确认谁正在使用它。 路径消失不代表使用结束。
- 恢复之后,核对旧引用是否消失、新日志是否正常。 不只看
df有没有下降。 - 让轮转和写入方协同。 反复手工删除,只会让同一个问题换个时间回来。
以前写过 Too many open files 排障实战,关注的是描述符数量及释放边界。这篇的入口不同:哪怕进程只多持有一个 FD,它指向的文件足够大,也能占住大量磁盘空间。
所以,查文件问题时,数量和体积都要看;更要看一个对象从创建、使用到释放的整个过程。

评论区 0