inode 与硬链接:删了文件,空间为什么没降

难度 ★★☆☆☆ · 前置:stat 那十几个字段分别在说什么 | 一句话结论:文件名只是目录项,inode 才是文件本体。只要还有进程开着它,unlink 之后空间就不还——df 和 du 报的不是一回事,根源在这里。

有台机器的磁盘报警,我上去看,df -h / 显示 98G 用了 93G,第一反应是盘该扩了,但运维流程要走单子,得先写清楚是谁占的,于是按常规套路找大目录,du 挨个扫过去,数出来一共 41G,93 减 41 还差 52G,这 52G 既不在日志里也不在家目录里,就像一个不存在的租客占着半个房子,我盯着那两个数字看了很久,开始怀疑自己是不是算错了,又重算了一遍,还是 52G。

先说结论,省得你也绕一个小时,那 52G 在一个被删掉的文件上,只是删它的时候还有个进程开着。

按常规套路找大目录,

1
du -sh /var/log /var/lib /home
1
2
3
2.1G	/var/log
8.4G /var/lib
31G /home

加起来 41G。93 减去 41,还差 52G 不知道在哪。

我当时的排查顺序挺有代表性的,先怀疑系统保留区(tune2fs -m 那种),再怀疑 ext4 的元数据,接着怀疑是不是有挂载点重叠导致 du 跳过了什么东西,最后甚至开始怀疑 df 的数字是不是在骗我,这四条假设我挨个去验证,每一条都要敲命令、看输出、再推翻,折腾了快一个小时,期间我一直默认占空间的必然是一个看得见名字的文件,这个前提错得很安静,因为它从来没被写下来过,也就没人去质疑它。

折腾了快一个小时才想起来还有一条命令,

1
sudo lsof +L1 2>/dev/null | head
1
2
COMMAND  PID USER   FD  TYPE DEVICE  SIZE/OFF NLINK  NODE NAME
nginx 1421 root 12w REG 253,1 45.2G 0 262144 /var/log/nginx/access.log (deleted)

一行,45.2G,名字后面挂着 (deleted)。

文件名被删了,inode 没释放,因为 nginx 还攥着它的 fd,这一篇讲的就是这件事为什么必然发生,以及它连带出来的一整套东西。这件事有意思的地方在于它不是故障,内核的每一步都做对了,unlink 该删的记录删了,st_nlink 该减一减了,数据块该留也留了,三件事都挑不出毛病,可合起来的结果就是运维看着 93G 的占用水位线找不到那 52G 在哪,任何人第一次撞见都得懵一下,因为脑子里那个模型一直把文件和文件名当成一件事,一件事只需要处理一次,删了就没了,而真实结构里这是两件事,删掉名字和丢掉数据是两个独立动作,它们在绝大多数时候同时发生,所以你从来没注意过它们能分开。(这条命令是同事路过时顺口提的,我之前完全不知道 lsof 的那个 +L1 是干什么的,参数见过很多次,一直当成某个无关的过滤开关)

文件名不是文件

这句话听着像在抠字眼,但它是整个文件系统的地基。

一个文件在磁盘上的完整结构是两层,

层 存的是什么 属于谁
目录项(dentry) 名字 → inode 号的映射 目录
inode 大小、权限、属主、时间、数据块位置、链接数 文件

目录本身也是一个文件,它的内容就是一张名字到 inode 号的表,所以严格讲,新建文件不是创建了一个文件,而是在目录里加了一条记录,另外分配了一个 inode,这两件事是分开的,分开这件事本身没什么存在感,可一旦你要问这个文件到底是谁,答案就只能落在 inode 上,因为名字属于目录,换一个目录就是另一个名字,而 inode 从头到尾只有一个,这也顺带解释了为什么同一个文件可以有多个名字但只有一份内容,目录那张表里多写一行就够了,数据块一个都不用复制,代价低到几乎为零,所以硬链接这个功能在内核里几乎没有成本,它只是给 inode 的计数加了个一。

这张表能直接看,ls -i /tmp 会给每个名字前面加上 inode 号,那一列数字比文件名更能代表身份。

1
ls -i /tmp
1
2490934 tiny.txt   2491033 xonly   2491034 share

-i 就是 inode 号,而硬链接不过是另一条指向同一个 inode 的记录,它和原文件完全平级,没有主次之分,两条记录都指着同一个 inode,谁都不是谁的副本,所以删掉其中任何一条,另一条照样能用,内容一点不动,我第一次理解这个模型的时候画了半页纸的箭头,画完才意识到自己想复杂了,其实就一句话,名字是贴在 inode 上的便利贴,可以贴很多张,贴几张都不影响底下那件东西本身,麻烦的地方在于你没法从 inode 反查出它一共有几个名字,内核根本不存这条反向信息,想数只能遍历目录去比对 inode 号,这也是 find / -inum 262144 慢得让人想放弃的原因。

