朕喜技术(ZhenxiJS)
2026.10.03
1 分钟阅读

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 写满 / Python open() 忘记 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,单调增长

三、根本原因

四个缺陷叠加,而且四处的判据全部指向了错误的对象:

  1. app/archive.py::commit() 只做 handle["committed"] = True, 既不出簿,也不 close(handle["fh"]) —— 句柄被登记簿长期持有, unlink 之后 inode 引用计数不减 → 空间永不归还,登记簿还随轮次无限增长。
  2. app/retention.py::sweep() 把 os.unlink() 调用成功当成「空间已归还」, freed_bytes 只是「我以为我删了多少字节」的自述累加, 报告里没有任何可外部独立复核的字段。
  3. app/health.py::check() 判据看 archive_report["ok"](被测方自己写的自述字段), 容量只算 df 的表观用量,self_reported_ok 是手写常量 True。
  4. 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 时要想清楚上游可能抛哪些异常类型,别只覆盖到最窄的那一种, 否则「大声失败」会退化成「抛栈退出」,退出码还各不相同。

六、经验与避坑

  1. unlink() ≠ 释放空间。这是本题的核心认知: 真正归还空间是最后一个 fd 被 close 之后的事。 「删除成功」与「空间归还」是两件事,别把它们当成一件。
  2. du / df 在这类故障上会一起骗你。文件已 unlink、目录树里看不见, 但分区占用不降反升。唯一可信判据是 /proc/<pid>/fd 里有没有指向 (deleted) 的 fd。 取证口诀:现场越「干净」(du 小、rm 成功),越可能藏着幽灵。
  3. 计数器和自述字段都不能自证。freed_bytes 是「我以为我删了多少」; 报告里必须有一个能被外部独立复核的字段(本题:幽灵 fd 数 / 幽灵字节数、 登记簿 in_flight),否则计数与事实矛盾也发现不了。
  4. self_reported_ok: True 这类手写常量等于没有自检;return 0 恒 0 是静默故障的温床。 自检必须算出来,退出码必须反映真实结果。
  5. 「拿不到数据」≠「没问题」:契约读不到要 fail-closed(本题退出码 3), 不能退化成「跳过校验 = 通过」。而且 fail-closed 要覆盖全部失败形态: 文件缺失(OSError/FileNotFoundError)与缺 key(KeyError)都得走到同一个非 0 退出码, 不能只 catch 其中一种 —— 漏掉的那种会以未捕获回溯(退出码 1)收场,看起来像「随便挂了」, 既不是大声失败也没有明确语义。
  6. 本环境无 docker CLI(docker: not found),按题面「方式 A」在 pipeline 容器内 直接复现即可(只需 python3 + procps);DATA_DIR / STATE_DIR / CONF_DIR 三个环境变量可把运行产物定向到 state/(已 gitignore),与 docker 方式等价。
  7. repro.sh 第 3 节用 ls -l 统计「幽灵字节」拿到的是 fd 符号链接自身的大小(几百字节), 不是文件真实大小。真实占用要 os.stat("/proc/<pid>/fd/<N>").st_size (本题 3 MiB = 6 × 512 KiB,与分片大小吻合),以 verify.sh 的 C 组数字为准。
  8. verify.sh 的 E 组是静态 grep(grep -c 'quota' app/health.py), 光改注释就能骗过 —— 所以必须补负向验证,证明「采纳契约」是行为上的。
  9. 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
指标问题态修复后
df64M / 99%1.7M / 3%
du1.7M(与 df 矛盾)1.7M(与 df 一致)
幽灵 fd 数1230
进程 fd 总数随轮次增长3(稳定)
unreclaimed_bytes字段不存在0
archive.in_flight123(单调增长)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 侧补充踩坑

  1. rm -rf /srv/data 报 Device or resource busy —— 它是 tmpfs 挂载点。 ops/repro.sh / ops/verify.sh 里都有这行红字,脚本用 set -u 不是 set -e, 不影响验收,别当成场景坏了。清干净用 rm -rf "$D"/* "$D"/.[!.]*。
  2. app/ 是 bind mount(./app:/srv/app)→ 改宿主机文件容器内立刻可见、不用 --build; 但必须清 app/__pycache__,否则 Python 跑的还是旧 .pyc。
  3. docker compose exec 会把容器内退出码当自己的返回码; 再套 | tail 的话 $? 变成 tail 的退出码 —— 我第一次就这样拿到 RC=0, 差点误判「闸门没牙」。取真实退出码要写 bash -c 'cmd >/tmp/v.log 2>&1; echo RC=$?; tail /tmp/v.log'。
  4. 负向注入必须 faithful,否则会被 CPython 引用计数骗。 我第一版只去掉 fh.close()、保留了出簿:in_flight 一直 0、幽灵 fd 也消失 —— 因为 self.pending.remove(handle) 之后 handle dict 成了唯一引用, CPython 立刻 GC 掉它、fh.__del__ 顺手把 fd 关了。 真实缺陷是「既不出簿也不 close」(句柄永远留在 self.pending 里,引用计数永不为 0)。 → 注入完要先确认注入真的生效再下结论。
  5. ops/apply_fix.sh --apply/--restore 会覆盖你自己的改动。 基线永远以 git 为准(git checkout -- app/),别信临时备份。
  6. 别改 ops/*.sh 与 client/space_probe.py —— 探针只看 /proc/<pid>/fd (不读任何自述字段),是本题唯一的外部裁判。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-03-1600-unlinkclosedeleted-fd

分享这篇文章