🤖
AI审核中

日志删了,磁盘空间为什么没回来

文章摘要

df 显示磁盘占用很高,du 却找不到大文件,可能是进程仍持有已删除的文件。用一个实测的 8 MiB Linux 小实验说明名字、inode 和文件描述符的关系,再给出 lsof 与 /proc 的核对方法,以及日志重新打开、轮转和空间恢复的验证步骤。

日志已经删了,目录里也找不到大文件,磁盘告警却一点没降。再执行一次 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 移除的是名字,打开的文件不会立即失效

可以把这段生命周期拆成三件事:

  1. 目录项把文件名关联到 inode。
  2. 进程打开文件后,持有一个可用于读写的文件描述符。
  3. 删除最后一个文件名,不会替进程关闭已经打开的描述符。

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,并不能证明轮转链路完整。上线后至少要实测一次:

  1. 轮转前后,应用写入的是哪个文件对象?
  2. 新日志有没有继续收到内容?
  3. 旧文件是否还被持有,持有多久?
  4. 保留与压缩任务是否真正执行,失败有没有被发现?

如果应用自身已经轮转日志,外部脚本又独立移动同一批文件,还应确认两套机制不会争抢同一个文件的生命周期。

七、没找到大的 deleted 文件,就换一条调查路径

df 和 du 的差距不能只用这一种原因解释。前面的查询没有足够证据时,应继续缩小问题:

现象 下一步核对
创建文件失败,但容量还有余量 df -i,确认是否耗尽 inode
某个用户写入失败,其他用户正常 用户或项目配额、错误码及实际写入身份
目录统计遗漏一大块数据 统计范围、权限错误、挂载覆盖和容器挂载命名空间
文件已删除,快照仍保留历史 对应存储系统的快照与保留策略
逻辑大小很大,实际占用很小 稀疏文件、压缩和共享块等文件系统特性

这些是后续调查方向,不是看到一个数字差就能成立的结论。文件系统元数据和保留空间也会影响统计,不能把所有差值都标成泄漏。

八、留下能复核的证据,比留下一个清理命令更有用

一次完整排查,至少应记录目标文件系统、占用对象的设备号与 inode、持有者 PID 和启动时间、相关 FD,以及采取操作前后的状态。

其中有三条最值得保留:

  • 删除路径之前,先确认谁正在使用它。 路径消失不代表使用结束。
  • 恢复之后,核对旧引用是否消失、新日志是否正常。 不只看 df 有没有下降。
  • 让轮转和写入方协同。 反复手工删除,只会让同一个问题换个时间回来。

以前写过 Too many open files 排障实战,关注的是描述符数量及释放边界。这篇的入口不同:哪怕进程只多持有一个 FD,它指向的文件足够大,也能占住大量磁盘空间。

所以,查文件问题时,数量和体积都要看;更要看一个对象从创建、使用到释放的整个过程。

0 条评论

如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧

评论区 0

头像
 0 条评论