删一个文件,内核做了三件事

rm 底下调的是 unlink,逻辑就三步,

  1. 在目录里删掉那条名字 → inode 的记录;
  2. st_nlink 减一;
  3. 如果 st_nlink 归零,并且没有任何进程打开它,才真正释放 inode 和数据块。

第 3 步那两个条件是与,不是或,所有现象都从这一个与字里长出来。

状态 st_nlink 有进程打开? 空间释放?
普通删除 变 0 没有 立即释放
删了但有人开着 变 0 有 不释放
只删了一个硬链接 还大于 0 无所谓 不释放
有进程打开但没删 还大于 0 有 不释放

内核里没有一张叫 unlinked 的链表专门收这类文件,真正撑住它的是 inode 自己的引用计数,只要还有打开的 fd 持着这个 inode,计数就不归零,iput 就不会走到最后那一步(iput_final → generic_drop_inode),inode 和数据块都留着,等最后一个 close 才真正清理,文件系统那一侧另有一条链,叫 orphan list,ext4 在 nlink 掉到 0 时把它挂上去(ext4_orphan_add),掉电重启后 e2fsck 靠这条链把块收回来,所以你看到的那 52G 并没有悬空,它一直有一个内核里的账本记着。(orphan list 这个名字我查了半天,因为它和 unlinked 链表传说混在一起,很多老文章还在讲后面那个)

所以删了文件空间不降不是 bug,是设计,数据还在被使用,只是没名字了。

st_nlink 就是 struct stat 里那个字段,stat -c %h 能看,建一个硬链接两边都变 2,删掉一个变回 1,内容一点没动。

这也就解释了 ls -l 第二位那个数字到底是什么,它就是 st_nlink,普通文件是 1,每加一条硬链接加一,目录的那个数字更大,因为它至少有 . 和父目录里的那条记录,我在讲 stat 那篇里专门绕了一圈才明白这一点,这里就不再重复。倒是有一个副作用值得记一下,st_nlink 大于 1 的普通文件意味着它还有别的名字,但你没办从这一个 inode 查出那些名字在哪,find / -inum 得把整个根遍历一遍,盘大了要跑很久,所以清理遗留文件的时候,靠 nlink 只能判断该不该删,找不到该删哪一个。

为什么改了名它还在写

这是生产上最经典的一幕。

1
2
3
cd /var/log/nginx                   # nginx.pid 在别处的话就用全路径
mv access.log access.log.1
sudo kill -USR1 $(cat /run/nginx.pid) # 让 nginx 重新打开日志

如果第二步没做,或者信号没送到主进程,nginx 会继续往那个已经改名的文件里写,原因很简单,

  • mv 在同一文件系统内只改目录项里的名字,inode 没变;
  • nginx 手里握的是 fd,fd 指向 inode,跟名字没有关系;
  • 所以它写进去的还是同一份数据,只是这份数据现在叫 access.log.1 了。

然后 du 看着 access.log.1 越来越大,df 也越来越满,你以为 logrotate 在工作,实际上什么都没发生,这种状态能持续很久,因为两边都觉得自己是对的,du 看见的确实是一个在长的文件,df 看见的确实是在被占的块,没人报错,也没人报警,唯一的症状就是盘慢慢满,而磁盘一般都是在最不合适的时候才满的,比如周五晚上,比如大促前一小时,我遇到的那次是后者,当时所有力气都花在找是谁写的日志上,没人想到去查这个文件还有没有名字,因为报警里显示的路径明明还好好的在那,ls 也能看见它,能看见的东西默认等于活着的,这个默认太强了,强到需要一条专门的命令才能反驳它。(我第一次遇到的时候排查方向完全错了,我在看 logrotate 的配置,怀疑它加了 delaycompress 之后没压缩,其实压缩根本不是问题所在)

清空的正确姿势不是 rm,是截断,/var/log 一般归 root,所以要 sudo,这是对生产日志的写操作,先想清楚再敲,

1
sudo sh -c ': > /var/log/nginx/access.log'

或者

1
truncate -s 0 /var/log/nginx/access.log

truncate 把 inode 的 size 设成 0 并释放数据块,文件名不变,fd 也不变,写日志的进程毫发无伤,继续往同一个 inode 里写,所以它不需要重启任何一个服务,也不需要通知谁,日志就从零开始重新长。

