适用场景

本文适用于通过 Kubernetes Deployment 滚动发布的 HTTP 服务。系统平时运行正常,但每次发布、缩容或节点驱逐时都会出现少量 502、连接重置或上游提前关闭;错误只持续几秒,Pod 日志里又看不到明显异常。

这类问题通常不是新版本启动失败,而是旧 Pod 的进程退出、EndpointSlice 状态传播、入口控制器更新后端和长连接结束之间存在时间差。治理目标不是简单延长睡眠,而是让实例先停止接收新请求,再完成在途请求,最后在宽裕的优雅终止窗口内退出。

现象描述

常见现场包括:

  • 只有发布或缩容窗口出现 502,稳定运行时错误率接近零;
  • 入口控制器日志出现 upstream prematurely closed connectionconnection 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 能区分 readyservingterminating

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=falseterminating=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 等待为传播缓冲,并为在途请求保留足够的终止预算。只有持续流量下多轮发布都不再产生终止相关错误,才算真正完成连接排空治理。

参考资料