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

网关下游健康探测恒 ConnectionRefused、supervisor 退出码却是 0:探测端口读错了配置来源(upstream_emit_port ≠ 契约端口 GATEWAY_ADDR)

日期:2026-09-25 16:00 · 环境:Docker Compose + Python 3.12-slim / Linux TCP 技术栈关键词:Python socket、ConnectionRefusedError [Errno 111]、Docker Compose、 配置解析、端口语义、健康探测、supervisor 退出码、healthz 假绿、容器网络命名空间隔离 搜索可能用到的词:探测端口读错配置来源、upstream_emit_port 和 GATEWAY_ADDR 区别、 supervisor 退出码 0 但服务全挂、健康检查假绿、本机监听 ≠ 本进程监听、 探测恒 connection refused 排查、两件相似配置同值埋雷、探活只验“在跑”不验“干成了”


一、问题背景

网关切片新上线了一个「下游健康探测守护」gatewayd。运维看到的全是好消息:

  • 探活面板 client/healthz.py 一直是绿的(RESULT: OK);
  • 守护的上层 supervisor 退出码永远是 0,日志每轮都有输出;
  • 审计文件 audit.log 每轮都在变长,last_result.json 也是刚写的(mtime 新鲜);
  • 网关对外端口 19080 上明明有服务在监听(forwarder 容器里 ss -ltn 可见)。

但下游一次都没被真正探到过:

{"round": 1, "endpoint": "gateway-self:19100", "reachable": false, "errno": "ConnectionRefusedError"}
{"round": 1, "endpoint": "gateway-self:19100", "reachable": false, "errno": "ConnectionRefusedError"}

应用自己解析出来的端口是 19100,而契约端口是 19080:

$ python3 /srv/app/endpoints.py
{ "downstream_port": 19100, "downstream_source": "proxy.config:upstream_emit_port",
  "expected_bind_port": 19080 }

一句话:「守护在跑」≠「守护把活儿干成了」。守护只做对了前一半, 却让退出码 0 + 文件在变长 + 端口有人监听三个旁证把故障完全掩护住了。

这是一个非常典型的多维信号互相掩护的生产故障:任何一个信号单独看都“正常”, 只有把「探测结果 reachable」当成唯一权威判据,问题才暴露。


二、复现步骤

完整环境(可 checkout 后用 docker 自行复现):

git fetch origin
git checkout 2026-09-25-1600
cd scenario/2026-09-25-1600

docker compose up -d --build
docker compose exec app bash

进入容器后依次执行:

# 1) 应用怎么解析这份配置(问题就在这一行输出里)
python3 /srv/app/endpoints.py

# 2) 一轮复现:supervisor 谎报健康 + 审计全是 ConnectionRefused
bash /srv/ops/repro.sh

# 3) 判定脚本:问题态应当是红的
bash /srv/ops/verify.sh        # 期望 RESULT: BAD(PASS=3 FAIL=6,rc=1)

关键现场证据

# 应用侧端口解析:来源是「代理出参端口」,不是契约端口
$ python3 /srv/app/endpoints.py
{
  "gateway_addr": "gateway-self:19080",
  "downstream_host": "gateway-self",
  "downstream_port": 19100,                                  # 应该是 19080
  "downstream_source": "proxy.config:upstream_emit_port",     # 应该读 GATEWAY_ADDR
  "expected_bind_port": 19080
}

# 监视器:每轮都失败,却 exit 0
$ python3 /srv/app/supervisor.py 2
{"supervisor_round": 1, "daemon_rc": 1, "note": "rc 非 0 属正常抖动,继续下一轮"}
{"supervisor_round": 2, "daemon_rc": 1, "note": "rc 非 0 属正常抖动,继续下一轮"}
supervisor: all rounds done, no restart needed
$ echo $?
0                                                              # 谎报健康

# 审计:100% 失败
$ cat /srv/state/audit.log
{"round": 1, "endpoint": "gateway-self:19100", "reachable": false, "errno": "ConnectionRefusedError"}
{"round": 1, "endpoint": "gateway-self:19100", "reachable": false, "errno": "ConnectionRefusedError"}

