日志删掉了,磁盘空间没有回来
磁盘告警,发现一个几十 GB 的日志文件,
rm删掉。ls里文件没了,du统计目录只剩几 MB,df显示的已用空间一个字节都没少,而且还在继续涨。
rm 删除的是文件的名字。文件的内容什么时候释放,取决于另一个条件:还有没有进程开着它。写日志的进程一直持有这个文件的句柄,名字没了,它照样往里写。
这不是故障,是文件系统的正常语义。本文在一个挂了小容量 tmpfs 的容器里把它跑一遍,再看由此引出的几个操作问题:怎么找到这些文件、不重启进程怎么把空间放出来、日志轮转为什么要「通知进程重新打开」,以及容器环境里要注意什么。实验见文末。
一、名字、文件与打开它的进程
rm 删掉的是名字;文件本体和它占的数据块,要等到「没有名字指向它」并且「没有进程开着它」两个条件都满足才释放。du 沿着目录里的名字累加,名字没了就数不到;df 看的是文件系统一共用了多少块,所以两个数对不上。文件系统里,一个文件由两部分组成:目录里的名字,和名字指向的文件本体(inode 与它的数据块)。一个文件本体可以有多个名字(硬链接),也可以被多个进程打开。内核为它维护两个计数:有几个名字指向它,有几个打开的句柄指向它。两个计数都归零,数据块才被释放。
实验里一个进程以追加方式打开 /data/app.log 并写入 100 MB,然后在它继续运行的情况下操作这个文件。每一步记录两个数:文件系统已用空间(df 看到的)和目录下所有文件占用之和(du 看到的)。
| 步骤 | 文件系统已用 | 目录下文件合计 | 目录内容 |
|---|---|---|---|
| 进程写了 100 MB | 100 MB | 100 MB | app.log |
rm app.log | 100 MB | 0 MB | 空 |
| 进程继续写 50 MB | 150 MB | 0 MB | 空 |
| 进程退出 | 0 MB | 0 MB | 空 |
删除之后,du 沿着目录里的名字逐个累加,名字没了,它数不到;df 问的是文件系统一共分配了多少块,这 100 MB 还在。df 与 du 对不上,差的就是「已删除但仍被打开」的文件。
更麻烦的是第三行:进程并不知道文件被删了,继续写,空间继续被占用。这时再去目录里找「是哪个文件在涨」,什么也找不到。
二、找到它们,并且不重启进程
每个进程打开的文件在 /proc/<pid>/fd/ 下有一个入口,是指向文件的符号链接。文件被删除后,链接的目标后面多了 (deleted)。实验里写入进程的 fd 指向:
/data/app.log (deleted)查找整台机器(或容器内)所有这样的文件:
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 MB | 1 MB |
| 普通写方式 | 101 MB | 1 MB |
以追加方式打开时,每次写入都写在文件当前的末尾;文件被清空,末尾回到 0。普通写方式下,进程自己记着「写到了 100 MB 的位置」,清空之后它仍然从那个位置继续写,前面留下 100 MB 的空洞。文件系统支持空洞时,磁盘占用确实只有 1 MB,但文件长度显示 101 MB,开头是 100 MB 的零字节。用 cat、grep、日志采集程序读它,会先读到一大段零。
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:
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,容器却因为内存被杀。
排查清单
df与du的差值很大:先查已删除但仍被打开的文件。- 找到持有者:
/proc/<pid>/fd里目标带(deleted)的链接,或lsof +L1。 - 释放:让进程重新打开文件或重启;不行就截断
/proc/<pid>/fd/<n>。 - 预防:清空而不是删除;写入方用追加方式;轮转方式与应用的能力匹配;给日志设总量上限。
配套实验
- codesphere-labs/kubernetes/deleted-open-files:在挂了 200 MB tmpfs 的容器里,删除、清空、改名一个仍被进程打开的文件之后,文件系统已用空间与目录下文件占用的变化;通过
/proc/<pid>/fd读回与清空;追加与普通写方式的差别;硬链接(验证记录)
参考资料
- Linux 手册页:unlink(2)(名字被删除后,文件保留到最后一个打开它的描述符关闭)
- Linux 手册页:proc_pid_fd(5)
- Linux 手册页:open(2)(
O_APPEND的语义) - logrotate 手册页(
copytruncate与postrotate的说明) - Kubernetes 文档:本地临时存储(上限、驱逐与两种统计方式)
- Kubernetes 文档:日志架构(容器日志的轮转)
- JDK 25 API:FileOutputStream(
append参数)