Skip to content

日志删掉了,磁盘空间没有回来 ​

磁盘告警,发现一个几十 GB 的日志文件,rm 删掉。ls 里文件没了,du 统计目录只剩几 MB,df 显示的已用空间一个字节都没少,而且还在继续涨。

rm 删除的是文件的名字。文件的内容什么时候释放,取决于另一个条件:还有没有进程开着它。写日志的进程一直持有这个文件的句柄,名字没了,它照样往里写。

这不是故障,是文件系统的正常语义。本文在一个挂了小容量 tmpfs 的容器里把它跑一遍,再看由此引出的几个操作问题:怎么找到这些文件、不重启进程怎么把空间放出来、日志轮转为什么要「通知进程重新打开」,以及容器环境里要注意什么。实验见文末。

一、名字、文件与打开它的进程 ​

目录项 app.log名字 → 文件编号文件本体(inode)名字数 1,打开数 1数据块100 MB写日志的进程fd 3 一直开着du 从这里数df 从这里数rm:名字数变成 0仍然占用,还能继续增长删除之后:ls 看不到文件,du 是 0,df 仍是 100 MB;进程再写 50 MB,df 变成 150 MB/proc/<pid>/fd/<n> 仍指向这个文件:可以读回内容,也可以对它做截断把空间放掉进程退出或关闭这个 fd,打开数变成 0,数据块才释放改名同理:写入的进程跟着文件本体走,mv 之后它继续写进改了名的那个文件要让进程写到新文件,得让它重新打开(重启、信号),或者用清空代替删除
图 1 · 目录里的名字只是指向文件本体的一个链接。rm 删掉的是名字;文件本体和它占的数据块,要等到「没有名字指向它」并且「没有进程开着它」两个条件都满足才释放。du 沿着目录里的名字累加,名字没了就数不到;df 看的是文件系统一共用了多少块,所以两个数对不上。

文件系统里,一个文件由两部分组成:目录里的名字,和名字指向的文件本体(inode 与它的数据块)。一个文件本体可以有多个名字(硬链接),也可以被多个进程打开。内核为它维护两个计数:有几个名字指向它,有几个打开的句柄指向它。两个计数都归零,数据块才被释放。

实验里一个进程以追加方式打开 /data/app.log 并写入 100 MB,然后在它继续运行的情况下操作这个文件。每一步记录两个数:文件系统已用空间(df 看到的)和目录下所有文件占用之和(du 看到的)。

步骤文件系统已用目录下文件合计目录内容
进程写了 100 MB100 MB100 MBapp.log
rm app.log100 MB0 MB空
进程继续写 50 MB150 MB0 MB空
进程退出0 MB0 MB空

删除之后,du 沿着目录里的名字逐个累加,名字没了,它数不到;df 问的是文件系统一共分配了多少块,这 100 MB 还在。df 与 du 对不上,差的就是「已删除但仍被打开」的文件。

更麻烦的是第三行:进程并不知道文件被删了,继续写,空间继续被占用。这时再去目录里找「是哪个文件在涨」,什么也找不到。

二、找到它们,并且不重启进程 ​

每个进程打开的文件在 /proc/<pid>/fd/ 下有一个入口,是指向文件的符号链接。文件被删除后,链接的目标后面多了 (deleted)。实验里写入进程的 fd 指向:

text
/data/app.log (deleted)

查找整台机器(或容器内)所有这样的文件:

bash
find /proc/[0-9]*/fd -lname '*(deleted)' -printf '%p -> %l\n' 2>/dev/null

装了 lsof 的环境里,lsof +L1 列出名字数为 0 的打开文件及其大小,更直接。

/proc/<pid>/fd/<n> 不只是个标记,通过它仍然可以操作这个文件。实验里做了两件事:

  • 读回内容。 打开这个入口读取,能读到文件原来的数据。误删了一个还在被进程使用的文件时,在进程退出之前可以用 cp /proc/<pid>/fd/<n> 恢复的文件 把它救回来。
  • 清空。 对这个入口做截断(: > /proc/<pid>/fd/<n>)之后,文件系统已用空间从 150 MB 降到 0 MB,进程没有受影响,继续运行。

所以磁盘告警时的处理顺序是:先找到持有已删除文件的进程;能重启就重启(或让它重新打开日志);不能重启就截断它的 fd 入口。最不应该做的是在不知道谁开着文件的情况下继续 rm。

事先避免的办法更简单:要释放一个正在被写的文件的空间,清空它,不要删除它。 : > app.log 或 truncate -s 0 app.log 让文件长度归零,名字和句柄都还在。

三、清空之后的文件长度 ​

清空有一个前提条件。实验里两个进程分别写 100 MB,文件被清空后各自再写 1 MB:

写入方打开文件的方式清空后再写 1 MB,文件长度实际占用
追加方式(O_APPEND)1 MB1 MB
普通写方式101 MB1 MB

