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

快照空间清理执行器排障实录: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执行成功执行成功 ≠ 结果正确

真实现象:

  1. 完全没设过期时间(expires_at=0)的 3 张快照被当成「1970 年就过期了」清掉;
  2. 仍被进程持有(holder 非空)的快照被记进 pending 甚至 purged;
  3. 同一 snap_id 两行被合并成一行,counts.rejected 恒 0;
  4. 容量一个都没有按快照算,used_bytes 是 capacity // 2 占位;
  5. 汇总 reclaim 恒 0,而逐卷 reclaim 加起来 400 多 MiB;
  6. refcount_warn 写成了「共享块数 + 卷引用计数」(加法),契约要的是乘法;
  7. 契约读不到就静默回退内置默认值,契约写了非法值(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

六、经验与避坑

  1. 0 在时间戳语义里通常是「未设置」,不是「纪元零点」。任何 if x == 0 的特殊分支, 都要保证每个分支都有返回值;只给一半分支 return 是这类 bug 的标准形态。
  2. SNAP_*_RETENTION* 这类"窗口/宽限"参数不是判定阈值。改代码前先把契约文字读一遍, docs/*.md 写的是唯一权威口径。
  3. min / max 反了不会报错,只会静默取错值。涉及「下限+上限」时写成 min(max(floor, candidate), candidate) 是唯一安全形式;写完顺手问一句"这两个参数谁是上限"。
  4. 同一业务 id 多行不等于重复数据。上游天然会重复提交;reject/duplicate_id 是 裁决结论,不是读取层可以提前去重的东西。去重会同时污染 total、rejected、pending。
  5. min(capacity//2, ...)、used - reclaim 这类"看起来合理"的表达式是重灾区。 容量这类强约束字段,一律由最细粒度事实(逐行 own_bytes)求和派生,禁止用中间量反推。
  6. 量纲/算子错误比数值错误更隐蔽。blocks_shared + usage_blocks 得出的数字"像样", 但契约要求乘法。写引用计数时先问:「谁引用谁?是这一份还是整卷?」
  7. bytes_per_block=0 的"零单元" 是容量单位标记(稀疏/占位),blocks_written 不占容量; 但共享块引用关系与单元大小无关,仍全额计入 usage_blocks。这两件事不能混。
  8. degraded=false 可能只代表"没检查"。健康检查的空判据表(BAD_FIELDS = ())是 最危险的假绿:for 循环一次不跑,issues 永远空。上线判据表时要反向验证: 故意篡改报告,看它会不会转红 —— 不会转红的健康检查等于没有。
  9. 健康检查绝不复用被测代码。我第一版 health.py 调 plan() 重算再比较, 回滚 expires_at=0 后依旧 degraded=false rc=0 —— 重算和被测代码犯同一个 bug,自然"对得上"。 复用 inventory.load_snapshots() 又把「按 id 去重」的 bug 一起复用。 健康检查的全部价值来自独立实现,代码重复是必要成本。
  10. fail-closed vs fail-open:契约读不到 → 3、报告读不到 → 2,绝不回退内置默认值。 except OSError: pass 是"静默降级成最宽松口径"的经典写法。
  11. yes/no 存成字符串后 "no" 是真值。env 里的布尔量必须在加载层就转成 bool。
  12. 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 被污染的状态。
  13. 区分「等价变换」和「闸门漏洞」。zero_unit_is_free and bytes_per_block == 0 → 0 这个前置判断去掉后,blocks_written * 0 本来就是 0,探针照样绿 —— 这不是漏洞。 想验证零单元语义,必须构造 bytes_per_block=0 且 blocks_written > 0 的行。 同理本题清单里没有「过期 + 持有」的行,snap-b2 被清不会产生差异, held 语义也得构造数据才能验证。
  14. 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。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-18-1000-snapshot-reaper-zero-expiry-refcount-fake-green

分享这篇文章