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

注册请求一条都没按契约带上参数、还派发给了不可用节点,而自述报告/健康检查/退出码全绿(注册参数名写死 + 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:给闸门装上牙

  1. BAD_FIELDS 换成非空判据表(本题 8 条),每条对应一个外部能独立复核的事实;
  2. 另写一遍 _reference()(故意不复用 syncer,避免「自己治自己」), 按契约独立重算:可用节点 / 容量 / 逐条派发落点 / 注册参数 / 处置 / 账目, 再与报告逐条比对;
  3. 判据落在事实层:conf/gateway.env + conf/nodes.json + conf/registry.json
    • input/registrations.jsonl + state/sync.json(报告只被对照,不作依据);
  4. fail-closed 退出码分层:
    • 契约读不到 / GATEWAY_STALE_POLICY 非法 / 数值字段缺失 → 3
    • 拿不到事实(节点清单 / 注册表 / 请求 / 报告缺失)→ 2(「判不了」≠「没问题」)
    • 有真问题 → 2;干净一轮 → 0
  5. 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=0rc=2rc=2,['inst-001.age_s:期望 720 实际 0']
T3删掉 state/sync.json(拿不到事实)rc=2rc=2,拿不到本轮报告…
T4CONF_DIR=/tmp/nope(契约不可用)rc=3health rc=3 · syncer rc=3
T5GATEWAY_STALE_POLICY=loose(口径非法)rc=3rc=3
T6篡改报告 counts.param_ok=999rc=2rc=2,汇总 counts.param_ok:期望 5 实际 999
T7篡改报告 up_nodes 塞入 node-x7rc=2rc=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
# -> 输出为空(验收口径零改动)

六、经验与避坑

可复用的排查思路

  1. 报告里的「名字」和「值」必须分清:契约 GATEWAY_REGISTER_QUERY_ARG=address 里的 address 是参数名,请求带上来的是值(10.7.1.11)。 拿名字比值必然恒假。问一句:我要比的是「参数名对不对」,还是「值带没带上?名字该从哪读?」
  2. 同一个字段出现两种含义,就是「值被改写」的指纹:报告里 node 一半是 id、一半是地址 —— 顺着 grep 找赋值语句,一行就能揪出来。
  3. 「判定结果」绝不能写回「事实」:age_s * alive 把「是不是残留」乘回年龄, 像「因为判断它过期了,就把它的年龄改成 0」。凡是「结论字段」与「事实字段」同名前缀 (age_s / alive / orphan),都要检查有没有交叉赋值。
  4. 自检口诀 archived + kept == entries:账目必须与逐条裁决同源, 并把它写成一条运行期断言,账目一错立刻暴露。
  5. 绿色信号分三类:
    • 进程没崩(弱)
    • 自己写的自述(无判别力)
    • 能被外部按契约独立复核的事实(强)

ok:true 写死、BAD_FIELDS = () 空判据表、main() 无条件 return 0、except: return EXIT_OK —— 这类「自述字段自证」比没有检查更危险,因为它静默地骗人。 6. 健康检查必须落在事实层:读契约 + 节点清单 + 注册表 + 请求 + 报告, 并且在 health 里另写一遍重算逻辑(不复用 syncer),避免「自己治自己」。 7. 「判不了」≠「没问题」:契约不可用、拿不到事实都要判不通过(fail-closed,退出码分层)。 8. 只修题眼不够:不修健康检查,下次同类错误依然四个绿灯全亮 —— 「修好了 bug」只解决了这一次,「修好了闸门」才解决了下一次。 9. 容量口径要一致:capacity.allowed = 可用节点 capacity 之和; 本轮注册数越界必须整轮失败,不能「派一部分就算过」。

操作层面的坑(本题实测)

  1. docker compose exec 在非交互环境必须加 -T,否则 the input device is not a TTY。
  2. ops/repro.sh 以探针退出码为自身退出码,问题态返回 1 —— 预期信号,用 ; echo rc=$? 接住,别用 &&。
  3. 只改 app/,别碰考卷:conf/、docs/、client/、input/、 ops/verify.sh、ops/negative.sh 是只读考卷与验收口径。
  4. ops/negative.sh 收尾会 apply_reference(),把 app/ 切成参考解 —— 跑完后容器里的 app/ 已不是你的手写版(宿主机文件不受影响); 想继续验证手写版需重新 docker compose cp 并清 __pycache__。
  5. ops/verify.sh --restore 会把代码切回问题态(实测 PASS 从 34 掉回 20/Fail 14), 它切的是代码不是数据,切完记得同步回手写修复。
  6. app/ 是 COPY 进镜像的、compose 无 bind mount: 直接改宿主机文件容器看不到,必须 docker compose cp 或重建镜像, 否则出现「容器内全绿、宿主机 git diff 却是空的」这种极具迷惑性的现象。
  7. 改完 app/*.py 必须清 __pycache__(ops/lib.sh::clean_pyc 已内置), 旧字节码会把「已改」掩盖成「没改」。
  8. 临时改考卷做对抗实验后务必还原:本次做 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 时就要拒绝启动 / 拒绝派发)。

版权声明

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

分享这篇文章