Skip to content

df 说磁盘满了,du 却只数出三成:那些「删掉了却没释放」的文件 ​

一份对不上的 ​

告警说 /data 分区使用率到了 100%,应用写日志开始报错。登上机器验证:

bash
$ df -h /data
Filesystem      Size  Used Avail Use% Mounted on
/dev/vdb1       500G  500G     0 100% /data

$ du -sh /data
148G    /data

差得离谱。再用 du 逐层看一遍,把 /data 下所有一级目录加起来:

bash
$ du -sh /data/* 2>/dev/null | sort -rh | head
120G    /data/app
 25G    /data/logs
  3G    /data/backup

120 + 25 + 3 也就 148G,剩下 350G 像凭空蒸发了一样。但 df 不会说谎——它直接从文件系统的超级块读统计数字,那是内核记账的结果。

所以问题不在"有多少空间被用了",而在"这 350G 被谁占着,为什么 du 看不见它"。

du 和 df 到底在看什么 ​

这两个命令的统计口径完全不同,理解这一点,这类问题就再也不会让人困惑:

数据来源统计方式
df文件系统超级块(statfs 系统调用)内核记账,所有已分配的块
du目录树遍历从你指定的路径一路 readdir 下去,累加每个文件的块数

关键差别:df 数的是「已分配的块」,du 数的是「从目录树能走到的文件的块」。

一份文件的数据块还在磁盘上被占着,但目录里已经没有它的名字了——这种块,du 永远走不到,df 一分不少地记着。上面那 350G 就是这么来的。

文件要满足什么条件才算真正消失 ​

这是整件事的核心,值得单独说清楚。

在 Linux 的文件系统里,一个文件由两部分组成:目录项(文件名 → inode 号码的映射)和 inode + 数据块(真正的元数据和内容)。

rm file 干的事情,准确说是 unlink——它删掉的是目录项,也就是那个"名字",同时把 inode 里的链接计数减 1。

数据块什么时候才真的还给文件系统?要同时满足两个条件:

  1. 链接计数归零——没有任何目录项指向它了;
  2. 打开句柄归零——没有任何进程还持有它的文件描述符。

只要有一个进程还开着这个文件,两个条件就不齐,内核就不会释放它的数据块。文件没有名字、ls 看不到、du 数不到,但空间实实在在占着。

用一句话概括这套机制:

rm 删除的是名字,不是数据。数据在最后一个持有者松手时才释放。

题外话,这个设计与 Unix "一切皆文件" 的哲学是一致的:只要还有一个 fd 在,进程就能继续正常读写它。你甚至可以在 rm 之后往 fd 里写东西,写入的内容会照常落盘到这个"无名文件"上,直到进程关闭它。对应用程序来说是透明的——它自己都不知道文件已经没了。

找出是谁还在占着 ​

定位这条链路的工具是 lsof。最直接的一招:

bash
$ lsof +L1
COMMAND    PID USER   FD   TYPE DEVICE  SIZE/OFF NLINK    NODE NAME
java     18422  app   47w   REG  253,17 41234567890     0 1234567 /data/logs/app.log (deleted)
java     18422  app   52w   REG  253,17  98765432100     0 1234789 /data/logs/gc.log (deleted)

+L1 的含义是"链接数小于 1"的文件,也就是已经被 unlink、但还有 fd 打开的。输出里 NLINK 列为 0、路径带 (deleted) 标记,一眼可辨。把 SIZE/OFF 加起来,就是那消失的 350G。

如果机器上没有 lsof(很多精简镜像确实没装),有两条不需要装任何东西的替代路径。

一是遍历 /proc:

bash
$ ls -l /proc/*/fd 2>/dev/null | grep '(deleted)'

二是用 find 按大小筛,先只看哪些进程嫌疑最大:

bash
$ for pid in /proc/[0-9]*; do
    n=$(ls -l $pid/fd 2>/dev/null | grep -c '(deleted)')
    [ "$n" -gt 0 ] && echo "$(basename $pid) $n $(cat $pid/comm 2>/dev/null)"
  done | sort -k2 -rn | head

拿到 PID 之后,确认一下它是什么、能不能动:

bash
$ ps -p 18422 -o pid,ppid,user,etime,cmd

etime 那一列很关键——它会告诉你这个进程已经跑了多久。如果是 88 天,那基本可以确定这 350G 就是从那天起累积到现在的。

怎么把空间要回来 ​

两种手段,代价差得很远,选哪个取决于进程的性质。

方案一:重启(或优雅重载)进程。

最干净,也最安全。进程退出时所有 fd 随之内核回收,条件满足,数据块立刻归还。重启后它会创建新的日志文件,一切正常。

缺点是业务有中断或需要走发布流程。但只要进程本身是可以重启的,这就是唯一正确的做法。

方案二:截断文件,不重启。

bash
# 先确认大小,动手前后各跑一次 df 对比
$ ls -l /proc/18422/fd/47
lrwx------ 1 app app 64 Oct  2 03:20 /proc/18422/fd/47 -> /data/logs/app.log (deleted)

$ : > /proc/18422/fd/47