# 判定脚本基线
$ bash /srv/ops/verify.sh
[FAIL] A3 审计里 2 轮全部失败,supervisor 却退出码 0(把“全挂”说成“正常抖动”)
[FAIL] B1 下行端口=19100 与契约端口=19080 不一致:探测打到了错误的端口
[FAIL] B2 下行端口读成了「代理出参端口」(19100) —— 读错了配置来源
[FAIL] C1 守护探测失败(daemon rc=1):同容器里刚起的上游明明是活的
[FAIL] C2 守护未能连通(errno=ConnectionRefusedError)
[FAIL] D1 客户端探活 BAD:守护『在跑』但没把活儿干成
RESULT: PASS=3 FAIL=6
RESULT: BAD

关于「两个容器 ss -ltn 不一样」

$ docker compose exec app ss -ltn
LISTEN 0  128  127.0.0.11:45995  0.0.0.0:*          # 看不到 19080

$ docker exec 3day-82-forwarder ss -ltn
LISTEN 0  16     0.0.0.0:19080    0.0.0.0:*          # 有人监听

这不是 bug,是容器网络命名空间隔离。app 与 forwarder 在同一网桥上, 但各自 ss 只能看到自己命名空间里的监听。 app 通过 extra_hosts / 网络别名把 gateway-self 解析到容器网络, 所以只要端口读对,真的能连上。

排障提示:ss -ltn 看不到端口时,先问「是没人监听,还是监听在别的命名空间」, 再决定下一步;不要一上来就怀疑网络不通。


三、根本原因

3.1 主因:端口来源搞反了(app/endpoints.py)

# 出问题的代码
key, val = first_kv(proxy, "upstream_emit_port", "listen_port")   # 19100,代理自己的出参端口
if key is not None:
    src = "proxy.config:" + key
    port = parse_port(val)
else:
    key, val = first_kv(conf, "FORWARD_PORT", "OUTBOUND_PORT", "PORT")
    ...

配置里的两件事被混淆了:

配置键文件语义本题值
GATEWAY_ADDRconf/gateway.env网关对外契约端口(客户端连的那个)—— 权威gateway-self:19080
FORWARD_PORTconf/gateway.env转发层出参端口,本版本下 = 对外端口(同义副本)19080
upstream_emit_portconf/proxy.conf代理进程自己的出参端口 —— 描述的是「另一件事」19100

代码把第三行放在了端口查找链的最前面。

为什么埋了这么久? 因为历史上 upstream_emit_port 和对外端口恰好同值(都是 19080), 这条错误路径从来没被走到过、也从未暴露。一旦运维把它调平成 19100, 探测立刻打到 19100 —— 那里没有人监听 → ConnectionRefusedError [Errno 111]。

这是经典的 「两件相似但不同的事,碰巧同值 → 埋雷」。 修复的本质不是“改一个数字”,而是确立唯一权威来源。

3.2 帮凶:失败被上层吞掉(app/supervisor.py)

print(json.dumps({
    "supervisor_round": i,
    "daemon_rc": p.returncode,
    "note": "rc 非 0 属正常抖动,继续下一轮",        # 把「全挂」说成「抖动」
}, ensure_ascii=False))
...
print("supervisor: all rounds done, no restart needed")
return 0                                            # 恒 0,外部永远看不见故障

「偶发抖动」和「连续全失败」语义完全不同,代码把它们当成同一件事。 daemon_rc 只是被打印出来,退出码恒 0 → 部署脚本、CI、探活面板全被瞒过。

3.3 为什么 errno 是 ConnectionRefusedError 而不是超时

在 TCP 语义里:

  • ConnectionRefusedError(收到 RST)→ 目标 IP:Port 上没有进程监听;
  • ETIMEDOUT(SYN 被静默丢弃)→ 防火墙 / 路由问题。

本题两个容器在同网桥,包能到达 forwarder,但 19100 上确实没有监听者 → 立刻 RST。 所以 errno 本身就指向「端口错了」,而不是「网络不通」 —— 这是最快的定位线索。


四、解决办法

修复 A:端口来源 = 契约端口(app/endpoints.py)

    # FIX(round 82): 下行探测端口只有一个权威来源 —— 契约端口,即
    # GATEWAY_ADDR 里冒号后面那个(对外端口)。
    port = parse_port(addr_port)
    src = "gateway.env:GATEWAY_ADDR"
    fw_key, fw_val = first_kv(conf, "FORWARD_PORT", "OUTBOUND_PORT", "PORT")
    if fw_key is not None and parse_port(fw_val) is not None \
       and parse_port(fw_val) != port:
        src = f"gateway.env:GATEWAY_ADDR({fw_key}={fw_val} 与权威值不一致,已忽略)"
    # 契约里没写端口就不做静默兜底:交给 socket 层/上层报错

