文件都删了,磁盘为什么还是满的:顺着 inode 找到没关掉的文件
有次我在服务器上看到根分区快满了,顺着目录查下去,发现一个日志文件已经十几个 GB。为了赶紧腾空间,我直接执行 rm,文件瞬间消失,但 df -h 里的可用空间几乎没变。
更离谱的是,du 也找不到那十几个 GB。一个工具说磁盘仍然很满,另一个工具说目录没占那么多,刚删掉的文件又确实不在了。那部分空间像是被藏了起来。
问题不在 rm 失效,而在我对“删除文件”的理解太简单。Linux 删除的是文件名与 inode 的关联。只要还有进程握着这个文件,内核就不能回收它占用的数据块。文件名可以没了,文件本身仍然活着。
df 和 du 没有互相矛盾,它们看的不是同一件事
遇到磁盘问题,最常用的是这两个命令:
df -h
du -sh /var/*df 询问文件系统:已经分配了多少数据块,还剩多少数据块。它从整个文件系统的角度统计,不关心这些块能不能通过某个文件名找到。
du 的做法不同。它从指定目录开始,沿着目录树一个文件一个文件地走,再把能找到的文件占用量加起来。一个文件如果没有名字,也不在任何目录树里,du 就走不到它。
于是会出现这种差异:
df 看到的已用空间
├── du 能遍历到的普通文件
├── 已删除但仍被进程打开的文件
├── 文件系统保留块和元数据
└── 其他 du 当前没有统计到的内容所以 df 显示 95%,du 加起来却只有 70%,不代表某个命令算错了。它们是在回答两个不同的问题。
排查时我会先确认差异发生在哪个文件系统:
df -hT
findmnt然后让 du 不跨越其他挂载点:
sudo du -xhd1 / | sort -h-x 很重要。假如 /data、/home 或容器目录挂载了别的文件系统,不限制的话,du / 可能把其他磁盘也算进去,数字会更难对上。
先确认是容量满,还是 inode 用完
磁盘还能剩很多 GB,创建文件却报 No space left on device,也可能是 inode 耗尽。大量小文件会先用完 inode。用 df -i 查看 inode 使用率,不要只看 df -h。
文件名只是入口,真正的数据由 inode 记录
我以前觉得文件就是“路径加内容”,例如 /var/log/app.log。文件系统里的关系更像这样:
目录中的名字 app.log
│
▼
inode 128734
├── 类型与权限
├── 所有者
├── 文件大小
├── 时间信息
├── 硬链接计数
└── 数据块位置
│
▼
磁盘上的实际内容目录本身也是一种文件。它保存“名字到 inode 编号”的映射。路径 /var/log/app.log 是从根目录开始,逐级查目录项,最后找到 app.log 对应的 inode。
可以用 ls -i 查看 inode 编号:
printf 'hello\n' > demo.txt
ls -li demo.txt输出可能是:
131155 -rw-r--r-- 1 user user 6 Jan 7 10:20 demo.txt最前面的 131155 就是 inode 编号。再用 stat 能看到更多信息:
stat demo.txtinode 通常不直接保存文件名。文件名属于目录项,inode 负责描述文件和定位数据。这一点看似只是术语区别,却决定了 rm 到底做了什么。
rm 做的是 unlink,不是立刻擦掉所有数据
在 C 标准库和系统调用里,删除普通文件对应的核心操作叫 unlink。这个名字比 remove 更准确,它做的是解除一个目录项与 inode 的链接。
unlink("demo.txt");命令行里的:
rm demo.txt最终也是要求系统完成类似的解除链接操作。执行成功后,目录里不再有 demo.txt 这个名字,所以 ls 看不到它,按原路径 open() 也会得到“文件不存在”。
但内核不会无条件立刻回收数据。一个文件真正可以消失,至少要满足两个条件:
- 指向该 inode 的硬链接数量降到 0
- 没有进程通过文件描述符或内存映射继续引用它
只满足第一个条件时,文件进入一种很反直觉的状态:没有名字,却仍被使用。它不在目录树里,du 找不到;数据块仍被文件系统占着,df 当然不会减少。
这也是 Unix 文件系统很有用的一点。程序正在读一个文件时,别人可以把它从目录中删掉,程序不会突然拿到一半内容就报文件不存在。已经打开的文件描述符仍然有效,等最后一个使用者退出,内核再收尾。
文件描述符让进程绕过文件名继续访问数据
程序调用 open() 后,内核返回一个小整数,这就是文件描述符。标准输入通常是 0,标准输出是 1,标准错误是 2,之后打开的文件可能是 3、4、5。
file = open("demo.txt", "rb")
print(file.fileno())文件描述符不是 inode 编号,也不是磁盘地址。它是当前进程文件描述符表中的一个下标,大致关系如下:
进程
└── 文件描述符 3
│
▼
内核打开文件表项
├── 当前读写偏移
├── 打开标志
└── 指向 inode
│
▼
文件数据块路径只在 open() 的查找阶段有用。文件成功打开后,程序后续执行 read(3, ...)、write(3, ...),靠的是描述符 3,不会每次重新查询原路径。
所以,即使另一个终端把 demo.txt 删除,已经打开它的进程仍然可以继续读写。只要描述符没关,inode 的引用就还在。
描述符还可能通过 dup() 被复制,或者在 fork() 后被子进程继承。你以为主进程关闭了文件,某个子进程可能仍握着另一份引用。排查长时间不释放的日志时,不能只看最初打开它的那行代码。
用一个小实验制造“文件删了,空间还在”
下面的脚本每秒向文件写入 256 KiB。速度故意设得不快,方便观察,也不至于一转头就写满磁盘。
# hold_deleted_file.py
import os
import time
path = "./disk-lab/growing.log"
os.makedirs("./disk-lab", exist_ok=True)
with open(path, "ab", buffering=0) as log:
print("pid:", os.getpid())
print("fd:", log.fileno())
print("path:", os.path.abspath(path))
block = b"x" * (256 * 1024)
while True:
log.write(block)
time.sleep(1)在一个终端运行:
python3 hold_deleted_file.py记下它打印的 PID 和 fd。另开终端观察文件增长:
watch -n 1 'ls -lh ./disk-lab/growing.log; du -sh ./disk-lab; df -h .'等文件增长几 MB 后删除它:
rm ./disk-lab/growing.log
ls -la ./disk-lab文件名已经消失,但写入脚本没有报错,仍在每秒写数据。此时 du -sh ./disk-lab 会变小,df -h . 的已用空间却不会因为这次删除立即恢复,并且还可能继续缓慢增加。
实验结束后按 Ctrl + C 停止 Python 进程。最后一个文件描述符关闭,内核才会回收那个无名文件的数据块。再次执行 df -h .,空间应该恢复。文件系统的统计可能有缓存和取整,观察几 MB 时变化不一定明显,可以让实验多运行一会儿,但不要放着不管。
不要在重要分区上长时间运行写入实验
先用 df -h . 确认当前目录所在分区有足够空间。脚本只用于观察原理,看到现象后立刻停止。不要把路径改到生产服务器的根分区,更不要用超大块和死循环追求“效果明显”。
lsof +L1 能直接找到没有名字的打开文件
生产环境里不可能靠猜哪个进程删过日志。最实用的命令是:
sudo lsof +L1lsof 的意思是 list open files。+L1 用于筛选硬链接数小于 1 的已打开文件,也就是通常所说的“deleted but still open”。输出可能类似:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
python3 4217 user 3w REG 253,0 10485760 0 8123 /home/user/disk-lab/growing.log (deleted)这行信息已经给出了排查所需的大部分答案:
COMMAND和PID告诉你哪个进程仍在使用文件FD的3w表示文件描述符 3,以写方式打开SIZE/OFF显示文件当前大小NLINK是 0,说明已经没有硬链接NAME保留了原路径,并标记(deleted)
如果输出很多,可以按文件系统、进程或大小继续筛选。不要直接把 lsof 的每一行都当作异常,有些程序会有意创建临时文件后立刻删除名字,只通过描述符使用,进程退出时自动清理。真正需要关注的是体积很大、持续增长,或者与磁盘告警时间对应的文件。
/proc 能让我们看到进程手里握着什么
Linux 的 /proc 是一个由内核提供的虚拟文件系统。每个进程都有 /proc/进程号/ 目录,其中 fd/ 保存文件描述符链接。
继续使用实验脚本打印的 PID:
ls -l /proc/4217/fd可能看到:
3 -> /home/user/disk-lab/growing.log (deleted)原来的路径已经打不开,但 /proc/4217/fd/3 仍然指向那个打开的文件。甚至可以读取它:
sudo head -c 64 /proc/4217/fd/3这说明文件名消失后,数据并没有进入某个神秘回收站。内核知道 inode 仍被 PID 4217 的 fd 3 引用,并通过 /proc 把这层关系展示出来。
如果误删的是重要日志,而且进程还活着,有时可以从这里复制一份:
sudo cp /proc/4217/fd/3 ./recovered.log不过要注意两件事。第一,源文件可能仍在写,复制出来的只是某个时间段的内容,不保证是稳定快照。第二,如果磁盘已经满了,在同一分区复制只会再占一份空间,可能让情况更糟。恢复前应先选择有空间的其他文件系统。
释放空间的正确动作,取决于进程能不能中断
找到 PID 和 fd 后,最干净的处理通常是让进程关闭并重新打开日志。
很多守护进程支持收到 SIGHUP 后重新加载配置或重开日志文件:
sudo kill -HUP 4217但这不是所有程序的统一约定。执行前要查该程序文档或 systemd 服务配置。对不支持重开日志的程序,正常重启服务更可靠:
sudo systemctl restart 服务名重启会关闭旧进程的全部描述符,链接数为 0 的文件就能被回收。相比直接杀 PID,通过 systemd 重启还能保留正确的环境变量、用户身份和启动参数。
如果业务暂时不能重启,确实有人会直接把打开文件截断:
sudo truncate -s 0 /proc/4217/fd/3这个操作能迅速释放文件数据块,但有风险。程序的当前写入偏移、是否使用 O_APPEND、日志库缓冲方式都会影响后续行为。处理不当可能产生稀疏文件、丢失仍需保留的日志,或者让应用继续以错误方式写入。它适合明确知道后果的应急处理,不应该成为日常清理命令。
更不推荐的是重启整台服务器。重启当然会关闭所有描述符,空间也会回来,但它同时抹掉了现场。下次问题再出现,仍然不知道是哪个服务、哪套日志轮转出了错。
日志轮转为什么通常选择改名,而不是直接删除
正确配置的日志轮转会考虑“程序已经打开文件”这个事实。常见做法是:
- 把
app.log重命名为app.log.1 - 创建新的
app.log - 通知应用关闭旧描述符并打开新文件
- 压缩或删除更早的日志
在同一个文件系统内,重命名通常只是修改目录项。应用原来的描述符仍指向旧 inode,所以它可以把手头一条日志写完。收到信号后,应用重新按路径打开 app.log,才会切到新 inode。
轮转前:
应用 fd 5 -> inode A <- 名字 app.log
执行 rename 和 create 后:
应用 fd 5 -> inode A <- 名字 app.log.1
名字 app.log -> inode B
应用重开日志后:
应用 fd 6 -> inode B <- 名字 app.log如果轮转脚本直接 rm app.log,却没有让应用重开日志,应用会继续写那个已经没有名字的 inode。表面上旧日志删掉了,磁盘占用却一直增长,这正是文章开头的事故。
logrotate 还有一个 copytruncate 选项:先复制当前日志,再把原文件截断为 0。它不要求应用重开文件,适合无法响应信号的老程序。
代价是复制与截断之间有时间窗口。应用仍在写时,少量日志可能重复或丢失;复制大文件本身也会带来磁盘和 I/O 压力。能让应用正确重开日志时,改名加信号通常更稳。
硬链接证明了“一个文件可以有多个名字”
理解已删除文件后,硬链接也顺手讲通了。执行:
printf 'hello\n' > a.txt
ln a.txt b.txt
ls -li a.txt b.txt两个文件名会显示相同的 inode 编号,而且链接计数是 2:
131155 -rw-r--r-- 2 user user 6 Jan 7 11:00 a.txt
131155 -rw-r--r-- 2 user user 6 Jan 7 11:00 b.txta.txt 和 b.txt 不是内容碰巧相同的两份文件,它们是同一个 inode 的两个目录项。修改任意一个名字看到的内容,另一个也会变化。
删除其中一个:
rm a.txt
cat b.txt数据仍然存在,因为链接计数只是从 2 变成 1。只有 b.txt 也被删除,并且没有打开描述符时,数据块才可以回收。
这也解释了为什么 inode 不保存唯一文件名。一个 inode 本来就可能对应多个硬链接,文件系统无法挑出哪个才算“真正的名字”。
符号链接则不同。符号链接保存的是另一个路径字符串,有自己的 inode。目标被删后,符号链接会变成断链;硬链接仍然可以访问同一份数据。
正在运行的程序被替换后,旧版本也可能继续占空间
“已删除但仍打开”不只发生在日志上。部署程序时,我们经常把新的可执行文件覆盖到原路径,然后以为机器上只剩新版本。实际上,旧进程可能还在执行旧 inode 中的代码。
Linux 启动程序时,会把可执行文件和动态库的相关部分映射到进程地址空间。部署工具如果通过“写新文件,再重命名覆盖旧路径”的方式更新,目录里的名字会指向新 inode,但已经运行的进程仍引用旧文件映射。
部署前:
/opt/app/server -> inode A
运行中的进程 -> inode A
部署替换后:
/opt/app/server -> inode B (新版本)
运行中的进程 -> inode A (旧版本仍在跑)这解释了一个常见现象:磁盘上的文件已经是新版本,正在服务请求的进程却表现得像旧代码。只有进程重启,才会重新按路径加载 inode B。旧 inode 没有其他引用后,空间才会释放。
可以用这些命令观察进程加载的文件:
sudo lsof -p 进程号 | grep deleted
readlink /proc/进程号/exe
grep '(deleted)' /proc/进程号/maps/proc/进程号/exe 可能显示主程序已被删除,maps 里也可能看到旧动态库。系统升级 libc、OpenSSL 等库后,磁盘上虽然已有修复版本,长期不重启的进程仍可能使用旧映射。服务器更新后的服务重启,不只是“让配置生效”,还关系到进程实际运行的是哪份二进制。
当然,不能看到 (deleted) 就无脑重启所有服务。有些运行时、包管理器和临时文件机制会产生正常的已删除映射。应该结合更新时间、文件大小、进程职责和发布记录判断。
重命名为什么常被用来做安全更新
既然路径和 inode 分开,很多程序更新文件时不会直接在原文件上写,而是先在同一目录创建临时文件,写完并同步后再用 rename() 替换目标路径。
配置旧文件 config.json -> inode A
临时文件 .config.tmp -> inode B
rename 后:
config.json -> inode B
已打开旧文件的读者 -> inode A同一文件系统内的重命名可以是原子的。其他进程看到的要么是旧名字对应的完整文件,要么是新名字对应的完整文件,不容易撞见“只写了一半”的内容。数据库生成快照、编辑器保存文件、包管理器升级程序,都可能借这个特性减少损坏窗口。
代价是老读者不会自动跳到新 inode。配置更新后,应用是否重新打开文件,要看它有没有监听文件变化、收到重载信号,或者定期读取路径。知道这一点后,很多“文件明明改了,程序为什么还是旧配置”的问题也能解释。
rm 不等于安全擦除,恢复也不能只靠运气
rm 解除目录项后,文件系统把数据块标记为可以重新分配。它通常不会为了删除一个大文件,立即把每个数据块都写一遍零。因此,从原理上说,在数据块被新内容覆盖前,底层介质上可能还留着旧数据。
但这不代表普通用户执行一个“恢复命令”就一定能找回文件。日志型文件系统会修改元数据,SSD 有缓存、磨损均衡和 TRIM,虚拟磁盘下面还可能有存储池与快照。你看到的逻辑块地址不一定对应固定的物理位置。误删重要数据后继续写入,只会增加覆盖机会。
真正重要的数据应该靠备份、快照和版本控制恢复,而不是把文件系统恢复工具当作备份。涉及敏感数据销毁时,也不能把 rm 当成安全擦除方案,需要结合磁盘加密、密钥销毁、存储设备类型和组织规范处理。
文章里的 /proc/PID/fd/N 恢复方法只适用于文件仍被进程打开的特殊窗口。它好用,是因为内核引用还在,不是因为 rm 后任何文件都能从 /proc 找回来。进程一旦关闭最后一个引用,这扇门也会关上。
删除权限为什么主要看目录,而不是文件本身
还有一个我以前觉得很怪的现象:文件本身是只读的,有时仍然能删除;文件属于我,有时却删不掉。
因为删除文件名,本质上是修改父目录中的目录项。能不能删,主要取决于你对父目录是否有写和执行权限,而不是文件内容是否可写。
ls -ld 父目录
ls -l 父目录/文件当然,/tmp 这类共享目录通常设置 sticky bit,只允许文件所有者、目录所有者或 root 删除对应条目,避免任何有目录写权限的人互删文件。
drwxrwxrwt ... /tmp
^
sticky bit把“删文件”理解成“修改目录映射”,这些权限规则就不再奇怪了。
df 和 du 差很多,不一定只有已删除文件这一种原因
lsof +L1 没有发现大文件时,还要继续看其他可能。
第一种是挂载点遮住了原目录内容。假设 /data 原本有 20 GB 文件,后来又把一块新磁盘挂载到 /data。挂载后,原文件仍在根分区占空间,却被新文件系统盖住,普通 du /data 看到的是新磁盘内容。需要在安全维护窗口卸载对应文件系统,才能检查被遮住的目录。
第二种是 ext4 等文件系统为 root 保留了一部分块。普通用户看到可用空间为 0 时,root 可能还能写少量关键日志和完成修复。可以用下面的命令查看文件系统参数,但不要在不了解用途时随意把保留比例改成 0:
sudo tune2fs -l /dev/设备名 | grep -E 'Block count|Reserved block count'第三种是稀疏文件。文件的逻辑大小和实际占用可能不同:
ls -lh sparse.img
du -h sparse.imgls 显示文件逻辑上有多大,du 默认更接近它实际占了多少数据块。虚拟机磁盘、数据库文件和某些日志可能出现这种差异。
第四种是容器层和日志。应用容器里看不到大文件,不代表宿主机的 Docker 数据目录没有增长:
docker system df
sudo du -xhd1 /var/lib/docker | sort -h这里先看清楚再处理。docker system prune 会删除未使用资源,影响范围比名字听起来大,不应该当成“磁盘清理万能键”。
我现在处理 Linux 磁盘爆满的顺序
把这次问题整理后,我留下了一套排查顺序:
- 用
df -hT确认哪个文件系统容量紧张,用df -i排除 inode 耗尽。 - 用
sudo du -xhd1 挂载点 | sort -h从顶层逐级定位可见大目录。 - 如果
df与du差距明显,立即执行sudo lsof +L1查已删除但仍打开的文件。 - 根据 PID 查服务来源、启动方式和日志配置,不直接强杀。
- 优先让应用重开日志或正常重启;截断
/proc/PID/fd/N只作为理解后果的应急手段。 - 没找到时检查挂载点遮挡、文件系统保留块、稀疏文件、容器数据和系统日志。
- 故障恢复后修日志轮转、文件关闭逻辑和磁盘告警,不把清理命令当作长期方案。
为了减少半夜才发现磁盘满,我还会给容量和 inode 分别设置告警,并观察增长速度。单次超过 80% 不一定马上危险,连续每小时增长 5% 就很值得处理。对日志目录设置保留天数和总大小上限,对上传文件、构建缓存、容器日志分别制定清理规则,比每天定时执行一条粗暴的 rm 更可控。
告警信息最好带上文件系统、挂载点、当前使用率和最近增长量。只发一句“磁盘空间不足”,值班的人还得重新登录机器找是哪块盘。故障处理的前几分钟很宝贵,能在告警里提前给出的上下文,不应该留给人肉查询。
我后来又做了一次文章里的实验。看着 rm 后目录变空,而 /proc/进程号/fd/3 仍然能读出数据,那种“文件凭空消失”的感觉就没有了。
文件路径只是入口。程序打开入口后,真正握住的是内核对象;rm 拿走名字,close 才放下最后的引用。下次 df 和 du 对不上时,缺失的空间通常没有藏起来,它只是还在等某个进程松手。