一次灰度发布后的 502 并非网关故障,而是新 Pod 的就绪探针在错误的端口上提前放行,流量被送到了尚未开始监听的进程。
某业务集群的网关在版本发布后开始返回 502。故障呈现明显的版本相关性:稳定版 Pod 的请求全部正常,只有灰度批次的新 Pod 出现 502,灰度比例约 10%,因此整体错误率看起来不高,但灰度流量内的失败比例接近三分之一。用户侧表现为部分页面提交失败,客户端重试后多数能落到旧 Pod 上成功。
网关日志里的关键行是 connect() failed (111: Connection refused) while connecting to upstream,上游地址指向新 Pod 的 IP 和业务端口。这个错误与超时不同:连接被明确拒绝,说明请求到达了目标 IP,但目标端口没有进程监听。与此同时,新 Pod 在 kubectl get pods 中显示 1/1 Running,Readiness 状态为 True,这成为最初误判的核心原因。
kubectl -n <ns> get pods -l app=<app> -o wide
kubectl -n <ns> get rs -l app=<app>
kubectl -n <ns> get endpointslice -l kubernetes.io/service-name=<svc> -o wide
kubectl -n <ingress-ns> logs deploy/<ingress-controller> --tail=200 | grep -E '502|connect\(\) failed'
进一步进入新 Pod 检查监听状态和本地健康检查:
kubectl -n <ns> exec <pod> -- ss -lntp
kubectl -n <ns> exec <pod> -- curl -sv http://127.0.0.1:8080/healthz
kubectl -n <ns> exec <pod> -- ps -ef
故障初期,ss 只显示管理端口在监听,业务端口尚未出现;十几秒后再次执行,业务端口才起来。这个时间差与网关日志中 502 的出现窗口完全重合。
Connection refused,不是上游超时,也不是没有可用端点。tcpSocket 探测 9090 管理端口,而非业务端口 8080。curl 127.0.0.1:8080/healthz,启动初期连接被拒绝,十几秒后成功。故障持续约 28 分钟,灰度批次承载约 10% 的流量,其中一部分请求被 502 拒绝。按峰值估算,用户可见的失败请求占比在低个位数,重试放大了网关和后端的总请求量,但没有造成数据不一致;业务侧使用了幂等提交,重复请求没有产生重复写入。稳定版 Pod 全程正常,因此故障没有扩散到全量用户。
真正的影响在于发布节奏和信任成本。灰度本应限制爆炸半径,但探针误判让“未就绪”的 Pod 被当成健康实例加入端点,灰度反而把问题直接暴露给真实用户。监控也没有按版本拆分错误率:网关大盘的总 502 比例被稳定版的成功请求稀释,告警延迟了几分钟。恢复后还需要确认没有请求滞留在旧 Pod 的终止流程中,并检查灰度期间是否有定时任务或异步消费者使用了新版本。
第一个误导是 502 的常见含义。502 通常让人联想到上游崩溃、协议不匹配或网关超时,于是排查一开始集中在网关配置和网络链路上。但日志中的 Connection refused 已经排除了超时:如果是上游处理慢,错误会是 timeout 或 504;如果是没有端点,网关通常返回 503。连接被拒绝说明流量确实到了 Pod,只是端口没人监听。
第二个误导是 upstream keepalive。有人提出网关与后端的长连接被应用主动关闭,复用了失效连接,导致 502;这个假设在其它场景成立,但无法解释“只有新 Pod 出错、旧 Pod 零错误”。调整 keepalive 参数并重启网关后错误率没有变化,这条线索被排除。真正推翻它的是按 Pod IP 聚合的错误分布:失败全部落在新 ReplicaSet 的地址上。
第三个误导最强:Pod 显示 Ready。Kubernetes 的 Ready 只代表探针成功,不代表探针检查了正确的对象。这里的 Readiness 用 tcpSocket 探测 9090,而 9090 是管理端口,启动脚本在进程一开始就打开它;业务端口 8080 要等配置加载完才监听。探针成功、端点加入、流量进入,三步都“合规”,但组合起来就是把请求送进了尚未营业的窗口。
根因是探针语义与流量入口不一致。新版本重构时,为了“让探针不受下游依赖影响”,把 Readiness 从原来的 HTTP /healthz:8080 改成了 tcpSocket:9090。9090 只证明管理进程活着,不能证明业务 HTTP 服务已经可以接收请求。kubelet 看到 TCP 连接成功,就把 Pod 标记为 Ready;EndpointSlice 控制器随即把 Pod IP 加入可用后端;网关按轮询或最少连接把请求发过去,业务端口尚未 bind,返回连接拒绝,网关翻译成 502。
发布策略放大了这个错误。新版本启动时需要加载规则和预热缓存,业务端口通常在十几秒后才监听;而 Pod 模板没有 startupProbe,Readiness 的 initialDelaySeconds 很短,minReadySeconds 为 0,RollingUpdate 又允许较快的替换速度。于是新 Pod 在“管理端口已开、业务端口未开”的窗口内被批量标记为 Ready,灰度流量正好落进这个窗口。
旧版本没有暴露问题,是因为它的 Readiness 直接检查业务端口,业务端口没起来就不会 Ready;这次重构把探针改到了管理端口,等于拆掉了唯一的启动保护。应用本身没有崩溃,回滚后同一镜像在探针正确的情况下可以正常服务,这也反证了问题在探针与发布编排,而不是业务代码。
第一步是中止灰度并回滚,先恢复用户请求成功率,再讨论新版本。回滚后确认 EndpointSlice 中只剩旧版本 Pod,网关 502 指标归零,避免残留的新 Pod 继续接流量。不要用重启网关或扩容副本来“压住” 502,那只会改变流量分布,不会修复未就绪实例。
kubectl -n <ns> rollout undo deploy/<app>
kubectl -n <ns> rollout status deploy/<app>
kubectl -n <ns> get endpointslice -l kubernetes.io/service-name=<svc> -o wide
新版本修正探针,让 Readiness 命中真实业务端口,并增加 startupProbe 覆盖最慢启动路径;同时把 minReadySeconds 设置为大于启动窗口的值,RollingUpdate 使用 maxUnavailable: 0。
spec:
minReadySeconds: 15
strategy:
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
spec:
containers:
- name: app
ports:
- name: http
containerPort: 8080
startupProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 2
failureThreshold: 30
readinessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 5
failureThreshold: 3
重新灰度时按 Pod 维度验证:新 Pod 变为 Ready 后,立即从同命名空间的调试 Pod 访问 Service,确认返回 200;观察网关日志中是否还有指向新 Pod 的 Connection refused;对比新旧版本的 5xx 比例,确认新版本不高于基线后再扩大比例。
把探针当成发布契约的一部分。Readiness 必须检查承载业务流量的监听端口和关键依赖,不能只探测管理端口或进程存活;startupProbe 用于覆盖慢启动,liveness 只负责判断进程是否需要重启,不要把它绑定到下游依赖上。团队应维护统一的探针模板,并在代码评审中检查探针端口、Service targetPort 与容器实际监听端口是否一致。
发布流水线增加静态检查:容器端口、Service 端口、探针端口三者必须能对应;新增或修改探针时要求说明检查对象。灰度阶段按版本对比错误率、上游连接失败数和延迟,而不是只看聚合大盘;当新版本 5xx 明显高于基线时自动中止。对冷启动较慢的服务,在预发环境做一次“从零启动到可服务”的计时,把最坏耗时写进 startupProbe 的预算。
合上页面,先回答,再展开对照。
minReadySeconds 和滚动更新参数?connect() failed (111: Connection refused),表示 TCP 连接被目标端口明确拒绝;超时通常会表现为 timeout 或 504。连接拒绝说明流量到了 Pod,但业务端口没有进程监听,应检查 Pod 内监听状态和启动顺序。minReadySeconds 让 Pod 稳定一段时间后再被视为可用,maxUnavailable: 0 和较慢的替换速度能避免批量未热实例同时进入端点,降低冷启动窗口的风险。
小程序版
微信扫码,随时查命令