网关下游健康探测恒 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_ADDR | conf/gateway.env | 网关对外契约端口(客户端连的那个)—— 权威 | gateway-self:19080 |
FORWARD_PORT | conf/gateway.env | 转发层出参端口,本版本下 = 对外端口(同义副本) | 19080 |
upstream_emit_port | conf/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 层/上层报错
设计要点:
GATEWAY_ADDR是唯一权威,upstream_emit_port彻底移出查找链;FORWARD_PORT只当同义副本:同值可用,不同值以权威值为准,并在source里留痕(可观测);- 契约没写端口时不猜(返回
None),避免“静默兜底继续跑”; 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
设计要点:
- 显式累计
failed_rounds,区分「抖动」与「全挂」; - 全部失败 →
exit 3+ 明确日志,让部署/CI/面板能看见; - 有成功轮次 → 仍
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 核心心法
- 「守护在跑」≠「守护把活儿干成了」。
探活必须验「干成了」:
reachable=true+ 回读peername(真实对端地址)。 不能拿「进程活着 / 文件在变长 / 退出码 0」当成功证据 —— 这三件事只能证明“在跑”。 - 警惕多维信号互相掩护。
本题的假绿由三个旁证叠加而成:退出码 0、审计文件在变长、
last_result.json新鲜。 单独看每一个都像“正常”,合起来就把故障彻底盖住。 → 排障时要问:这个信号真的能证明成功吗,还是只证明了「没崩」? - 配置读取只认唯一权威来源。
「两件相似但不同的事碰巧同值」是最难查的一类雷:
upstream_emit_port(代理出参)和GATEWAY_ADDR(对外端口)历史同值, 一旦其中一个被改,错误路径立刻暴露。 → 在解析层就固定优先级,并把「从哪个键读来的」写进可观测字段(source)。 - 区分「抖动」和「全挂」。 偶发失败容忍是对的;但「连续 N 轮全失败」是另一种性质的事实, 必须让外部(部署脚本 / CI / 探活面板)能看见 —— 否则等于没有监控。
- 看
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 一句话总结
探活要验「活儿干成了没有」,不是「进程还在不在」; 配置要认「唯一权威来源」,别让碰巧同值的另一件事顶替契约。 把「全挂」说成「正常抖动」的监督器,比没有监督器更危险。