以追加方式打开时,每次写入都写在文件当前的末尾;文件被清空,末尾回到 0。普通写方式下,进程自己记着「写到了 100 MB 的位置」,清空之后它仍然从那个位置继续写,前面留下 100 MB 的空洞。文件系统支持空洞时,磁盘占用确实只有 1 MB,但文件长度显示 101 MB,开头是 100 MB 的零字节。用 cat、grep、日志采集程序读它,会先读到一大段零。

追加方式(O_APPEND):日志框架、>>1 MB写在当前末尾 · 长度 1 MB · 占用 1 MB普通写方式:new FileOutputStream(path)、>空洞:100 MB 零字节(不占磁盘)1 MB进程记着的偏移:100 MB长度 101 MB · 占用 1 MBcat、grep、日志采集程序会先读到一大段零
图 2 · 实测:两个进程各写 100 MB,文件被清空后各自再写 1 MB。追加方式(O_APPEND)每次写在当前末尾,文件长度 1 MB;普通写方式自己记着写到了 100 MB 的位置,清空后仍从那里写,文件长度 101 MB、实际占用 1 MB,开头是 100 MB 的零字节。「清空正在被写的文件」只对追加方式的写入方安全

日志框架都以追加方式打开文件,一般不会遇到。自己写的 new FileOutputStream(path)(没有第二个参数 true)、shell 里的 cmd > file(而不是 >>)是普通写方式。「清空正在被写的文件」只对追加方式的写入方是安全的。

四、改名也一样:进程跟着文件走 ​

日志轮转的常见做法是把 app.log 改名成 app.log.1。实验里进程写 10 MB,文件被改名,进程再写 10 MB:

text
app.log.1 的长度 = 20 MB
app.log 存在     = false

改名只换了名字,进程手里的句柄指向的还是同一个文件本体,后写的 10 MB 进了 app.log.1。新的 app.log 不会自己出现,要等进程重新按名字打开一次。

这就是日志轮转有两种模式的原因:

  • 改名,然后通知进程重新打开。 轮转工具改名后给进程发信号或调用它的接口,进程关闭旧句柄、按原名打开新文件。不丢日志,但要求进程支持「重新打开」。
  • 复制后清空(copytruncate)。 轮转工具把当前内容复制出去,再把原文件清空,进程无感知。代价是复制与清空之间写入的内容会丢,并且要求进程以追加方式写(原因见上一节)。

Java 服务通常由日志框架自己按大小或时间滚动:框架知道什么时候切换文件,不存在句柄与名字脱节的问题。两套轮转机制不要同时对同一个文件生效。

硬链接是同一件事的另一面:一个 20 MB 的文件有两个名字,删掉其中一个,空间不释放,剩下的名字仍然指向它。

五、容器里要注意的 ​

容器的可写层与 emptyDir 都算临时存储。 应用把日志写到容器内的文件里,占的是节点的磁盘。Kubernetes 可以为容器设置 ephemeral-storage 的请求与上限,超过上限的 Pod 会被驱逐。已删除但仍被打开的文件继续占着这部分空间;而按目录扫描的统计方式数不到它们,于是可能出现「节点磁盘满了,每个 Pod 看起来都没超」的情况。kubelet 具体怎么统计(定期扫描目录还是使用文件系统的项目配额)取决于配置,见文末的 Kubernetes 文档。

标准输出的日志由运行时轮转。 写到标准输出与标准错误的日志由容器运行时落盘,kubelet 按 containerLogMaxSize 与 containerLogMaxFiles 轮转,应用不需要也不应该自己去删这些文件。

不要在容器里装一套 logrotate 去轮转应用自己写的文件,除非确认应用支持重新打开,或者使用 copytruncate 并接受它的丢失窗口。更省事的做法是日志写标准输出,或者由日志框架自己滚动并限制总大小与保留份数。

tmpfs 上的文件占的是内存。 本文的实验为了得到干净的数字用了 tmpfs。在 /dev/shm 或内存型 emptyDir 上,已删除但仍被打开的文件占的是容器的内存额度,后果是 OOM 而不是磁盘满,见 进程只占 10 MB,容器却因为内存被杀。

排查清单

  1. df 与 du 的差值很大:先查已删除但仍被打开的文件。
  2. 找到持有者:/proc/<pid>/fd 里目标带 (deleted) 的链接,或 lsof +L1。
  3. 释放:让进程重新打开文件或重启;不行就截断 /proc/<pid>/fd/<n>。
  4. 预防:清空而不是删除;写入方用追加方式;轮转方式与应用的能力匹配;给日志设总量上限。

配套实验

  • codesphere-labs/kubernetes/deleted-open-files:在挂了 200 MB tmpfs 的容器里,删除、清空、改名一个仍被进程打开的文件之后,文件系统已用空间与目录下文件占用的变化;通过 /proc/<pid>/fd 读回与清空;追加与普通写方式的差别;硬链接(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权