适用场景
本文适用于通过 Kubernetes Deployment 滚动发布的 HTTP 服务。系统平时运行正常,但每次发布、缩容或节点驱逐时都会出现少量 502、连接重置或上游提前关闭;错误只持续几秒,Pod 日志里又看不到明显异常。
这类问题通常不是新版本启动失败,而是旧 Pod 的进程退出、EndpointSlice 状态传播、入口控制器更新后端和长连接结束之间存在时间差。治理目标不是简单延长睡眠,而是让实例先停止接收新请求,再完成在途请求,最后在宽裕的优雅终止窗口内退出。
现象描述
常见现场包括:
- 只有发布或缩容窗口出现 502,稳定运行时错误率接近零;
- 入口控制器日志出现
upstream prematurely closed connection、connection reset by peer或连接拒绝; - 旧 Pod 进入
Terminating后很快退出,但部分代理仍短暂向旧 Pod IP 转发; - 短请求大多成功,上传、下载、流式响应或耗时接口更容易失败;
- 增加副本只能降低失败比例,不能消除单个终止实例上的竞态。
先把故障与发布事件对齐,而不是直接修改探针:
kubectl -n production rollout history deployment/api
kubectl -n production get pods -l app=api -w
kubectl -n production get events --sort-by=.lastTimestamp
如果错误峰值与 Pod 的删除时间高度重合,就应沿终止链路取证。
终止链路为什么会产生竞态
删除 Pod 后,控制面会把对应 EndpointSlice 端点标记为终止中,并使其 ready=false;与此同时,节点上的 kubelet 开始容器优雅终止流程。各组件是并行收敛的:EndpointSlice 控制器、kube-proxy、Ingress Controller、云负载均衡器和服务网格都需要时间观察并应用变化。
如果应用收到 SIGTERM 后立即关闭监听端口,而某一层代理还保留旧后端,就会把新请求送到已退出的进程。即使新请求已停止,应用若没有等待在途请求完成,也可能主动断开正在处理的连接。
还要注意两个边界:
preStop必须执行完成后,kubelet 才向容器发送终止信号;terminationGracePeriodSeconds的倒计时在执行preStop之前已经开始,钩子等待时间与应用退出时间共享同一个预算。
因此,把整个宽限期都消耗在 preStop 中,反而会让应用来不及处理 SIGTERM,最终被强制终止。
排查思路
1. 确认错误是否只发生在实例终止阶段
给入口访问日志保留后端 Pod IP、状态码、请求耗时和上游耗时,再将失败后端与 Pod 对照:
kubectl -n production get pod -l app=api -o wide
kubectl -n ingress-nginx logs deployment/ingress-nginx-controller \
--since=30m | grep ' 502 '
若 502 的上游地址集中指向刚进入 Terminating 的 Pod,可基本确认方向。日志中应记录后端地址和请求标识,但不要记录 Authorization、Cookie 或完整敏感请求体。
2. 观察 EndpointSlice 的真实状态
不要只看旧版 Endpoints 对象。EndpointSlice 能区分 ready、serving 和 terminating:
kubectl -n production get endpointslice \
-l kubernetes.io/service-name=api \
-o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\t"}{.conditions.ready}{"\t"}{.conditions.serving}{"\t"}{.conditions.terminating}{"\n"}{end}'
正常终止中的端点通常表现为 ready=false、terminating=true。在短时间内保留终止端点是预期行为,消费者应据此停止向它分配普通新流量。如果代理长时间继续选择该地址,应继续检查对应 Ingress、服务网格或云负载均衡器的同步延迟和连接池行为。
3. 检查应用是否正确处理 SIGTERM
进入正在运行的容器,确认业务进程确实是 PID 1,并查看镜像入口:
kubectl -n production exec deploy/api -- ps -o pid,ppid,stat,args
kubectl -n production get pod -l app=api \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[0].command}{"\t"}{.spec.containers[0].args}{"\n"}{end}'
错误写法如 sh -c "python app.py" 可能让 shell 成为 PID 1 且不转发信号。应使用 exec 形式启动,或在启动脚本末尾执行:
exec python -m app
应用收到 SIGTERM 后应按顺序执行:把自身标记为不接收新流量、停止领取新任务、等待在途请求完成、关闭连接池与监听端口、退出。不要在信号处理器里直接执行不可控的长时间阻塞操作。
4. 测量而不是猜测排空时间
记录以下时间点:
- Pod 获得
deletionTimestamp; - EndpointSlice 变为
terminating=true; - 入口控制器最后一次把新请求发给旧 Pod;
- 应用收到
SIGTERM; - 活跃请求数归零;
- 容器实际退出。
可以在低风险环境执行一次带时间戳的删除测试:
kubectl -n production delete pod api-7d8f6c9b7d-example
kubectl -n production get pod api-7d8f6c9b7d-example \
-w -o custom-columns='TIME:.metadata.deletionTimestamp,PHASE:.status.phase'
不要在只有一个副本、没有维护窗口或无法承受连接中断的生产服务上直接实验。
实现方案
方案一:应用实现真正的优雅关闭
以下 Go 示例在收到终止信号后停止接收新连接,并给在途请求最多 20 秒完成:
package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health/ready", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("优雅关闭超时: %v", err)
}
}()
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("HTTP 服务异常退出: %v", err)
}
}
Shutdown 会停止接受新连接并等待活跃连接空闲,但等待上限必须小于 Pod 的剩余终止预算。实际项目还应先切换 readiness 状态,并同步停止消费者、定时任务等非 HTTP 工作。
方案二:给路由收敛留出短暂窗口
当入口组件确实存在可测量的同步延迟时,可以用 preStop.sleep 在发送 SIGTERM 前留出一小段传播窗口:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 4
strategy:
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
spec:
terminationGracePeriodSeconds: 45
containers:
- name: api
image: registry.example.com/api:2026.09.12
ports:
- containerPort: 8080
lifecycle:
preStop:
sleep:
seconds: 8
readinessProbe:
httpGet:
path: /health/ready
port: 8080
periodSeconds: 3
failureThreshold: 1
这里的 8 秒不是通用答案,应根据“最后一个新请求到达旧 Pod”的实测分布设置。45 秒总预算还必须覆盖这 8 秒、最长正常请求和应用自身清理时间。若集群版本或平台不支持 sleep 类型钩子,可使用轻量的 exec 钩子,但要确认镜像内存在所需命令。
方案三:让 readiness 表达业务可接流量状态
应用开始排空时,让 /health/ready 返回非 2xx,而存活检查仍只表达进程是否需要重启。不要让 readiness 与 liveness 共用一套“所有依赖必须正常”的严格逻辑,否则依赖抖动可能同时摘除大量副本。
需要强调的是,Pod 被删除时 Kubernetes 本身会把终止端点设为非 ready;主动切换 readiness 更适合应用内部先进入排空、再触发删除的自定义流程,也能让应用状态更易观察。它不能替代应用处理 SIGTERM。
验证方案
在预发布环境持续发送带唯一请求 ID 的流量,同时执行多轮滚动更新:
kubectl -n staging rollout restart deployment/api
kubectl -n staging rollout status deployment/api --timeout=5m
kubectl -n staging get endpointslice \
-l kubernetes.io/service-name=api -w
验证至少覆盖:
- 滚动发布、手动删除 Pod、缩容和节点驱逐;
- 普通短请求、最长合法请求、上传下载和长连接;
- 入口 5xx、连接重置、请求取消数和旧 Pod 活跃请求数;
preStop执行时间、收到SIGTERM到退出的耗时、强制终止次数;- 新副本未就绪时,
maxUnavailable: 0是否确实保持容量。
完成标准应是多轮发布期间没有由终止 Pod 引发的 5xx,旧实例不再接收新请求,在途请求可在预算内完成,而且没有出现超过宽限期后被强制杀死的容器。
常见误区
- 只把
terminationGracePeriodSeconds调大,却不处理信号;进程仍会立即退出。 preStop睡眠占满整个宽限期;应用收到信号后没有清理时间。- 只依赖 readiness 探针周期;Pod 删除后的端点状态变化与探针失败不是同一机制。
- 把所有 502 都归因于终止竞态;新版本启动失败、上游超时和连接池配置错误仍需分别排除。
- 忽略消费端实现;某些入口、服务网格或自研代理可能对终止端点和现有连接采用不同策略。
- 用
kill -9验证优雅关闭;强制信号无法被应用捕获,不能代表正常删除流程。
预防措施
- 为每个 HTTP 服务统一提供可测试的优雅关闭组件和 readiness 状态机;
- 将最长请求时间、排空传播时间和资源清理时间纳入终止预算;
- 发布监控中同时展示 Deployment 事件、入口 5xx 和 Pod 终止时间线;
- 对流式接口、WebSocket 和后台消费者单独定义结束协议;
- 在发布流水线中执行持续流量下的滚动更新测试,而不只检查
rollout status; - 对超过宽限期、出现退出码 137 或排空时仍接收新请求的实例告警;
- 定期复核入口控制器、服务网格与云负载均衡器对 EndpointSlice 终止状态的支持。
总结
滚动发布期间的少量 502,本质上常是分布式状态传播与应用退出速度不匹配。排查时要把 Pod 删除、EndpointSlice 条件、代理最后转发时间、SIGTERM 和进程退出串成一条时间线。修复应以应用优雅关闭为核心,以经过测量的短暂 preStop 等待为传播缓冲,并为在途请求保留足够的终止预算。只有持续流量下多轮发布都不再产生终止相关错误,才算真正完成连接排空治理。
Discussion
评论