rm/unlink 报成功、df/du 也说空间释放了,磁盘却被占满 —— 已 unlink 的文件被「打开未释放的 fd」钉成幽灵文件(deleted but still held)
日期:2026-10-03 · 分支:
2026-10-03-1600· 题号:第101题 关键词:rm删除成功但空间没释放 /unlink与close的区别 / 幽灵文件 / deleted but still held //proc/<pid>/fd里的(deleted)/ inode 引用计数 / 文件描述符泄漏导致磁盘满 /No space left on device但du很小 / 归档句柄登记簿不收敛 / 容量水位监控失灵 / tmpfs 写满 / Pythonopen()忘记close()/ df 与 du 不一致 / 自述字段 vs 内核事实 / 契约未被采纳
一、问题背景
一台「归档守护」在数据分区上周期性干活,每轮三件事:
写 spool 分片 → 登记进归档台账(并在内存登记簿里保留分片自身的写句柄,以便后续校验/补写)→ 按年龄 unlink 超期分片。
现象:分区只有 64 MiB,跑几十轮后写不进去了(No space left on device),可是:
rm/unlink每轮都报成功,retention.freed_bytes每轮都在涨;du -sh看数据目录几乎不占地方;- 守护进程
ok: true、health.degraded: false、退出码 0,看板一路全绿。
一句话结论:unlink() 摘掉的是目录项,close() 释放的才是 inode 引用。
只要还有 fd 指向那个 inode,空间的引用计数就不为 0,文件系统不会回收它 ——
文件名从目录树消失了,磁盘却仍然被这块「幽灵文件」占着。
二、复现步骤
cd scenario/2026-10-03-1600
# 方式 A(本环境无 docker CLI,用的就是这条):只需 python3 + procps
bash ops/repro.sh # 一键复现:跑守护 → 探针取证 → 磁盘对账 → 结论
bash ops/verify.sh # 验收:问题态 PASS=0 FAIL=5 / RESULT: BAD
# 方式 B:docker 一键(/srv/data 是 64 MiB 的 tmpfs,容量上限是真的)
docker compose up -d --build
docker compose exec archiver bash ops/repro.sh
docker compose exec archiver bash ops/verify.sh
问题态关键输出:
== 2. 独立探针(只看内核事实,不读任何自述字段) ==
pid=513 指向已删除文件的 fd 数 = 6
fd=10 -> /srv/data/spool/shard-000202.dat (deleted)
幽灵文件合计 = 6 个 / 3145728 字节
[FAIL] 空间已归还(无幽灵文件)
RESULT: BAD
[daemon] round=2 retention={"scanned": 3, "deleted": 3, "freed_bytes": 1572864},
disk_used_ratio=0.000006 health_degraded=False in_flight=6
证明泄漏是累积的(这正是「跑几十轮后磁盘满」的机理):
export DATA_DIR=$PWD/state/leak STATE_DIR=$PWD/state CONF_DIR=$PWD/conf
rm -rf "$DATA_DIR"; mkdir -p "$DATA_DIR"
ROUND_INTERVAL=0.1 python3 app/daemon.py > "$STATE_DIR/leak.log" 2>&1 &
sleep 4
ls -l /proc/$(pgrep -f daemon.py | head -1)/fd | grep -c '(deleted)'
grep -o 'in_flight=[0-9]*' "$STATE_DIR/leak.log"
round=1 ... in_flight=3
round=2 ... in_flight=6
round=3 ... in_flight=9 # 每轮净增 3,单调增长
三、根本原因
四个缺陷叠加,而且四处的判据全部指向了错误的对象:
app/archive.py::commit()只做handle["committed"] = True, 既不出簿,也不close(handle["fh"])—— 句柄被登记簿长期持有,unlink之后 inode 引用计数不减 → 空间永不归还,登记簿还随轮次无限增长。app/retention.py::sweep()把os.unlink()调用成功当成「空间已归还」,freed_bytes只是「我以为我删了多少字节」的自述累加, 报告里没有任何可外部独立复核的字段。app/health.py::check()判据看archive_report["ok"](被测方自己写的自述字段), 容量只算df的表观用量,self_reported_ok是手写常量True。app/supervisor.py::main()return 0恒 0;contract.load_quota()从来没有调用点 ——conf/quota.env是彻头彻尾的死代码型契约。
四个绿灯没有一件指向真相:
rm 报成功只证明目录项摘了;du / df 低占用是因为文件在目录树里已经消失;
ok: true / degraded: false 是自述;退出码 0 只证明进程正常退出。
四、解决办法
只改 app/ 下 4 个文件(client/space_probe.py、ops/verify.sh 一字未动)。
① archive.py:commit() 真正结算句柄(出簿 + close),并保持幂等
def commit(self, handle: dict) -> None:
if handle.get("committed"): # 幂等:避免 double-close 掉已被复用的 fd
return
fh = handle.get("fh")
if fh is not None and not fh.closed:
fh.close() # ← 让空间真正归还的唯一动作
ledger = handle.get("ledger")
if ledger is not None and not ledger.closed:
ledger.close() # 台账句柄每轮一个,不关就是每轮漏一个 fd
handle["committed"] = True
try:
self.pending.remove(handle) # ← 出簿,登记簿才会收敛
except ValueError:
pass
② retention.py:区分「已删除」与「真的归还」
新增 unreclaimed_bytes:登记簿里还挂着句柄时,已删除的字节数就是未归还的字节数,
并如实追加进 errors(不再静默)。
③ health.py:判据改对内核事实 + 采纳配额契约 + 自检由计算得出
- 从
/proc/<pid>/fd数指向(deleted)的 fd(ghost_fd_count/ghost_bytes)—— 可被外部独立复核的事实,而不是自述; - 容量按 有效占用 = 文件系统已用 + 幽灵占用 判定,不再看
df表观值; quota is None→ 算不通过(「拿不到数据」≠「没问题」); 采纳conf/quota.env的total_bytes/per_tenant_bytes/ 租户清单;self_reported_ok改为not issues(算出来的),删掉写死的True。
④ supervisor.py:退出码分层 + 真正采纳契约run_round() 里调 contract.load_quota();契约不可用 → EXIT_CONTRACT=3;
容量越界 / 有未归还空间 → EXIT_CAPACITY=2;report["ok"] 由
「算出来的自检」+「unreclaimed_bytes == 0」共同决定。
main() 的 except 要同时覆盖 KeyError(缺 key)与 OSError(文件缺失 / 不可读,
FileNotFoundError 是其子类),否则「契约文件被删」会变成未捕获回溯(退出码 1),
而不是 fail-closed 的 EXIT_CONTRACT=3;daemon.py 做同样保护。
五、验证
bash ops/verify.sh # PASS=5 FAIL=0 / RESULT: OK
bash ops/repro.sh # 探针 RESULT: OK
== A. [PASS] 指向已删除文件的 fd 数 (期望=0 实际=0)
== B. [PASS] 登记簿 in_flight 是否收敛(<=3) (期望=0 实际=0)
== C. [PASS] 幽灵占用字节 (期望=0 实际=0) # 自述 freed_bytes=1572864
== D. [PASS] 无幽灵文件时 degraded (期望=false 实际=false)
== E. [PASS] 健康检查是否采纳配额契约(1=采纳) (期望=1 实际=1)
PASS=5 FAIL=0
RESULT: OK
长跑收敛验证(4 轮,ROUND_INTERVAL=0.05):ghost_fds=0、
in_flight 取值分布 4 次全是 in_flight=0(不再单调增长)、
每轮 "freed_bytes": 1572864, "unreclaimed_bytes": 0。
负向验证 A:只去掉 commit() 里的 close + 出簿(其余保持修复版)→
幽灵 fd 立刻回来、in_flight 立刻不再收敛、闸门正确翻真:
== A. [FAIL] 指向已删除文件的 fd 数 (期望=0 实际=6)
== C. [FAIL] 幽灵占用字节 (期望=0 实际=3145728)
== D. [PASS] 存在幽灵文件时 degraded (期望=true 实际=true)
PASS=2 FAIL=3 / RESULT: BAD
负向验证 B:把 conf/quota.env 整个删掉(其余契约保留,CONF_DIR 指向临时目录)→
契约不可用大声失败([FATAL] + 退出码 3):
$ DATA_DIR=/tmp/nv/... CONF_DIR=/tmp/nv/conf python3 app/supervisor.py
[FATAL] 契约不可用:[Errno 2] No such file or directory: '/tmp/nv/conf/quota.env'
退出码 = 3
$ CONF_DIR=/tmp/nv/conf ROUND_INTERVAL=0.1 python3 app/daemon.py
[FATAL] 契约不可用:[Errno 2] No such file or directory: '/tmp/nv/conf/quota.env'
退出码 = 3
负向验证 B′:quota.env 存在但缺 key(去掉 QUOTA_TOTAL_MIB)→ 走 KeyError 分支:
[FATAL] 契约不可用:'QUOTA_TOTAL_MIB'
退出码 = 3
(修复前 load_quota() 无任何调用点,把它删掉整个程序行为不变 —— 这本身就是「契约形同虚设」的证据。)
踩过的坑:一开始
supervisor.main()只except (KeyError, ValueError), 删掉quota.env得到的是未捕获的FileNotFoundError回溯、退出码 1,没有[FATAL]。 已改为except (KeyError, ValueError, OSError),让「文件缺失」和「缺 key」都 fail-closed 到退出码 3。 教训:写except时要想清楚上游可能抛哪些异常类型,别只覆盖到最窄的那一种, 否则「大声失败」会退化成「抛栈退出」,退出码还各不相同。
六、经验与避坑
unlink()≠ 释放空间。这是本题的核心认知: 真正归还空间是最后一个 fd 被close之后的事。 「删除成功」与「空间归还」是两件事,别把它们当成一件。du/df在这类故障上会一起骗你。文件已 unlink、目录树里看不见, 但分区占用不降反升。唯一可信判据是/proc/<pid>/fd里有没有指向(deleted)的 fd。 取证口诀:现场越「干净」(du小、rm成功),越可能藏着幽灵。- 计数器和自述字段都不能自证。
freed_bytes是「我以为我删了多少」; 报告里必须有一个能被外部独立复核的字段(本题:幽灵 fd 数 / 幽灵字节数、 登记簿in_flight),否则计数与事实矛盾也发现不了。 self_reported_ok: True这类手写常量等于没有自检;return 0恒 0 是静默故障的温床。 自检必须算出来,退出码必须反映真实结果。- 「拿不到数据」≠「没问题」:契约读不到要 fail-closed(本题退出码 3),
不能退化成「跳过校验 = 通过」。而且 fail-closed 要覆盖全部失败形态:
文件缺失(
OSError/FileNotFoundError)与缺 key(KeyError)都得走到同一个非 0 退出码, 不能只 catch 其中一种 —— 漏掉的那种会以未捕获回溯(退出码 1)收场,看起来像「随便挂了」, 既不是大声失败也没有明确语义。 - 本环境无 docker CLI(
docker: not found),按题面「方式 A」在 pipeline 容器内 直接复现即可(只需python3+procps);DATA_DIR/STATE_DIR/CONF_DIR三个环境变量可把运行产物定向到state/(已 gitignore),与 docker 方式等价。 repro.sh第 3 节用ls -l统计「幽灵字节」拿到的是 fd 符号链接自身的大小(几百字节), 不是文件真实大小。真实占用要os.stat("/proc/<pid>/fd/<N>").st_size(本题 3 MiB = 6 × 512 KiB,与分片大小吻合),以verify.sh的 C 组数字为准。verify.sh的 E 组是静态 grep(grep -c 'quota' app/health.py), 光改注释就能骗过 —— 所以必须补负向验证,证明「采纳契约」是行为上的。close()之后再做一次commit()必须安全(幂等),否则会 double-close 掉已经被复用给别的资源的 fd 号。
一句话自查:
rm 报了成功之后,谁证明这块空间回到了文件系统?
你的容量监控看的是 df / du,还是有效占用(含已删除但仍被占用的部分)?
常驻进程里那些「保留起来待会儿再关」的句柄,有没有一条路上真的把它们关掉?
同类影响面:write() 后忘记 close() 的归档/日志轮转、连接池与文件句柄池的归还路径、
DB 连接泄漏、socket 忘记 close()、shm_open / memfd_create 之后只 shm_unlink 不 close、
临时文件清理逻辑「只 unlink 不关引用」、以及任何把「调用成功」当成「效果达成」的代码。
附:Docker 环境下的独立复核(真实 64 MiB tmpfs)
上面的复现来自「无 docker CLI」的 pipeline 容器(
/srv/data是普通目录,不受容量限制, 只能靠/proc/<pid>/fd取证)。在有 docker CLI 的机器上又独立复核了一遍, 补上了「真的把盘写到 100%」这条终局证据。
复现命令
cd scenario/2026-10-03-1600
docker compose up -d --build # /srv/data 是 64 MiB tmpfs
# 问题态
bash ops/apply_fix.sh --restore
docker compose exec -T archiver bash -c 'rm -rf /srv/app/__pycache__; bash ops/verify.sh'
# -> PASS=0 FAIL=5 / RESULT: BAD
# 修复态
git checkout -- app/ # 本分支自带的修复版
docker compose exec -T archiver bash -c 'rm -rf /srv/app/__pycache__; bash ops/verify.sh'
# -> PASS=5 FAIL=0 / RESULT: OK
★ 端到端对照:同样 41 轮负载(ROUND_INTERVAL=0.02)
问题态:
轮次数 = 41
df: tmpfs 64M 64M 856K 99% /srv/data <-- 盘满了
du: 1.7M <-- 几乎不占地方(自相矛盾!)
幽灵 fd 数 = 123
写 2MiB 试: 0+0 records out <-- 写不进去了
[daemon] round=41 retention={"scanned": 3, "deleted": 3, "freed_bytes": 1572864, "errors": []}
disk_used_ratio=0.963440 health_degraded=True in_flight=123
修复态:
轮次数 = 41
df: tmpfs 64M 1.7M 63M 3% /srv/data <-- 稳定 3%
du: 1.7M <-- 与 df 一致了
进程 fd 总数 = 3 <-- 稳定,不增长
幽灵 fd 数 = 0
写 4MiB 试: 4+0 records out <-- 4 MiB 一次写成功
[daemon] round=41 retention={..., "unreclaimed_bytes": 0, "errors": []}
disk_used_ratio=0.002502 health_degraded=False in_flight=0
| 指标 | 问题态 | 修复后 |
|---|---|---|
df | 64M / 99% | 1.7M / 3% |
du | 1.7M(与 df 矛盾) | 1.7M(与 df 一致) |
| 幽灵 fd 数 | 123 | 0 |
| 进程 fd 总数 | 随轮次增长 | 3(稳定) |
unreclaimed_bytes | 字段不存在 | 0 |
archive.in_flight | 123(单调增长) | 0 |
| 写文件 | 2 MiB 失败 | 4 MiB 成功 |
负向验证(Docker 侧)
- A:恢复问题态的
commit()(不close+ 不出簿)→PASS=2 FAIL=3 / RESULT: BAD、真实退出码 1; D 组由「恒绿」变成「独立抓到」(degraded=true)—— 证明闸门不是把指标改好看,而是第一次真的有牙。 - B:删掉
conf/quota.env→supervisor.py与daemon.py两条入口都[FATAL] 契约不可用:[Errno 2] No such file or directory、退出码 3。 - 边界:
commit()重复 3 次幂等(in_flight=0、fh.closed=True,不 double-close);QUOTA_TOTAL_MIB=1时degraded=True、exit_code=2(配额契约真的参与判定)。 docker compose down -v干净重建后连跑 3 次全绿。
Docker 侧补充踩坑
rm -rf /srv/data报Device or resource busy—— 它是 tmpfs 挂载点。ops/repro.sh/ops/verify.sh里都有这行红字,脚本用set -u不是set -e, 不影响验收,别当成场景坏了。清干净用rm -rf "$D"/* "$D"/.[!.]*。app/是 bind mount(./app:/srv/app)→ 改宿主机文件容器内立刻可见、不用--build; 但必须清app/__pycache__,否则 Python 跑的还是旧.pyc。docker compose exec会把容器内退出码当自己的返回码; 再套| tail的话$?变成tail的退出码 —— 我第一次就这样拿到RC=0, 差点误判「闸门没牙」。取真实退出码要写bash -c 'cmd >/tmp/v.log 2>&1; echo RC=$?; tail /tmp/v.log'。- 负向注入必须 faithful,否则会被 CPython 引用计数骗。
我第一版只去掉
fh.close()、保留了出簿:in_flight一直 0、幽灵 fd 也消失 —— 因为self.pending.remove(handle)之后 handle dict 成了唯一引用, CPython 立刻 GC 掉它、fh.__del__顺手把 fd 关了。 真实缺陷是「既不出簿也不 close」(句柄永远留在self.pending里,引用计数永不为 0)。 → 注入完要先确认注入真的生效再下结论。 ops/apply_fix.sh --apply/--restore会覆盖你自己的改动。 基线永远以git为准(git checkout -- app/),别信临时备份。- 别改
ops/*.sh与client/space_probe.py—— 探针只看/proc/<pid>/fd(不读任何自述字段),是本题唯一的外部裁判。
