滚动更新发版偶发 502
01 前置理论与核心机制¶
1. 为什么配了优雅停机依然会 502?¶
- 两条链路的时间差:K8s 下达删除 Pod 指令时,控制面(摘除路由)和数据面(关闭容器)是并行推进的,但生效时间完全不在一个节奏上。
- 数据面(关进程极快):Kubelet 收到指令后,会立刻向容器内的 1 号进程发送 SIGTERM 信号。业务服务收到信号,瞬间关闭端口,闭门谢客。
- 控制面(摘路由极慢):K8s 需要先更新 Endpoints,再通知所有节点的 Kube-proxy 修改 iptables/IPVS,最后同步给 Ingress/网关。这条链路走完可能需要 2~3 秒。
2. 竞态条件引发的 502¶
- 时间差酿成事故:因为网关摘除路由慢了这两三秒,它还以为旧容器活着,继续把前端进来的新流量往里打。但此时旧容器的端口已经关了,网关连不上,直接向用户抛出 502 Bad Gateway 或 Connection Refused。
02 故障排查与恢复闭环 SOP¶
遇到发版瞬间流量轻微跌零或报 502,不需要去查网络玄学或修改就绪探针,直接干预 Pod 的退出流程。
步骤一:观测端点摘除的真实状态¶
查看服务背后的端点映射,了解路由变更过程:
- 观测点:看起来只是一个 Pod 被删了,实际上 K8s 内部发生了一连串的端点解绑动作,这段时间足以让旧容器提早关机。
步骤二:给容器配置增加 preStop 钩子¶
核心解决思路:让旧 Pod 收到终止信号后别急着退出,在原地强行等上十几秒,给网关留足时间把路由摘干净。
编辑业务的 Deployment YAML(如 app-v2.yaml),在容器的 lifecycle 中加入 preStop 配置:
步骤三:调整宽限期参数防强杀¶
加了 sleep 15 后,必须确保 K8s 的强制宽限期 terminationGracePeriodSeconds 足够长(默认是 30 秒)。如果你的业务自身优雅停机需要 20 秒,加上 sleep 的 15 秒,总共需要 35 秒,就会被 K8s 提前强杀。
步骤四:执行滚动更新并验证平滑度¶
重新应用配置,让它开始滚动更新:
- 验证结果:此时能看到新 Pod 已经起来接流量了,旧 Pod 会处于 Terminating 状态并卡住 15 秒。在这 15 秒内,网关完成了路由摘除,旧 Pod 才真正开始关闭进程。流量实现 100% 平滑过渡。
03 真实生产故障现象与案例库¶
案例 1:Nginx Ingress 刷新延迟导致的 502¶
- 故障现象:每次 Jenkins 发布新版本,API 监控面板上总会出现 1~2 秒的 502 报错尖峰。
- 排查结果:Nginx Ingress Controller 依赖 K8s Endpoints 的变更来重载配置(Reload)。在大规模集群中,Endpoints 的变更通知推送到 Ingress 耗时达到 3 秒,而 Java 进程在 0.5 秒内就关闭了 Tomcat 端口。
- 根因与处置:典型的路由摘除时间差。加入 preStop: sleep 15 后,故障彻底消除。
案例 2:注册中心 (Nacos/Eureka) 摘除延迟¶
- 故障现象:微服务架构下,滚动更新时 RPC 消费者疯狂报错 Connection Refused。
- 排查结果:提供者 Pod 已经退出,但 Nacos 注册中心里的节点信息还没剔除,消费者依然拿到旧的 IP 发起调用。
- 根因与处置:除了加 sleep 等待 K8s 内部路由摘除,还需在 preStop 中加入主动调用注册中心下线 API 的命令,强制让 Nacos 提前摘除该节点,然后再 sleep。
案例 3:长连接业务 (WebSocket) 被无情切断¶
- 故障现象:带有 WebSocket 聊天的服务发版时,旧连接全被暴力掐断,导致客户端大面积重连。
- 排查结果:sleep 只能解决新流量不再进入,但无法解决已经建立的长连接。
- 根因与处置:除了 preStop 延迟关闭外,还需要在业务代码中捕获 SIGTERM 信号。收到信号后停止接受新连接,并主动向已有的客户端发送“请重连”指令,确保长连接平滑迁移。
04 生产排障速查表(发版 502)¶
遇到滚动更新掉线问题,按序排查配置:顺序排查层级执行命令 / 观测点异常定性处置动作01策略核查检查 strategy: RollingUpdatemaxUnavailable 配成了 100%调整为 25% 或 1,避免一口气全删02探针核查检查 readinessProbe就绪探针未配或时间太短补充就绪探针,确保新 Pod 真活了再引流03钩子核查检查 lifecycle.preStop未配置停机钩子增加 sleep 15 抵消网络与网关延迟04宽限期核查检查 terminationGracePeriodSeconds默认 30 秒,但 sleep + 停机耗时超过 30调大该参数至 60 甚至 120 秒防强杀05注册中心检查 RPC 报错日志K8s 内正常,但微服务网关报错在 preStop 先执行 curl 下线 API 再休眠
05 面试应答参考话术¶
面试官问:“K8s 滚动更新明明配了 0 宕机,也做了优雅停机,为什么每次发版还是会偶发 502?”
可以这样回答:这个问题不在 Pod,也不在探针,而是在于控制面和数据面之间有两三秒的时间差。发版的时候,K8s 会同时干两件事:一是去删容器进程,二是去更新 Endpoints 和网关路由。 这两件事生效时间完全不在一个节奏上。网关摘除路由是一条很长的链路,从 Endpoints 到 Kube-proxy 再到 Ingress,走完往往需要两三秒。 但容器那边收到 SIGTERM 信号后,服务立刻关掉端口闭门谢客。网关慢了这两三秒,还以为旧容器活着,继续往里打流量,502 就来了。解决起来很简单:直接在 Deployment 里给容器加上一个 preStop 停机钩子,里面写上 sleep 15。 它的作用就是让旧 Pod 收到终止信号后别急着退出,先在原地继续接客 15 秒。这 15 秒足够网关把这条路由彻底摘干净,然后再把容器进程平滑关掉。这样就能彻底消除滚动更新时那一瞬间的 502 报错。
06 面试高频追问与应答预案¶
追问 1:加了 sleep 15 之后,那 Pod 真正完全退出需要多久?¶
- 回答要点:
- 总耗时 = preStop 耗时 (15秒) + 业务框架处理旧请求的耗时。
- 必须特别注意,K8s 有一个兜底参数 terminationGracePeriodSeconds(默认 30 秒)。如果你的 sleep 加上业务停机时间超过了 30 秒,K8s 会直接发 SIGKILL 把你强杀。所以加了 sleep 后,通常要把这个宽限期参数调高到 60 秒。
追问 2:我们的集群不用 Ingress,用的是 Service Mesh(比如 Istio),还需要配 preStop 吗?¶
- 回答要点:
- 依然需要。Istio 的 Envoy Sidecar 同样依赖控制面 (Istiod) 下发的 EDS (Endpoint Discovery Service) 更新路由。只要有路由分发机制,就必然存在网络延迟与时间差。加 preStop 是屏蔽这种分布式系统延迟的最通用解法。
07 自动巡检脚本(check_prestop.sh)¶
该脚本用于快速扫描指定 Namespace 下的所有 Deployment,找出未配置 preStop 钩子的潜在发版掉线风险对象。
#!/usr/bin/env bash
# ==============================================================================
# 脚本名称: check_prestop.sh
# 功能描述: 自动化扫描 K8s 集群中缺失 preStop 钩子的 Deployment (纯只读)
# 使用方式: 运行在具备 kubectl 权限的机器上,可传入 namespace 参数
# ==============================================================================
set -o pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
PLAIN='\033[0m'
NAMESPACE=${1:-"default"}
echo -e "${BLUE}================================================================${PLAIN}"
echo -e "${BLUE} Deployment 发版平滑度 (preStop 钩子) 安全巡检 ${PLAIN}"
echo -e "${BLUE}================================================================${PLAIN}"
echo -e "${YELLOW}[INFO] 当前检查的 Namespace: ${NAMESPACE}${PLAIN}\n"
# 获取指定 Namespace 下的所有 Deployment 名称
DEPLOYS=$(kubectl get deploy -n "$NAMESPACE" -o custom-columns=":metadata.name" --no-headers)
if [ -z "$DEPLOYS" ]; then
echo -e "${GREEN}[OK] 该 Namespace 下暂无 Deployment 资源。${PLAIN}"
exit 0
fi
RISK_COUNT=0
for deploy in $DEPLOYS; do
# 提取 preStop 配置内容
PRESTOP=$(kubectl get deploy "$deploy" -n "$NAMESPACE" -o jsonpath='{.spec.template.spec.containers[*].lifecycle.preStop}' 2>/dev/null)
if [ -n "$PRESTOP" ]; then
echo -e " -> ${GREEN}[OK] Deployment: ${deploy} 包含 preStop 配置,具备抗发版 502 能力。${PLAIN}"
else
echo -e " -> ${RED}[WARN] Deployment: ${deploy} 缺失 preStop 钩子!发版时极易出现微秒级路由断层报错。${PLAIN}"
((RISK_COUNT++))
fi
done
echo -e "\n${BLUE}================================================================${PLAIN}"
if [ "$RISK_COUNT" -gt 0 ]; then
echo -e "${RED}[汇总] 发现 $RISK_COUNT 个无 preStop 防护的 Deployment,建议补充 lifecycle.preStop: sleep 15。${PLAIN}"
else
echo -e "${GREEN}[汇总] 巡检通过!所有 Deployment 均已配置平滑下线钩子。${PLAIN}"
fi
echo -e "${BLUE}================================================================${PLAIN}"