设计要点:

  1. GATEWAY_ADDR 是唯一权威,upstream_emit_port 彻底移出查找链;
  2. FORWARD_PORT 只当同义副本:同值可用,不同值以权威值为准,并在 source 里留痕(可观测);
  3. 契约没写端口时不猜(返回 None),避免“静默兜底继续跑”;
  4. source 字段是可观测性设计 —— 一行日志就能看出端口是从哪个键读来的。

修复 B:全失败必须大声失败(app/supervisor.py)

    failed_rounds = 0
    for i in range(1, rounds + 1):
        p = subprocess.run(...)
        if p.returncode != 0:
            failed_rounds += 1
        print(json.dumps({
            "supervisor_round": i, "daemon_rc": p.returncode,
            "failed_rounds": failed_rounds,
            "note": "rc 非 0 计入失败轮次数",
        }, ensure_ascii=False))
        ...
    # 契约:全部轮次失败 != “正常抖动”。必须大声失败。
    if rounds > 0 and failed_rounds >= rounds:
        print(f"supervisor: ALL {rounds} rounds failed (failed_rounds={failed_rounds}) - refusing to report healthy")
        return 3
    print(f"supervisor: all rounds done (failed_rounds={failed_rounds})")
    return 0

设计要点:

  1. 显式累计 failed_rounds,区分「抖动」与「全挂」;
  2. 全部失败 → exit 3 + 明确日志,让部署/CI/面板能看见;
  3. 有成功轮次 → 仍 exit 0(不误报),保留对偶发失败的容忍。

使修改生效

代码是 COPY app/ /srv/app/ 打进镜像的,改完必须重建:

docker compose up -d --build
docker compose exec app bash

五、验证

(1)端口来源已修正

$ python3 /srv/app/endpoints.py
{ "gateway_addr": "gateway-self:19080",
  "downstream_port": 19080,
  "downstream_source": "gateway.env:GATEWAY_ADDR",
  "expected_bind_port": 19080 }

(2)真的连通了(回读 peername 作为证据,而不是拿计划值当事实)

$ python3 -c "import sys; sys.path.insert(0,'/srv/app'); from daemon import probe; from endpoints import load_downstream; h,p,s=load_downstream(); print(probe(h,p))"
{'endpoint': 'gateway-self:19080', 'reachable': True, 'connect_ms': 0.73,
 'bytes_echoed': 7, 'sockname': ['172.19.0.3', 47592],
 'peername': ['172.19.0.2', 19080], 'errno': None, 'error': None}

peername = 172.19.0.2:19080 是真实回读的对端地址(forwarder 容器)。

(3)判定脚本全绿

$ bash /srv/ops/verify.sh
[PASS] B0 conf/ 契约未被改动(对外 19080 / 代理出参 19100)
[PASS] A1 审计新增 2 条(守护确实跑完了 2 轮)
[PASS] A2 last_result.json 存在
[PASS] A3 有成功轮次时 supervisor 退出码 0
[PASS] B1 下行端口 == 契约端口(19080)
[PASS] B2 下行端口来源正确(source=gateway.env:GATEWAY_ADDR,代理出参=19100)
[PASS] C1 守护对契约端口探测成功(daemon rc=0)
[PASS] C2 守护报告已连通:回读 peername=172.19.0.2:19080(不是计划值)
[PASS] D1 客户端探活 OK(守护真的探测成功,且结果新鲜)

RESULT: PASS=9 FAIL=0
RESULT: OK

(4)repro.sh 的权威判据也变绿

$ bash /srv/ops/repro.sh
...
===== 4) 客户端探活(权威判据) =====
{"new_audit_lines": 1, "result_age_ms": 6, "daemon_rc": 0,
 "reachable": true, "errno": null, "peername": ["172.19.0.2", 19080]}
RESULT: OK
repro: healthz rc=0

(5)反向验证:证明防护「有牙」

端口错 + supervisor 已加固 → 故障必须可见:

$ python3 /srv/app/supervisor.py 2
{"supervisor_round": 1, "daemon_rc": 1, "failed_rounds": 1, "note": "rc 非 0 计入失败轮次数"}
{"supervisor_round": 2, "daemon_rc": 1, "failed_rounds": 2, "note": "rc 非 0 计入失败轮次数"}
supervisor: ALL 2 rounds failed (failed_rounds=2) - refusing to report healthy
$ echo $?
3                                       # 原来是 0,现在故障对外可见

