快照空间清理执行器排障实录:expires_at=0 被当成「纪元零点已过期」、同 id 多行被去重、健康检查空判据表假绿
关键词:快照清理 / snapshot reaper / expires_at=0 过期判定 / Unix 纪元零点 / 保留窗口 retention 清单去重 / 零单元不占容量 / reclaim 恒 0 / min 与 max 写反 / 引用计数乘法 / 健康检查假绿 空判据表 / fail-closed / 契约不可用回退默认值 / Python / Docker Compose
- 题号:第 132 题
- 分支:
2026-10-18-1000,场景目录scenario/2026-10-18-1000 - 技术栈:Python 3.11 / Docker Compose / TSV 事实文件 + 契约 env / 外部探针 + 健康检查双层验收
一、问题背景
/srv/app 是块存储的快照空间回收器:读快照清单 inventory/snapshots.tsv、卷容量
inventory/volumes.tsv、本轮时间快照 inventory/now_clocks.tsv、业务动作 actions/actions.tsv,
按契约 conf/snapshot.env 与 docs/snapshot-contract.md 判定「每张快照清不清、清完剩多少、引用计数多少」,
写 state/snapshot-report.json 作为回执。
线上症状(也就是四个绿灯全部指向错误结论):
| 自述信号 | 表面结论 | 实际真相 |
|---|---|---|
counts = {"kept":0,"pending":2,"purged":6,"rejected":0,"total":8} | 正常跑完 | 清单 9 行算成 8 行,同 id 被去重 |
ok: true | 报告自洽 | purged+kept+rejected 根本没等于 total(8≠8 靠硬编码) |
health → {"degraded": false, "issues": []} | 健康 | BAD_FIELDS = (),它什么都没查 |
ops/repro.sh 里 app.run rc=0 | 执行成功 | 执行成功 ≠ 结果正确 |
真实现象:
- 完全没设过期时间(
expires_at=0)的 3 张快照被当成「1970 年就过期了」清掉; - 仍被进程持有(
holder非空)的快照被记进pending甚至purged; - 同一
snap_id两行被合并成一行,counts.rejected恒 0; - 容量一个都没有按快照算,
used_bytes是capacity // 2占位; - 汇总
reclaim恒0,而逐卷reclaim加起来 400 多 MiB; refcount_warn写成了「共享块数 + 卷引用计数」(加法),契约要的是乘法;- 契约读不到就静默回退内置默认值,契约写了非法值(
maybe)也照跑。
二、复现步骤
git checkout 2026-10-18-1000
cd scenario/2026-10-18-1000
docker compose up -d --build
docker compose exec -T reaper bash # 注意 -T:非交互环境没有 TTY
容器内:
bash ops/repro.sh ; echo "repro rc=$?"
# 问题态:RESULT: BAD(不一致 23 条) / probe rc=1 / repro rc=0(脚本退出码=探针退出码,1 是预期信号)
bash ops/verify.sh ; echo "verify rc=$?"
# 问题态:PASS=14 FAIL=26 / RESULT: BAD
python3 -m app.health ; echo "health rc=$?"
# 问题态:{"degraded": false, "issues": []} rc=0 ← 它什么都没查
问题态典型输出(节选):
counts {"kept":0,"pending":2,"purged":6,"rejected":0,"total":8}
purged ["snap-a1","snap-a2","snap-a3","snap-a4","snap-b1","snap-b3"]
pending ["snap-b2","snap-c1"]
reclaim 0
volumes.vol-a {"after_bytes":5972688896,"capacity":12884901888,"reclaim":469762048,"used_bytes":6442450944}
refcount_warn {"snap-a2":96,"snap-a4":308,"snap-b2":400} # 期望 9984 / 20800 / 136000
三、根本原因(逐条)
1. expires_at=0 被判成「Unix 纪元零点已过期」
def _is_expired(row, now, retention_s, conf):
if row["expires_at"] == 0:
if conf["expire_at_zero"] == "purge":
return True # 只有 purge 分支 return
return now - row["expires_at"] > retention_s # discard 掉到这里:now - 0 > 86400 恒真
SNAP_EXPIRE_AT_ZERO=discard(0 = 永不过期)时没有 return False,于是落进通用公式,
1765000000 - 0 > 86400 恒真 → 「永不过期」变成「早已过期」。
这是「特殊值分支不早返回」的通用 bug 形态,同类还有:null/0/空串表示"未设置"的字段,
一掉进默认公式就变成"最小值/最早时间"。
2. SNAP_RETENTION_HOURS 被当成过期判定阈值
契约里它是过期之后的等待窗口(expired_s > retention_s 才动手),不是「多久算过期」。
误读的后果是持有窗口内的快照被提前清。
3. 同一 snap_id 多行被去重
load_snapshots() 用 taken[snap_id] 建索引,if row["snap_id"] not in taken 就跳过。
上游天然会出现同 id 多行(同一快照的多次提交),契约要求按行裁决、同 id ≥2 行一律 reject。
去重的连锁效应:counts.total 8≠9、rejected 恒 0、snap-c1 进了 pending。
4. min / max 写反:汇总 reclaim 恒 0
reclaim = min(conf["min_reclaim_bytes"], reclaimable) # min(0, 469762048) = 0
契约:reclaim = max(下限, 可回收),但不能超过可回收。正确写法只有一个:
reclaim = min(max(min_reclaim_bytes, reclaimable), reclaimable)
min/max 写反不会抛异常,只是静默取错值,最难查。
5. 零单元语义丢失 + 容量整体没按快照算
used = meta["capacity"] // 2 # 占位假数据
SNAP_ZERO_UNIT_MEANS_FREE=yes 时,bytes_per_block=0 的行是容量单位标记,
blocks_written 不占容量。原实现 blocks_written * bytes_per_block 看似"自然",
但整条 used_bytes 都是假数据,所以「空卷报满」只是表象。
6. 引用计数写成加法
refcount[snap_id] = row["blocks_shared"] + usage_blocks.get(vol, 0) # 190 + 208 = 398?
契约:refcount_warn = 卷引用计数 × 这份快照的共享块数。96 / 308 / 400 这些数字看起来"像样",
所以没人怀疑 —— 量纲错误比数值错误更隐蔽。
7. 健康检查是空判据表 + 恒返回 0
BAD_FIELDS = ()
def check(report):
for field in BAD_FIELDS: # 空 tuple,循环一次都不跑
...
return [] # 永远 []
def main():
...
except (OSError, json.JSONDecodeError):
return EXIT_OK # 拿不到报告也报健康
...
return EXIT_OK # 恒 0
for 循环一次都不跑 → degraded=false 只代表"没检查",不代表"没问题"。
8. 契约不可用静默回退默认值
conf = dict(FALLBACK)
try: ... except OSError: pass # 读不到就用内置默认值
if k == "SNAP_ZERO_UNIT_MEANS_FREE":
conf["zero_unit_is_free"] = v # 字符串!"no" 也是真值
两处隐蔽问题:读不到契约不回退失败(fail-open);yes|no 存成字符串后 "no" 是真值。
四、解决办法
只改 app/(conf/ docs/ inventory/ actions/ client/ ops/ 是只读考卷)。
4.1 契约层:fail-closed + 取值域校验
class ContractError(Exception): ...
def load_contract():
try:
raw = open(_conf_path()).readlines()
except OSError as exc:
raise ContractError("契约不可用:%s" % exc) # 不再回退默认值
...
if conf["SNAP_EXPIRE_AT_ZERO"] not in ("discard", "purge"):
raise ContractError(...)
if conf["SNAP_ZERO_UNIT_MEANS_FREE"] not in ("yes", "no"):
raise ContractError(...)
return {"zero_unit_is_free": zero_unit == "yes", ...} # 真 bool
app.run 和 app.health 都捕获 ContractError → 退出码 3。
4.2 清单读取:按行,不去重
def load_snapshots():
rows = []
...
rows.append(row) # 不建 id 索引
return rows
4.3 裁决:优先级 + 早返回
def _row_verdict(row, now, conf):
if row["dup"]:
return "reject", "duplicate_id" # 优先级 1:重复 id
if row["expires_at"] == 0: # 优先级 2:0 = 未设置
if conf["expire_at_zero"] == "purge":
return "purge", "expired"
return "keep", "not_expired" # ← 关键:这里必须 return
if now - row["expires_at"] > conf["retention_s"]:
if row["holder"]:
return "keep", "held" # 仍被持有:本轮不动,进 pending
return "purge", "expired"
return "keep", "not_expired"
4.4 容量与回收:全部由逐行 own_bytes 派生
def _own_bytes(row, zero_unit_is_free):
if zero_unit_is_free and row["bytes_per_block"] == 0:
return 0
return row["blocks_written"] * row["bytes_per_block"]
used_by_vol[vol] += size # 不再 capacity//2
after_by_vol[vol] += size if verdict != "purge" # 不再 used - reclaim
reclaim = min(max(min_reclaim_bytes, reclaimable), reclaimable)
refcount[snap_id] = usage_blocks[vol] * row["blocks_shared"] # 乘法
4.5 健康检查:独立重算再逐字段对照
关键设计:health 不 import reaper.plan,也不复用 inventory.load_snapshots,
自己用 _read_tsv() 从事实文件起步重算一遍,再和报告深比较。
def check(report, conf):
issues = [f for f in FIELDS if f not in report] # 判据非空
exp = _expected(conf) # 独立重算
for key in FIELDS:
_walk(key, exp[key], report[key], issues) # dict 递归
for desc, passed in _invariants(report): # 硬约束
if not passed: issues.append(desc)
return issues
退出码分层:0 干净 / 2 有真问题或拿不到事实 / 3 契约不可用或口径非法。
五、验证
docker compose up -d --build
docker compose exec -T reaper bash
bash ops/verify.sh ; echo "verify rc=$?" # PASS=40 FAIL=0 / RESULT: OK / rc=0
bash ops/repro.sh ; echo "repro rc=$?" # RESULT: OK / probe rc=0 / rc=0
python3 -m app.run && python3 -m app.health ; echo "rc=$?" # degraded=false / rc=0
修好后的关键数字:
counts {"kept":4,"pending":0,"purged":3,"rejected":2,"total":9}
purged ["snap-a2","snap-a4","snap-b3"]
pending []
reclaim 184549376
volumes.vol-a used=469762048 reclaim=184549376 after=285212672
volumes.vol-b used=234881024 after=234881024 # b1/b3 零单元不占容量
volumes.vol-c used=536870912 after=536870912 # 两份 reject 占容量但不参与剩余
usage_blocks {"vol-a":208,"vol-b":680,"vol-c":0}
usage_blocks_effective {"vol-a":60,"vol-b":200,"vol-c":0}
refcount_warn.snap-b2 136000 # = 680 × 200
负向验证:闸门真的有牙
ops/negative.sh 基于问题态源码文本硬编码替换,修好后锚点消失会大量 FAIL(脚本与实现耦合,
不代表判定失败)。改为按语义重写负向验证(脚本随分支提交在 scenario/<分支>/notes/):
== 回滚单个题眼 ==
N1 expires_at=0 当纪元零点 probe rc=1 health rc=2 转红
N2 保留窗口当过期限 probe rc=1 health rc=2 转红
N5 after 用 used-reclaim probe rc=1 health rc=2 转红
N6 引用计数退回加法 probe rc=1 health rc=2 转红
N7 按 id 去重 probe rc=1 health rc=2 转红
== 篡改报告,健康检查必须 rc=2 ==
usage_blocks_effective 改 / after_bytes 改 / pending 塞 id / ok 谎报 / purged 少一条 → 全部 rc=2
再加两个构造事实的语义验证(这份清单测不出来):
(1) 造一行「已过期 + holder 非空」→ pending=['snap-h1'],不清 OK
(2) 造 bytes_per_block=0 且 blocks_written=256 → vol-b.used_bytes=0 OK
(3) 造两份共享块 480 的快照 → refcount=480×480=230400 OK
六、经验与避坑
0在时间戳语义里通常是「未设置」,不是「纪元零点」。任何if x == 0的特殊分支, 都要保证每个分支都有返回值;只给一半分支return是这类 bug 的标准形态。SNAP_*_RETENTION*这类"窗口/宽限"参数不是判定阈值。改代码前先把契约文字读一遍,docs/*.md写的是唯一权威口径。min/max反了不会报错,只会静默取错值。涉及「下限+上限」时写成min(max(floor, candidate), candidate)是唯一安全形式;写完顺手问一句"这两个参数谁是上限"。- 同一业务 id 多行不等于重复数据。上游天然会重复提交;
reject/duplicate_id是 裁决结论,不是读取层可以提前去重的东西。去重会同时污染total、rejected、pending。 min(capacity//2, ...)、used - reclaim这类"看起来合理"的表达式是重灾区。 容量这类强约束字段,一律由最细粒度事实(逐行own_bytes)求和派生,禁止用中间量反推。- 量纲/算子错误比数值错误更隐蔽。
blocks_shared + usage_blocks得出的数字"像样", 但契约要求乘法。写引用计数时先问:「谁引用谁?是这一份还是整卷?」 bytes_per_block=0的"零单元" 是容量单位标记(稀疏/占位),blocks_written不占容量; 但共享块引用关系与单元大小无关,仍全额计入usage_blocks。这两件事不能混。degraded=false可能只代表"没检查"。健康检查的空判据表(BAD_FIELDS = ())是 最危险的假绿:for循环一次不跑,issues永远空。上线判据表时要反向验证: 故意篡改报告,看它会不会转红 —— 不会转红的健康检查等于没有。- 健康检查绝不复用被测代码。我第一版
health.py调plan()重算再比较, 回滚expires_at=0后依旧degraded=false rc=0—— 重算和被测代码犯同一个 bug,自然"对得上"。 复用inventory.load_snapshots()又把「按 id 去重」的 bug 一起复用。 健康检查的全部价值来自独立实现,代码重复是必要成本。 - fail-closed vs fail-open:契约读不到 →
3、报告读不到 →2,绝不回退内置默认值。except OSError: pass是"静默降级成最宽松口径"的经典写法。 yes/no存成字符串后"no"是真值。env 里的布尔量必须在加载层就转成bool。- Docker 实操:
docker compose exec在 pipeline 里必须加-T,否则the input device is not a TTY;app/是COPY进镜像的,改完要up -d --build,restart没用;__pycache__会掩盖改动,手动跑前先清理;- 容器
/tmp在up --build重建后会清空,拷进去的验证脚本要重拷; - 用容器内的"备份还原"脚本(如
ops/negative.sh的reset()从$ROOT/app备份) 一旦中途失败,会把脏文件当基线反复回滚;出怪结果时docker compose down && up -d --build重建最快,别在原地 debug 被污染的状态。
- 区分「等价变换」和「闸门漏洞」。
zero_unit_is_free and bytes_per_block == 0 → 0这个前置判断去掉后,blocks_written * 0本来就是 0,探针照样绿 —— 这不是漏洞。 想验证零单元语义,必须构造bytes_per_block=0 且 blocks_written > 0的行。 同理本题清单里没有「过期 + 持有」的行,snap-b2被清不会产生差异, held 语义也得构造数据才能验证。 repro.sh退出码 = 探针退出码,问题态1是预期信号。用; echo rc=$?接住, 用&&会把后面所有步骤短路掉。
七、可复现命令速查
git checkout 2026-10-18-1000
cd scenario/2026-10-18-1000
docker compose up -d --build
docker compose exec -T reaper bash -c 'bash ops/verify.sh; echo rc=$?' # PASS=40 FAIL=0 rc=0
docker compose exec -T reaper bash -c 'bash ops/repro.sh; echo rc=$?' # RESULT: OK rc=0
docker compose exec -T reaper bash -c 'python3 -m app.run && python3 -m app.health' # rc=0
# 负向 + 语义验证(脚本随分支提交)
docker compose exec -T reaper bash -lc '
rm -rf /tmp/app.golden && cp -r /srv/app /tmp/app.golden
find /tmp/app.golden -name __pycache__ -prune -exec rm -rf {} + 2>/dev/null
python3 -m app.run >/dev/null
python3 notes/check_semantics.py ; echo "semantics rc=$?"
python3 notes/check_negative.py ; echo "negative rc=$?"
'
完整操作记录(含每一步真实输出、报错信息、踩坑)见
scenario/2026-10-18-1000/SOLUTION.md。
