
Kubernetes Pod 优雅停机实战:滚动更新时为什么总有几个请求 502你上线新版本,做的是滚动更新,理论上应该零停机。可监控上偏偏出现一小撮 502/连接被重置,量不大但每次发布都有。你查代码没问题、查网关没问题,最后发现罪魁祸首是——Pod 停机根本不「优雅」。K8s 杀 Pod 的过程有一堆隐藏时序,踩不对就会把还在处理的请求硬生生切断。这篇把优雅停机的完整链路拆开,给你一套能直接抄的配置。先搞清楚 K8s 杀一个 Pod 时到底发生了什么当你kubectl delete pod或滚动更新触发 Pod 终止,K8s同时做两件独立的事,它们没有先后保证:把 Pod 从 Service 的 Endpoints 里摘掉(停止把新流量路由过来)——这一步是异步的,要经过 kube-proxy 更新 iptables/ipvs 规则,有延迟。给容器主进程发SIGTERM信号,然后等待terminationGracePeriodSeconds(默认 30 秒),超时还没退就发SIGKILL强杀。问题就出在「同时」上:摘流量是异步的、有延迟,而 SIGTERM 是立刻发的。于是很容易出现这个致命时序:t0.0s SIGTERM 发到容器,应用立刻开始关闭、停止 accept 新连接 t0.1s 但 kube-proxy 还没更新完规则,流量还在往这个 Pod 发 t0.1s 新请求打到一个正在关闭的 Pod → 连接被拒 → 502 t0.5s kube-proxy 终于摘掉 Endpoint,此时已经漏了一批请求根因看明白了:应用收到 SIGTERM 后不能立刻死,得等负载均衡器先把自己摘干净。第一招:preStop 钩子,睡一小会儿再退最简单有效的修法,是在容器上挂一个preStop钩子。K8s 保证preStop 执行完才会发 SIGTERM,所以我们用它「拖延」几秒,给 kube-proxy 摘 Endpoint 争取时间:apiVersion:apps/v1kind:Deploymentmetadata:name:webspec:template:spec:terminationGracePeriodSeconds:45# 总宽限期,要大于 preStop 应用排空时间containers:-name:webimage:myapp:v2lifecycle:preStop:exec:# 睡 5 秒:等 kube-proxy 把本 Pod 从 Endpoints 摘掉,# 这期间应用还活着、还能处理已建立连接上的请求command:[sh,-c,sleep 5]时序就变成了:t0s Pod 进入 Terminating,preStop 开始 sleep 5,同时 kube-proxy 开始摘 Endpoint t5s preStop 结束,此时流量早已不再路由过来,才发 SIGTERM t5s 应用从容处理完手头请求再退出,一个不漏注意terminationGracePeriodSeconds必须大于preStop 时间加上应用处理存量请求的时间,否则宽限期到了会直接 SIGKILL,preStop 还没睡醒就被强杀。第二招:应用必须自己响应 SIGTERM 做排空preStop 只是争取时间,真正干净退出还得靠应用自己处理 SIGTERM——收到信号后停止接收新请求,但把已经在处理的请求做完再退。以 Go 的 HTTP 服务为例:packagemainimport(contextnet/httposos/signalsyscalltime)funcmain(){srv:http.Server{Addr::8080}gosrv.ListenAndServe()// 监听 SIGTERM(K8s 停机发的就是它)和 SIGINTquit:make(chanos.Signal,1)signal.Notify(quit,syscall.SIGTERM,syscall.SIGINT)-quit// 阻塞直到收到信号// 优雅关闭:停止 accept 新连接,等待存量请求处理完,最多等 30 秒ctx,cancel:context.WithTimeout(context.Background(),30*time.Second)defercancel()srv.Shutdown(ctx)// Shutdown 会等在途请求结束,不会硬切}这里有个极其常见的坑:如果你的容器用sh -c your-app启动,SIGTERM 会发给 shell(PID 1),而不是你的应用,应用根本收不到信号,只能等 30 秒后被 SIGKILL 强杀,前面所有配置全白费。解法有两个,任选其一:# 方案 A:用 exec 形式的 ENTRYPOINT,让应用直接当 PID 1 ENTRYPOINT [/app/server] # 正确:server 是 PID 1,直接收到 SIGTERM # 而不是 ENTRYPOINT /app/server # 错误:被 shell 包一层,信号收不到如果非要用 shell,就在命令前加exec(sh -c exec /app/server),让应用进程替换掉 shell 进程占据 PID 1。第三招:别忘了 readiness 探针的配合还有个容易被忽略的点:Pod 进入 Terminating 后,readiness 探针如果还在返回成功,某些老版本或特定网关仍可能把它当健康节点。稳妥做法是让应用收到 SIGTERM 后,立刻让 readiness 探针返回失败(比如设一个shuttingDown标志位,探针路径检查它),双保险地告诉 K8s「别再给我导流了」。// readiness 探针检查这个标志,SIGTERM 后置 true,探针立即返回 503varshuttingDown atomic.Bool http.HandleFunc(/readyz,func(w http.ResponseWriter,r*http.Request){ifshuttingDown.Load(){w.WriteHeader(http.StatusServiceUnavailable)return}w.WriteHeader(http.StatusOK)})完整配置一图流把三招合起来,一个生产级的优雅停机配置长这样:spec:template:spec:terminationGracePeriodSeconds:45containers:-name:webimage:myapp:v2lifecycle:preStop:exec:command:[sh,-c,sleep 5]# 争取摘流量时间readinessProbe:httpGet:path:/readyz# 停机时立即返回 503port:8080periodSeconds:3配合应用侧:ENTRYPOINT 用 exec 形式(确保收到 SIGTERM) 代码里srv.Shutdown排空存量请求。三层配齐,滚动更新才能真正零 502。小结502 的根因:K8s 摘流量(异步、有延迟)和发 SIGTERM(立即)是并行的,应用死得太快,漏进来的新请求打到关闭中的 Pod。preStop 睡几秒给 kube-proxy 摘 Endpoint 争取时间,terminationGracePeriodSeconds要大于 preStop 排空耗时。应用必须自己响应 SIGTERM做优雅关闭(停新收、排存量);ENTRYPOINT 用 exec 形式,否则信号被 shell 吞掉、应用收不到。readiness 探针在停机时立即返回失败,双保险切断导流。一句话记忆:先让负载均衡器忘记你,你再退出——顺序错了就丢请求。