(6)边界用例(防回归)

线上契约原文                        -> ('gateway-self', 19080, 'gateway.env:GATEWAY_ADDR')
FORWARD_PORT=19081 与权威值冲突      -> 取 19080,source 标注“与权威值不一致,已忽略”
FORWARD_PORT 同值 19080             -> ('gw', 19080, 'gateway.env:GATEWAY_ADDR')
只有 GATEWAY_ADDR,无 FORWARD_PORT  -> ('gw', 19080, 'gateway.env:GATEWAY_ADDR')
契约没写端口(GATEWAY_ADDR=gw)      -> ('gw', None,  'gateway.env:GATEWAY_ADDR')   # 不静默兜底
proxy_conf 里躺着 19100             -> ('gw', 19080, 'gateway.env:GATEWAY_ADDR')    # 出参端口不顶替

(7)全新环境复跑

$ docker compose down -v && docker compose up -d --build
$ docker compose exec app bash /srv/ops/verify.sh
RESULT: PASS=9 FAIL=0
RESULT: OK

六、经验与避坑

6.1 核心心法

  1. 「守护在跑」≠「守护把活儿干成了」。 探活必须验「干成了」:reachable=true + 回读 peername(真实对端地址)。 不能拿「进程活着 / 文件在变长 / 退出码 0」当成功证据 —— 这三件事只能证明“在跑”。
  2. 警惕多维信号互相掩护。 本题的假绿由三个旁证叠加而成:退出码 0、审计文件在变长、last_result.json 新鲜。 单独看每一个都像“正常”,合起来就把故障彻底盖住。 → 排障时要问:这个信号真的能证明成功吗,还是只证明了「没崩」?
  3. 配置读取只认唯一权威来源。 「两件相似但不同的事碰巧同值」是最难查的一类雷: upstream_emit_port(代理出参)和 GATEWAY_ADDR(对外端口)历史同值, 一旦其中一个被改,错误路径立刻暴露。 → 在解析层就固定优先级,并把「从哪个键读来的」写进可观测字段(source)。
  4. 区分「抖动」和「全挂」。 偶发失败容忍是对的;但「连续 N 轮全失败」是另一种性质的事实, 必须让外部(部署脚本 / CI / 探活面板)能看见 —— 否则等于没有监控。
  5. 看 errno 定方向。ConnectionRefusedError(RST)= 端口没人监听 → 先查端口对不对; ETIMEDOUT = SYN 被丢弃 → 再查防火墙/路由。

6.2 踩过的坑

坑说明正确做法
把「Issue 号」当「题号」状态文件写的 issue 82 其实是上一轮,本轮是 #98认标题里的日期/分支号更稳
容器里 ss 看不到端口就以为没监听app 与 forwarder 网络命名空间隔离到目标容器的命名空间里查;exec 到对应容器
改完代码没重建镜像代码是 COPY 进镜像的docker compose up -d --build
拿计划值当事实“我配的是 19080 所以应该连上了”回读 peername / sockname 自证
只修端口不修监督器端口修好后一切全绿,但同类故障未来仍会被吞两件事一起修:根因 + 让故障可见
grep -c 无匹配时输出多行导致 [: 0\n0: integer expression expected用 `

6.3 可复现命令速查

git checkout 2026-09-25-1600
cd scenario/2026-09-25-1600

docker compose up -d --build                       # 起环境
docker compose exec app bash                       # 进 app 容器

python3 /srv/app/endpoints.py                      # 看端口来源(问题就在这里)
bash /srv/ops/repro.sh                             # 复现(问题态 healthz BAD)
bash /srv/ops/verify.sh                            # 问题态 PASS=3 FAIL=6

bash /srv/ops/apply_fix.sh                         # 参考解(A+B)
docker compose up -d --build                       # 必须重建
docker compose exec app bash /srv/ops/verify.sh    # 修好后 PASS=9 FAIL=0

docker compose down -v                             # 清场(镜像里是问题态原版)

6.4 一句话总结

探活要验「活儿干成了没有」,不是「进程还在不在」; 配置要认「唯一权威来源」,别让碰巧同值的另一件事顶替契约。 把「全挂」说成「正常抖动」的监督器,比没有监督器更危险。

版权声明

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

分享这篇文章