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

负载均衡健康检查假绿排查:连续失败算成累计失败、恢复条件写死、失败率分母错、健康检查空判据表

日期:2026-10-23 分支:2026-10-23-1000(第 141 题) 技术栈:Python 3.11 标准库 / Docker Compose / 外部独立探针(不导入被测包) 关键词:负载均衡 健康检查 假绿 fail-closed 连续失败 累计失败 失败率分母 SLA 可用性缺口 摘流 恢复条件 退出码分层 空判据表 health check 全绿但有问题

一、问题背景

边缘网关的出站探测判定器(lb-probe-balancer)每轮读三份输入:成员池、探测序列、SLA 目标表, 输出「哪些成员确诊坏掉 / 疑似坏掉 / 已恢复 / 可接流量 / 距 SLA 还差多久」。

上线后值班同学报了三件怪事:

  1. 某成员连续失败好几次都没被摘流,流量一直往上打;
  2. 已恢复的成员一直留在人工摘流名单里,容量白空着;
  3. 失败的成员有时反而「看起来更健康」—— 打得越少,越不容易被判坏。

而所有自动检查都是绿的: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):

#错误写法正确口径
1st["fail_run"] = 0 之后紧跟 st["fail_run"] += 1,连续失败永远是 1成功清零、失败 += 1
2confirmed = fail > 0 and fail_run > 0fail_run >= FAIL_MIN_PROBES
3recovered = tail_ok >= 1tail_ok >= RECOVER_AFTER_CONSECUTIVE_OK
4fail_rate_ppm = fail*1e6 // max(denom_min, fail_count)分母取 max(RATE_DENOMINATOR_MIN, 该成员尝试次数)
5withdrawn = sorted(set(withdrawn_raw)),池外名字也混进去只保留池内成员,池外计 withdrawn_invalid
6counts.members = len(eligible)池中成员数(含 status=down)
7未知成员的尝试不计入 counts.fail记 fail + unknown_members
8sla_rows 先 ok*1e6//attempts 再乘回 attempts//total,且不排序uptime = ok*1e6 // probes_effective,gap>0 才上报,按 member_id 升序

假绿机制三处:

  1. app/service.py main() 永远 return EXIT_OK,有确诊坏成员 / SLA 缺口也不返回 2;
  2. app/contract.py 缺配置回退内置 DEFAULTS、锚点读不到猜 DEFAULT_ANCHOR —— 配置坏了照样「跑通」;
  3. app/health.py BAD_FIELDS = () 是空元组,for field in BAD_FIELDS 一次都不执行, 而且 compare() 的返回值被丢弃,最后 degraded = len(issues) > 0 恒为 False —— 判据表为空 = 没检查 = 全绿。

第 3 条能解释「已恢复的成员一直留在名单里」(其实反过来:没恢复的被误判成恢复了), 第 4 条能解释「打得越少越不容易被判坏」(分母用了失败次数,次数少反而分母小、失败率虚高或偏低), 第 9/10/11 条能解释「四个绿灯」。

四、解决办法

只改 app/(考卷目录 conf/ docs/ input/ state/ client/ 一字不动):

  1. contract.py 去掉 DEFAULTS/DEFAULT_ANCHOR,缺 key / 非整数 / 越界 / 未知 key / 锚点不可读 一律抛 ContractError(fail closed,退出码 3)。
  2. judge.py 修表里 1~8 全部口径。
  3. service.py
    try:
        report = build_report()
    except (ContractError, InputError) as exc:
        return EXIT_CANNOT_JUDGE          # 3:判不了 ≠ 没问题
    ...
    return EXIT_ISSUES if has_issue(report) else EXIT_OK   # 2 / 0
    
  4. health.py
    • BAD_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() 挖空后同一份坏报告立刻变绿(假绿实证)。

六、经验与避坑

  1. 「顺序无关」的假绿最危险:BAD_FIELDS = () 让整段检查变成空循环,程序不报错、不告警、还挺快。 凡是「按清单逐项检查」的代码,都要断言清单非空、并输出检查条数(本题 checks)。
  2. 判不了 ≠ 没问题。配置缺失、锚点读不到、输入跳号,都要有独立退出码(3), 绝不能靠内置默认值「先跑起来」——默认值会把契约漂移变成静默错误。
  3. 分母选错,指标就变成反指标。失败率的分母必须是「该成员被采样次数(含保底)」, 用失败次数当分母会让「少打多得」的成员看起来更健康。
  4. 「连续 N 次」这种统计量要显式建模:fail_run 只在成功时清零、失败时 +1, 别在同一段代码里又置零又自增。
  5. 外部独立探针是唯一可信裁判:client/probe.py 不 import 被测包,自己按契约重算再逐字段对比, 这才让 9 条不一致浮出水面。内部自检和被测代码共享实现时,天然会一起错。
  6. Docker Compose 复现三连:app/ 是 COPY 进镜像、无 bind mount,改完必须 docker compose up -d --build --force-recreate;调试时用 docker cp 改容器内文件容易和镜像层错位, 出现「宿主机对、容器旧」的诡异现象。
  7. 变异测试脚本要防脏基线:负向脚本会在源码上做变异,被 SIGKILL 时 trap 不执行, 下一次就在变异版本上跑。加一条启动守卫(grep 关键口径),发现是脏基直接 FATAL。
  8. bash 函数里的 python3 - <<'EOF' 会吃掉 stdin(bash 5.2 起 heredoc 走临时文件), 连续调用时第二次拿到空输入,变异静默失效。改成独立 .py + sys.argv 最稳。
  9. 考卷里的断言也可能带「问题态口径」:本题 verify.sh 的 chk A1 0 "$RC" 注释写着 「问题态恒 0(假绿)」,修好后必须改成 2,否则永远差一条。看到「永远差一条」先怀疑断言本身。

版权声明

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

分享这篇文章