负载均衡健康检查假绿排查:连续失败算成累计失败、恢复条件写死、失败率分母错、健康检查空判据表
日期:2026-10-23 分支:
2026-10-23-1000(第 141 题) 技术栈:Python 3.11 标准库 / Docker Compose / 外部独立探针(不导入被测包) 关键词:负载均衡 健康检查 假绿 fail-closed 连续失败 累计失败 失败率分母 SLA 可用性缺口 摘流 恢复条件 退出码分层 空判据表 health check 全绿但有问题
一、问题背景
边缘网关的出站探测判定器(lb-probe-balancer)每轮读三份输入:成员池、探测序列、SLA 目标表,
输出「哪些成员确诊坏掉 / 疑似坏掉 / 已恢复 / 可接流量 / 距 SLA 还差多久」。
上线后值班同学报了三件怪事:
- 某成员连续失败好几次都没被摘流,流量一直往上打;
- 已恢复的成员一直留在人工摘流名单里,容量白空着;
- 失败的成员有时反而「看起来更健康」—— 打得越少,越不容易被判坏。
而所有自动检查都是绿的:python3 -m app.run 退出码 0、python3 -m app.health 输出
{"degraded": false, "issues": []} 退出码 0。四个绿灯没有一件指向真相。
二、复现步骤
git clone <repo> && cd <repo>/scenario/2026-10-23-1000
docker compose up -d --build
docker compose exec -T lbprobe bash -lc 'cd /srv && bash ops/repro.sh; echo repro_rc=$?'
docker compose exec -T lbprobe bash -lc 'cd /srv && bash ops/verify.sh' # 问题态 PASS=40 FAIL=30
docker compose exec -T lbprobe bash -lc 'cd /srv && bash ops/negative.sh' # 问题态 NPASS=7 NFAIL=0
问题态关键输出:
{"confirmed_bad": ["edge-d"], "suspect_bad": [], "eligible": ["edge-a", "edge-b"], "sla_gaps": 4}
run rc=0
RESULT: BAD(不一致 9 条)
- counts.members: 期望 5 实际 2
- counts.fail: 期望 3 实际 2
- counts.withdrawn_invalid: 期望 1 实际 0
- members.edge-d.fail_run: 期望 2 实际 1
- members.edge-f.recovered: 期望 False 实际 True
- withdrawn: 期望 ['edge-f'] 实际 ['edge-c', 'edge-f', 'edge-ghost']
{"degraded": false, "issues": []}
health rc=0
三、根本原因
判定逻辑(app/judge.py):
| # | 错误写法 | 正确口径 |
|---|---|---|
| 1 | st["fail_run"] = 0 之后紧跟 st["fail_run"] += 1,连续失败永远是 1 | 成功清零、失败 += 1 |
| 2 | confirmed = fail > 0 and fail_run > 0 | fail_run >= FAIL_MIN_PROBES |
| 3 | recovered = tail_ok >= 1 | tail_ok >= RECOVER_AFTER_CONSECUTIVE_OK |
| 4 | fail_rate_ppm = fail*1e6 // max(denom_min, fail_count) | 分母取 max(RATE_DENOMINATOR_MIN, 该成员尝试次数) |
| 5 | withdrawn = sorted(set(withdrawn_raw)),池外名字也混进去 | 只保留池内成员,池外计 withdrawn_invalid |
| 6 | counts.members = len(eligible) | 池中成员数(含 status=down) |
| 7 | 未知成员的尝试不计入 counts.fail | 记 fail + unknown_members |
| 8 | sla_rows 先 ok*1e6//attempts 再乘回 attempts//total,且不排序 | uptime = ok*1e6 // probes_effective,gap>0 才上报,按 member_id 升序 |
假绿机制三处:
app/service.pymain()永远return EXIT_OK,有确诊坏成员 / SLA 缺口也不返回 2;app/contract.py缺配置回退内置DEFAULTS、锚点读不到猜DEFAULT_ANCHOR—— 配置坏了照样「跑通」;app/health.pyBAD_FIELDS = ()是空元组,for field in BAD_FIELDS一次都不执行, 而且compare()的返回值被丢弃,最后degraded = len(issues) > 0恒为False—— 判据表为空 = 没检查 = 全绿。
第 3 条能解释「已恢复的成员一直留在名单里」(其实反过来:没恢复的被误判成恢复了), 第 4 条能解释「打得越少越不容易被判坏」(分母用了失败次数,次数少反而分母小、失败率虚高或偏低), 第 9/10/11 条能解释「四个绿灯」。
四、解决办法
只改 app/(考卷目录 conf/ docs/ input/ state/ client/ 一字不动):
contract.py去掉DEFAULTS/DEFAULT_ANCHOR,缺 key / 非整数 / 越界 / 未知 key / 锚点不可读 一律抛ContractError(fail closed,退出码 3)。judge.py修表里 1~8 全部口径。service.pytry: report = build_report() except (ContractError, InputError) as exc: return EXIT_CANNOT_JUDGE # 3:判不了 ≠ 没问题 ... return EXIT_ISSUES if has_issue(report) else EXIT_OK # 2 / 0health.pyBAD_FIELDS填真实字段清单;main()真的执行issues += compare(rep, reference(contract));- 退出码 0/2/3 分层,并在输出里带
checks(判据条数 > 0)。
五、验证
docker compose up -d --build --force-recreate
docker compose exec -T lbprobe bash -lc 'cd /srv && bash ops/repro.sh' # probe rc=0, run rc=2, health rc=0
docker compose exec -T lbprobe bash -lc 'cd /srv && bash ops/verify.sh' # RESULT: OK(PASS=70 FAIL=0)
docker compose exec -T lbprobe bash -lc 'cd /srv && bash ops/negative.sh' # RESULT: OK(闸门有牙 NPASS=9)
修复后:
{"confirmed_bad": ["edge-d"], "suspect_bad": [], "eligible": ["edge-a", "edge-b", "edge-c"], "sla_gaps": 4}
run rc=2
RESULT: OK
{"degraded": false, "checks": 53, "issues": []}
health rc=0
负向验证(ops/negative.sh)覆盖:探针干净、参考解干净、两处单点回退必红、删锚点必 3、
fail-open 回归必非 0、报告篡改必 2、把 compare() 挖空后同一份坏报告立刻变绿(假绿实证)。
六、经验与避坑
- 「顺序无关」的假绿最危险:
BAD_FIELDS = ()让整段检查变成空循环,程序不报错、不告警、还挺快。 凡是「按清单逐项检查」的代码,都要断言清单非空、并输出检查条数(本题checks)。 - 判不了 ≠ 没问题。配置缺失、锚点读不到、输入跳号,都要有独立退出码(3), 绝不能靠内置默认值「先跑起来」——默认值会把契约漂移变成静默错误。
- 分母选错,指标就变成反指标。失败率的分母必须是「该成员被采样次数(含保底)」, 用失败次数当分母会让「少打多得」的成员看起来更健康。
- 「连续 N 次」这种统计量要显式建模:
fail_run只在成功时清零、失败时 +1, 别在同一段代码里又置零又自增。 - 外部独立探针是唯一可信裁判:
client/probe.py不 import 被测包,自己按契约重算再逐字段对比, 这才让 9 条不一致浮出水面。内部自检和被测代码共享实现时,天然会一起错。 - Docker Compose 复现三连:
app/是COPY进镜像、无 bind mount,改完必须docker compose up -d --build --force-recreate;调试时用docker cp改容器内文件容易和镜像层错位, 出现「宿主机对、容器旧」的诡异现象。 - 变异测试脚本要防脏基线:负向脚本会在源码上做变异,被 SIGKILL 时
trap不执行, 下一次就在变异版本上跑。加一条启动守卫(grep 关键口径),发现是脏基直接 FATAL。 - bash 函数里的
python3 - <<'EOF'会吃掉 stdin(bash 5.2 起 heredoc 走临时文件), 连续调用时第二次拿到空输入,变异静默失效。改成独立.py+sys.argv最稳。 - 考卷里的断言也可能带「问题态口径」:本题
verify.sh的chk A1 0 "$RC"注释写着 「问题态恒 0(假绿)」,修好后必须改成 2,否则永远差一条。看到「永远差一条」先怀疑断言本身。