: 是 shell 内建的空命令,配合 > 重定向,实际效果是 open(..., O_WRONLY|O_TRUNC) —— 把文件长度截为 0,inode 和数据块结构保留,进程的 fd 依然有效,可以继续写。

好处是不中断进程,立刻见效。代价和风险必须讲清楚:

  • 只对日志这类纯追加写的文件安全。 日志是 O_APPEND 模式,每次写入都从当前末尾开始,截断后新数据从偏移 0 重新排,逻辑自洽。
  • 对数据库文件、正在被 mmap 的文件、有内部偏移量维护的二进制文件,绝对不要这么干。 截断会让进程里的偏移量与实际文件长度错位,轻则数据错乱,重则文件损坏。MySQL 的 ibdata、Redis 的 dump.rdb、任何 mmap 打开的文件都不在此列。
  • 有些程序自己缓存了写入位置(比如在内存里维护 write offset),截断后它会继续按旧偏移写,文件中间出现一大段空洞。这种程序只能重启。

所以实操顺序应该是:先看这个 fd 指向什么类型的文件 → 是日志就考虑截断,不是就老老实实重启。

让这件事不再发生 ​

根因基本都是同一件事:有人用 rm 清理了正在被写的日志文件。

这句话拆开看,有两种典型的踩法。

踩法一:人工清理。"磁盘满了,我先 rm 几个大日志腾点地方"

这个操作当时看着有效——df 确实会掉一点(如果没有进程持有的话)。但只要日志进程还开着这些文件,下一个 10 分钟它就又满了,而且这次你 du 也找不到证据,只能一脸茫然。

正确的姿势是让日志轮转工具去处理,而不是 rm:

bash
# /etc/logrotate.d/app
/data/logs/*.log {
    daily
    rotate 14
    size 500M
    compress
    delaycompress
    missingok
    notifempty
    copytruncate
}

这里有一个容易忽略的选项:copytruncate。

logrotate 默认的做法是 mv 改名 + 让程序重新打开新文件(通常靠 postrotate 里给进程发信号)。而 copytruncate 是"先复制一份、再把原文件截断",好处是程序完全不需要感知、不需要 reopen,也不用改任何配置——它继续往同一个 fd 写,只是起点回来了。

代价是复制和截断之间有一个极短的时间窗,那个窗口内写入的日志会丢。对绝大多数业务日志可以接受;审计类、计费类日志要评估。

踩法二:程序没实现日志重开。

有些自研服务,日志 fd 在启动时 open 一次就再也不管了,收到 SIGHUP 也不 reopen。这种情况下 logrotate 的默认策略(mv + 通知重开)会让日志继续写进那个被改名成 app.log.1 的文件里,而新的 app.log 永远是空的——现象是"日志不写了,磁盘还在涨",比第一种更隐蔽。

修法是让程序支持 SIGHUP 重开日志,或者退一步,用 copytruncate 绕过。

容器场景要多留一个心眼。 k8s 里容器 stdout 的日志由 kubelet 以 json 格式写到宿主机上(通常在 /var/log/pods/... 或 /var/log/containers/...)。如果有人在宿主机上 rm 掉这些文件,而容器里的进程还开着它们,空间会一直挂着不还——du 同样看不见,lsof 能看到持有者是 containerd-shim 或容器进程。这种场景的清理必须走 kubelet 的日志轮转配置(kubelet.conf 里的 containerLogMaxSize / containerLogMaxFiles),不能靠 rm。

顺带一提:另一种 df / du 对不上 ​

df 大、du 小,除了上面这种"已删除未释放",还有一个常见原因值得一并记住:挂载点覆盖。

如果你把一个文件系统挂载到了一个本来有数据的目录上,那么原目录里的内容不会消失,只是被"盖住"了——du 遍历到那个路径时看到的是新挂载的内容,而被盖住的那部分数据块依然占着底层分区的空间。

现象是:某个分区 df 快满了,但无论怎么 du 都找不出大头。检测方法是把挂载点临时 umount 掉(或者用一个临时目录 bind mount 到它的父目录再看):

bash
$ mkdir /tmp/root_check
$ mount --bind / /tmp/root_check
$ du -sh /tmp/root_check/var   # 直接看底层真实目录

判断的依据是区分"元数据耗尽"和"数据块未释放":先跑 df -h 看块,再跑 df -i 看 inode。两个都正常却写不进文件,才需要考虑配额、只读重挂载这类更冷门的原因。

小结 ​

这类问题的排查路线其实很短,串起来是这样:

bash
# 1. 确认是块不足还是 inode 不足
df -h /data && df -i /data

# 2. df 大 du 小 → 怀疑已删除未释放
du -sh /data

# 3. 揪出持有者
lsof +L1                      # 或 ls -l /proc/*/fd | grep deleted

# 4. 看是什么文件,再决定怎么处理
ls -l /proc/<PID>/fd/<FD>
ps -p <PID> -o pid,etime,cmd

# 5. 能重启就重启;纯日志可考虑截断
: > /proc/<PID>/fd/<FD>

真正需要记住的只有一句话:rm 删的是名字,数据块要等最后一个持有者松手才释放。 想清楚这一点,df 和 du 对不上就再也不是玄学了。

用 VitePress 构建