一句话,rm 对正在被写的日志文件是无效的,要用 truncate,这条我记了很久,因为第一次遇到的时候,我 rm 完之后发现磁盘没降,还以为 df 缓存了,后来在另一个项目上又遇到一次同样的症状,才把这条彻底刻进去,那次是 php-fpm 的慢日志,运维同事删了文件之后还一脸笃定地说应该等一会儿才降,因为文件系统有延迟,我们俩就这么等了两小时,水位线纹丝不动,最后还是 lsof 一查就破了案,两小时换来一个不该忘的结论,代价还算便宜。

df 和 du 问的不是同一个东西

命令 怎么算 从哪拿数据
df 文件系统层面:总块数减空闲块数 statfs 系统调用,问的是块设备
du 目录树层面:把所有文件的 st_blocks 加起来 遍历目录,问的是每个 inode

一个问设备,一个问 inode,能对上才怪,这两个命令的名字里都带着 disk 和 usage 的意思,谁都会觉得它们在回答同一个问题,实际上它们连数据来源都不在一层,df 问的是文件系统自己还剩多少空闲块,du 问的是这些目录底下的文件登记了多少块,一个从上面往下看,一个从下面往上加,中间差着所有没有名字的东西,我一开始以为这种差异只出现在特别边缘的场景里,直到发现差 52G,才意识到 52G 已经不算边缘了,它占了总量的一半还多,而两个命令都没报错,都在很自信地给数字。

差异至少四类,第一类就是上面那个,被删但还被打开的文件,du 找不到名字,df 知道块被占着。

稀疏文件其实两类都对得上,因为你比较的如果不是 ls -l 的 st_size 而是实际块数,会误导人的通常是 ls -l,它显示的是逻辑长度,不是占用。

文件系统自身开销也是一类,ext4 的日志、inode 表、元数据都占块,du 不计入,小文件特别多的时候这一项很显眼,一个全是几 KB 小文件的目录能吃掉比预期多得多的盘。

挂载边界也会制造差异,du / 默认不跨文件系统,而 df / 只报一个,反过来,du 跑到没权限的目录会静默跳过,结果偏小,这个偏小还特别难发现,因为 du 不报错,它只是少算了几块。

第 1 类有个特别好用的自查法,前面已经出现过了,df 满而 du 加起来不多,先去翻 deleted 文件。

1
lsof +L1

+L1 的意思是链接数小于 1,也就是已经被删掉的,输出里 NLINK 那列是 0 的就是,这个参数长得像某种过滤语法,其实就一个条件,我第一次知道的时候还愣了下,因为它跟 -i、-p 那些参数完全不是一个路子。

没装 lsof 也能找,

