注册请求一条都没按契约带上参数、还派发给了不可用节点,而自述报告/健康检查/退出码全绿(注册参数名写死 + state!=up 节点被派发 + 年龄被判定结果改写 + 空判据表 BAD_FIELDS=() 假绿健康检查)
技术栈:Python 3.11 标准库 / Docker / 契约驱动判定 / 报告字段结构(嵌套 vs 摊平) 关键词:注册参数名写死、
GATEWAY_REGISTER_QUERY_ARG、报告字段 keys 不在值、state=up可用节点、state=draining节点被派发、startswith/resolve_addr当相等用、 年龄age_s被判定结果改写(age_s * alive)、残留 orphan 账目archived + kept == entries、 容量越界整轮失败capacity_exceeded、空判据表BAD_FIELDS = ()、退出码恒 0、 外部独立探针、自述字段自证、fail-closed 退出码分层 0/2/3
一、问题背景
app/ 是一个物料同步网关(sync gateway):一轮里读「代理池节点清单」+「本轮注册请求」,
把请求派发给可用节点,再把「注册表快照」里的残留条目按策略处置,最后出一份回执报告。
契约 conf/gateway.env(只读考卷)规定:
GATEWAY_REGISTER_QUERY_ARG=address # 注册接口要求带的「可选注册参数」的名字
GATEWAY_NODES_FILE=conf/nodes.json
GATEWAY_REGISTER_KEY=instance
GATEWAY_ORPHAN_AFTER_S=600 # 年龄超过它就算残留
GATEWAY_STALE_POLICY=archive # 残留条目归档
代理池 conf/nodes.json 里 4 个节点只有 3 个 state=up,
最后一个是 {"id":"node-x7","addr":"10.8.0.17","state":"draining"}。
现象:一轮跑下来四个绿灯全亮 ——
- 自述报告
{"ok": true, "counts": {"dispatched": 5, "failed": 0, "param_ok": 0}} - 健康检查
{"contract_available": true, "degraded": false, "issues": []}(rc=0) - 脚本退出码
0
但外部独立探针 client/sync_probe.py(只读契约 + 节点清单 + 注册表 + 请求 + 报告,
不读任何自述字段)报出真相:
不一致:reg-01:期望带上注册参数 address 实际未带上
不一致:reg-02 … reg-05 同上
不一致:reg-01:期望派发节点 'node-a0' 实际 '10.8.0.17'
不一致:inst-001.age_s:期望 720 实际 0
不一致:汇总 counts.param_ok:期望 5 实际 0
RESULT: BAD(不一致 8 条) rc=1
也就是说:5 条注册请求一条都没按契约带上参数、reg-01 被派发给了 state=draining
的不可用节点、inst-001 明明是 720 秒前注册的年龄却被写成 0 —— 而没有一盏灯亮。
二、复现步骤
环境基于 python:3.11-slim-bookworm,纯标准库、无外网依赖。
cd scenario/2026-10-15-0000
docker compose up -d --build
docker compose exec -T gateway bash # 非交互必须加 -T,否则 TTY 报错
# 容器内
cd /srv
bash ops/repro.sh ; echo "rc=$?" # -> RESULT: BAD(不一致 8 条),rc=1
bash ops/verify.sh ; echo "rc=$?" # -> PASS=20 FAIL=14 / RESULT: BAD
python3 -m app.health # -> {"degraded":false,"issues":[]},rc=0(假绿)
坑:
ops/repro.sh以外部探针的退出码为自己的退出码,问题态返回1—— 这是预期的复现信号,不是环境坏了。用; echo "rc=$?"接住,别用&&, 否则脚本在 rc=1 处就中断,后面的步骤一条都不跑。
无 docker 也可纯标准库直跑:
export SCENARIO_ROOT="$PWD" CONF_DIR="$PWD/conf" PYTHONPATH="$PWD"
bash ops/repro.sh; echo "rc=$?"
三、根本原因
四个缺陷互相掩护(还附带一个容量口径缺口):
① 注册参数名写死 + 拿「名字」当「值」比(题眼)
app/config.py 把契约参数名塞进了一个容易误读的结构:
return {
"register_query": {"address": conf["query_arg"]}, # 值 = "address"(这是个「名字」)
...
}
app/syncer.py 却拿它去和请求实际带上来的值比:
param = req.get("address") # "10.7.1.11"
carries = param == local["register_query"]["address"] # "10.7.1.11" == "address" → 恒 False
参数名(address)和参数值(10.7.1.11)是两件事,
拿名字去比永远不相等 → carries_register_param 恒 False → counts.param_ok 恒 0。
迷惑点:报告里顶层
register_query的键恰好是address(因为契约query_arg就叫address), 所以「顶层摘要」看着完全正确,问题只藏在逐条carries_register_param里。
② 报告值被一行代码直接改写 + 不可用节点混进现场
nodes = pool_up = pool.up_nodes(pool_def) # 这里其实是对的(只留 up)
...
dispatches[0]["node"] = pool.resolve_addr(pool_def, "node-x7") # ← 恶魔这一行
resolve_addr 查的是全池(含 draining 的 node-x7),所以能查到地址 10.8.0.17。
这行把 reg-01 的 node 从 id node-a0 改成了地址,造成:
state != "up"的节点「出现在」派发里;- 同一个字段一半是节点 id(
node-b5)、一半是地址(10.8.0.17) —— 这就是「有人把值改写过」的指纹,看到字段含义不统一就该怀疑有赋值语句在作祟。
③ 年龄被「判定结果」改写(题眼)
alive = 0 if entry["age_s"] > conf["orphan_after_s"] else 1
row["age_s"] = int(entry["age_s"]) * alive # 720 * 0 = 0
「它是不是残留」是判定结果,却被乘回了年龄本身。
inst-001 的 age_s: 720 被写成 0。
静默程度极高:返回的还是整数、量纲没变、不抛异常、自检也不看这个字段。 「因为判断它过期了,就把它的年龄改成 0」 是典型的把「结论」写回「事实」。
④ 账目与逐条裁决不同源
accounts.archived 与逐条 verdict 是两条独立计算路径,缺了
archived + kept == entries 这条自洽约束,任一侧改动就会对不上账。
⑤ 空判据表 + 退出码恒 0 = 「给自己发体检合格证」
BAD_FIELDS = () # for 一次都不循环 → issues 恒空
def check():
issues = []
return issues, {"contract_available": True}, (EXIT_PROBLEM if issues else EXIT_OK)
def main():
...
return EXIT_OK # 无条件 return 0
判据表为空 → issues 恒空 → degraded 恒 false;
main() 无条件 return EXIT_OK,连「契约不可用」「拿不到报告」都判健康。
代码看着像有检查,其实是空转。
注意
syncer.py里本来就有orphan_after_s_matches_contract这个算出来的字段, 却没有任何人看。「有字段」不等于「有闸门」。
四、解决办法
只改
app/下三个文件。conf/(只读考卷)、docs/、client/(外部验收口径)、input/、ops/verify.sh、ops/negative.sh一字未动 —— 改契约/探针让判定变绿 = 造假。
① app/config.py:把两件事分开命名
return {
"register_query_arg": conf["query_arg"], # 注册参数的「名字」,来自契约字段的值
"register_count": None, # 本轮注册条数(与参数名是两件事)
"orphan_after_s": conf["orphan_after_s"],
"stale_policy": conf["stale_policy"],
}
② app/syncer.py:五处改动
# (a) 节点清单路径来自契约,不在代码里写死
pool_def = pool.load_pool(conf["nodes_file"])
up_nodes = pool.up_nodes(pool_def) # 只有 state == "up" 的才算可用节点
cap = pool.capacity(pool_def)
regs = _read_jsonl(paths["registrations"])
over = len(regs) > cap # (b) 本轮注册数越界 → 整轮失败
arg_name = local["register_query_arg"] # (c) 参数名来自契约
...
param = req.get(arg_name) # 按契约要求的名字,取请求实带的值
dispatches.append({
"node": node["id"], # node 恒为节点 id
"node_addr": node.get("addr"), # 地址放独立字段,不覆盖 node
"register_query": {arg_name: param},
"carries_register_param": param is not None,
})
# 【删掉】dispatches[0]["node"] = pool.resolve_addr(pool_def, "node-x7")
if over: # (d) 越界时整轮 dispatched=false
for row in dispatches:
row["dispatched"] = False
row["reason"] = "capacity_exceeded"
# (e) 年龄原样保留,只拿它做一次残留判定
age = int(entry["age_s"])
orphan = registry.is_orphan(age, conf["orphan_after_s"])
verdict, reason = registry.decision({"orphan": orphan}, local["stale_policy"])
# 账目由逐条 verdict 统计而来(同源),并加自洽断言
assert accounts["archived"] + accounts["kept"] == accounts["entries"]
report["ok"] = counts["failed"] == 0 and not over
report["register_query"] = {arg_name: len(regs)} # 顶层键 = 契约要求的参数名
③ app/health.py:给闸门装上牙
BAD_FIELDS换成非空判据表(本题 8 条),每条对应一个外部能独立复核的事实;- 另写一遍
_reference()(故意不复用syncer,避免「自己治自己」), 按契约独立重算:可用节点 / 容量 / 逐条派发落点 / 注册参数 / 处置 / 账目, 再与报告逐条比对; - 判据落在事实层:
conf/gateway.env+conf/nodes.json+conf/registry.jsoninput/registrations.jsonl+state/sync.json(报告只被对照,不作依据);
- fail-closed 退出码分层:
- 契约读不到 /
GATEWAY_STALE_POLICY非法 / 数值字段缺失 → 3 - 拿不到事实(节点清单 / 注册表 / 请求 / 报告缺失)→ 2(「判不了」≠「没问题」)
- 有真问题 → 2;干净一轮 → 0
- 契约读不到 /
main()返回check()算出的code,不再无条件return EXIT_OK。
五、验证
容器内
docker compose exec -T gateway bash -c 'cd /srv && bash ops/repro.sh; echo rc=$?' # RESULT: OK, rc=0
docker compose exec -T gateway bash -c 'cd /srv && bash ops/verify.sh; echo rc=$?' # PASS=34 FAIL=0
docker compose exec -T gateway bash -c 'cd /srv && bash ops/negative.sh; echo rc=$?' # 14 PASS / 0 FAIL
修复后一轮关键数值:
{"counts": {"dispatched": 5, "failed": 0, "param_ok": 5, "total": 5},
"register_query": {"address": 5},
"up_nodes": ["node-a0","node-b5","node-cc"],
"capacity": {"allowed": 192, "used": 5, "over": false},
"accounts": {"archived": 1, "capacity": 192, "entries": 2, "kept": 1},
"ok": true}
health 由空壳 {"degraded":false,"issues":[]}(rc=0)变为带事实可复核:
{"archived":1,"capacity":192,"capacity_allowed":192,"contract_available":true,
"degraded":false,"dispatched":5,"entries":2,"failed":0,"issues":[],
"kept":1,"orphan_after_s":600,"over_capacity":false,"param_ok":5,
"register_key":"instance","register_query_arg":"address","stale_policy":"archive",
"total":5,"up_nodes":["node-a0","node-b5","node-cc"]} rc=0
从零重建复跑(证明不是靠 cp 蒙的)
docker compose down -v && docker compose up -d --build
docker compose exec -T gateway bash -c 'cd /srv && grep -n register_query_arg app/syncer.py'
# -> 60: arg_name = local["register_query_arg"] (容器里跑的就是手写修复版)
docker compose exec -T gateway bash -c 'cd /srv && bash ops/verify.sh' # PASS=34 FAIL=0
闸门「有牙」的定点实证
| 实验 | 动作 | 期望 | 实际 |
|---|---|---|---|
| T1 | 契约改名 GATEWAY_REGISTER_QUERY_ARG=endpoint(请求不带该参数) | 转红 | 探针 rc=1(5 条不一致)· health rc=2 |
| T2 | 篡改报告 inst-001.age_s=0 | rc=2 | rc=2,['inst-001.age_s:期望 720 实际 0'] |
| T3 | 删掉 state/sync.json(拿不到事实) | rc=2 | rc=2,拿不到本轮报告… |
| T4 | CONF_DIR=/tmp/nope(契约不可用) | rc=3 | health rc=3 · syncer rc=3 |
| T5 | GATEWAY_STALE_POLICY=loose(口径非法) | rc=3 | rc=3 |
| T6 | 篡改报告 counts.param_ok=999 | rc=2 | rc=2,汇总 counts.param_ok:期望 5 实际 999 |
| T7 | 篡改报告 up_nodes 塞入 node-x7 | rc=2 | rc=2,可用节点:期望 [...] 实际 [...,'node-x7'] |
T2/T6/T7 最有价值:事实层没变、只篡改报告,health 立刻转红 —— 证明判据落在事实层,「自述字段自证」被彻底杜绝。
提交前复核
git diff --name-only -- scenario/2026-10-15-0000/conf scenario/2026-10-15-0000/docs \
scenario/2026-10-15-0000/client scenario/2026-10-15-0000/input \
scenario/2026-10-15-0000/ops/verify.sh scenario/2026-10-15-0000/ops/negative.sh
# -> 输出为空(验收口径零改动)
六、经验与避坑
可复用的排查思路
- 报告里的「名字」和「值」必须分清:契约
GATEWAY_REGISTER_QUERY_ARG=address里的address是参数名,请求带上来的是值(10.7.1.11)。 拿名字比值必然恒假。问一句:我要比的是「参数名对不对」,还是「值带没带上?名字该从哪读?」 - 同一个字段出现两种含义,就是「值被改写」的指纹:报告里
node一半是 id、一半是地址 —— 顺着 grep 找赋值语句,一行就能揪出来。 - 「判定结果」绝不能写回「事实」:
age_s * alive把「是不是残留」乘回年龄, 像「因为判断它过期了,就把它的年龄改成 0」。凡是「结论字段」与「事实字段」同名前缀 (age_s/alive/orphan),都要检查有没有交叉赋值。 - 自检口诀
archived + kept == entries:账目必须与逐条裁决同源, 并把它写成一条运行期断言,账目一错立刻暴露。 - 绿色信号分三类:
进程没崩(弱)自己写的自述(无判别力)能被外部按契约独立复核的事实(强)
ok:true 写死、BAD_FIELDS = () 空判据表、main() 无条件 return 0、except: return EXIT_OK
—— 这类「自述字段自证」比没有检查更危险,因为它静默地骗人。
6. 健康检查必须落在事实层:读契约 + 节点清单 + 注册表 + 请求 + 报告,
并且在 health 里另写一遍重算逻辑(不复用 syncer),避免「自己治自己」。
7. 「判不了」≠「没问题」:契约不可用、拿不到事实都要判不通过(fail-closed,退出码分层)。
8. 只修题眼不够:不修健康检查,下次同类错误依然四个绿灯全亮 ——
「修好了 bug」只解决了这一次,「修好了闸门」才解决了下一次。
9. 容量口径要一致:capacity.allowed = 可用节点 capacity 之和;
本轮注册数越界必须整轮失败,不能「派一部分就算过」。
操作层面的坑(本题实测)
docker compose exec在非交互环境必须加-T,否则the input device is not a TTY。ops/repro.sh以探针退出码为自身退出码,问题态返回1—— 预期信号,用; echo rc=$?接住,别用&&。- 只改
app/,别碰考卷:conf/、docs/、client/、input/、ops/verify.sh、ops/negative.sh是只读考卷与验收口径。 ops/negative.sh收尾会apply_reference(),把app/切成参考解 —— 跑完后容器里的app/已不是你的手写版(宿主机文件不受影响); 想继续验证手写版需重新docker compose cp并清__pycache__。ops/verify.sh --restore会把代码切回问题态(实测 PASS 从 34 掉回 20/Fail 14), 它切的是代码不是数据,切完记得同步回手写修复。app/是COPY进镜像的、compose 无 bind mount: 直接改宿主机文件容器看不到,必须docker compose cp或重建镜像, 否则出现「容器内全绿、宿主机git diff却是空的」这种极具迷惑性的现象。- 改完
app/*.py必须清__pycache__(ops/lib.sh::clean_pyc已内置), 旧字节码会把「已改」掩盖成「没改」。 - 临时改考卷做对抗实验后务必还原:本次做 T1/T5 时改过
conf/gateway.env, 中途脚本中断导致endpoint残留,后续实验全部「带毒」。 还原:git checkout -- scenario/2026-10-15-0000/conf/, 再用git diff --stat -- conf/复核为空。
自查口诀
我比的是参数名还是参数值?这个名字该从契约的哪个字段读?
报告里那个字段是 id 还是地址(同一字段两种含义 = 有人改写过)?
age_s 是事实还是结论?archived + kept == entries 成立吗?
判据表是不是空的?健康检查读的是事实还是自述?
拿不到数据时我判通过还是不通过?把题眼回滚之后,我的闸门会不会转红?
同类影响面
- 一切契约驱动的参数/字段映射(把「字段名」当「字段值」用、env 变量名与值混用、 报告 key 与 value 语义错位、i18n 键与文案混用);
- 一切**「判定结果写回事实」**的实现(
age * alive、size * exists、 状态机里把「结论」覆盖到「原始输入」、ORM 里把计算字段存回源字段); - 一切同一字段承载多种含义的接口(
node一会儿 id 一会儿地址、owner一会儿 uid 一会儿用户名、ts一会儿秒一会儿毫秒); - 一切候选集/可用性口径错的调度(把不健康节点拉进轮询、
state != up也派发、startswith当相等用导致node-cc撞上node-x7前缀); - 一切账目与逐条明细不同源的汇总(
archived/kept、deleted/remaining、 分页total与items不一致); - 一切**「自述字段自证 + 空判据表 + 退出码恒 0」的假绿健康检查与巡检脚本**;
- 一切**「读不到配置就静默回退默认值」**的配置加载(该 fail-closed 时就要拒绝启动 / 拒绝派发)。
