Appearance
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/backup120 + 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。
数据块什么时候才真的还给文件系统?要同时满足两个条件:
- 链接计数归零——没有任何目录项指向它了;
- 打开句柄归零——没有任何进程还持有它的文件描述符。
只要有一个进程还开着这个文件,两个条件就不齐,内核就不会释放它的数据块。文件没有名字、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,cmdetime 那一列很关键——它会告诉你这个进程已经跑了多久。如果是 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 对不上就再也不是玄学了。