1
sudo find /proc/*/fd -lname '*(deleted)' 2>/dev/null | head

扫的是每个进程的 fd 里,符号链接指向带 (deleted) 的那些,find 的 -lname 就是拿符号链接的目标去匹配,而 /proc/PID/fd 下面那些恰好都是符号链接。

两种链接,六条差别

硬链接不是万能的,它有三条硬限制。

不能跨文件系统,因为 inode 号只在单个文件系统内唯一,两个设备各自从 1 开始编号,撞号了也说不清指的是谁,所以 ln /tmp/a /dev/shm/a 这种写法会直接报错。

不能给目录建,这条是无条件的,vfs_link() 里撞到目录直接 return -EPERM,root 也不行,因为放开了会造出环,fsck 和目录遍历都会挂进去。

第三条不算限制,但更实际,多数时候你其实该用软链接。

软链接是另一个东西,它是个独立文件,内容就是目标路径那串字符串,所以差别很成体系,

对比 硬链接 软链接
inode 共享同一个 自己独立
跨文件系统 不行 可以
指向目录 不行 可以
目标删除后 还能访问 变成悬空链接
大小 和原文件一样 目标路径的字节数
权限 和原文件一样 恒为 777,没意义

软链接的权限位永远显示 lrwxrwxrwx,chmod 它也不生效,它的权限根本没用,访问时看的是目标文件的权限,这一条我在面试里被问过一次,答对了,但当时是因为背过,不是因为想通了,真正想通是有一次我给一个软链接 chmod 700,然后以另一个用户身份访问它指向的文件,照样能读,才明白那七位只是摆设。

顺带说一条反直觉的经验,软链接用相对路径比绝对路径好,绝对路径的软链接在目录整体搬移之后会断,相对路径的还能用,打包软件的时候这条特别省事,因为你不知道用户会把它解到哪个父目录下。

文件系统不同,这些数字会飘

上面这些结论在 ext4、xfs、btrfs 上基本成立,细节有差。

xfs 的 inode 号是 64 位,数字非常大,第一次看到十位数的时候我还以为自己的 find 写错了,overlayfs 和容器里 st_nlink 的行为不太一样,偶尔能看到 0 却仍然读得到,tmpfs 上的文件不占块设备,df 看到的是内存用量,NFS 上 df 和 du 的差异可能很大,因为客户端并不知道服务端真实的占用。

排查前先确认挂载类型,findmnt -T /var/log 会一路往上找到那个目录属于哪个挂载点,这一行的信息比 df -h 更直接。

1
findmnt -T /var/log

让 inode 被复用一次

理解名字和 inode 不是一回事,最快的办法是让一个 inode 被复用一次。

1
2
3
4
cd /tmp && rm -rf ino && mkdir ino && cd ino
echo a > a.txt
echo b > b.txt
ls -i
1
2
3

2491101 a.txt
2491102 b.txt

删掉再建同名文件,

1
2
3
rm a.txt
echo c > a.txt
ls -i

a.txt 的 inode 变了,也不一定,可能被复用,内核会把刚释放的 inode 塞进缓存优先复用,这一点本身就说明同名文件不等于同一个文件,所以拿 inode 号做判断也不完全可靠,短时间内的复用会让两次不同的创建看起来像同一个文件。(这个坑我在比对两个备份的时候踩过,inode 一样,我以为是同一份,其实是旧的被删了、新的恰好用了同一个号)

再验删了但空间没降,这个实验要盯着 df 的 Used 列看,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cd /tmp/ino
fallocate -l 500M big.img # 这 500M 是真的,测完记得删
df -h /tmp | tail -1
python3 -c "
import time
f = open('/tmp/ino/big.img','rb')
import os
os.remove('/tmp/ino/big.img')
print('已删除,但 fd 还开着')
time.sleep(30)
" &
sleep 2
df -h /tmp | tail -1
lsof +L1 2>/dev/null | grep big.img

df 的 Used 不动,lsof 里那个文件带着 (deleted),等 30 秒 Python 退出、fd 关掉,空间立刻回来。

然后对比截断,这个实验最直观的地方是你能亲眼看到文件从 0 开始重新长,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
echo "一些日志" > /tmp/ino/service.log
python3 -c "
import time
f = open('/tmp/ino/service.log','a')
for i in range(1000):
f.write('x' * 1000)
f.flush()
print('现在截断')
import subprocess
subprocess.run(['truncate','-s','0','/tmp/ino/service.log'])
print('继续写')
f.write('截断之后的内容\n')
f.flush()
time.sleep(3)
" &
sleep 1
ls -l /tmp/ino/service.log

截断之后进程接着写,文件从 0 开始长,这就是 logrotate 本该有的行为。

最后看两种链接,

1
2
3
4
5
cd /tmp/ino
echo hello > orig.txt
ln orig.txt hard.txt
ln -s orig.txt soft.txt
ls -li
1
2
3
4

2491150 -rw-r--r-- 2 alice alice 6 Nov 13 21:00 hard.txt
2491150 -rw-r--r-- 2 alice alice 6 Nov 13 21:00 orig.txt
2491151 lrwxrwxrwx 1 alice alice 8 Nov 13 21:00 soft.txt -> orig.txt

看第一列,hard.txt 和 orig.txt inode 相同、链接数都是 2,soft.txt 是自己的 inode,大小 8 是 orig.txt 那串字符的字节数。

删掉 orig.txt,

1
2
3
rm orig.txt
cat hard.txt
cat soft.txt

第一个正常,第二个 No such file or directory,因为软链接自己没死,它指向的那个名字没了。

硬链接和软链接,混着问的最多

删了文件空间就该降吗,有进程还开着就不降,lsof +L1 一秒钟能查出来。

mv 会搬数据吗,同一文件系统内只改目录项,不搬,跨文件系统才真复制,那时候等价于 cp 加 rm,所以一个几百 G 的目录 mv 过去慢得像在拷,是正常的。

rm 能腾出日志占的空间吗,对正在被写的日志不管用,用 : > file 或 truncate -s 0。

硬链接是两个文件吗,同一个 inode 的两条记录,没有主次。

符号链接的权限有用吗,没用,永远 777,访问控制看目标。

ln -s 用相对路径不安全吗,反了,相对路径在目录搬移之后还活着,绝对路径会断。

名字、inode、引用计数

文件名是目录里的一条记录,inode 才是文件,ls -i 能看到 inode 号。

unlink 三步,删记录、nlink 减一、两个条件都满足才释放。

删了空间不降等于还有进程开着那个 inode,lsof +L1 直接定位。

日志文件要截断,不要 rm。

df 问块设备,du 问 inode,对不上是常态。

硬链接不能跨文件系统、不能指目录,软链接能,但会悬空。

到这里,一个文件在系统里的身份,它叫几个名字、归谁、能干什么、被谁占着,都讲完了,接下来该换主角了,进程。第一件事是个几乎所有人都答错的问题,ps 里的 D 是什么意思,为什么这种进程连 kill -9 都杀不掉。