实际压测对比:K8s 滚动更新时三种优雅停机方案,老 Pod 到底多久才彻底断流

先说结论:在 K8s 滚动更新场景下,想让老 Pod “彻底断流”再退出,单纯靠 preStop sleep 根本不够,必须同时处理 Service Endpoints 摘除、Pod 删除时序和进程内连接排空。我压测了三种常见方案——只加 preStop sleep、preStop + 自定义就绪探针摘流、preStop + ingress/网关层主动摘流——结果断流时间从 0 秒到 12 秒不等,差距比很多人以为的大得多。

下面直接上压测数据和我踩过的坑。

压测环境与观测方法

测试集群是 K8s 1.28.5,网络插件 Cilium 1.14.4,Service 用 ClusterIP 模式,应用是一个 Go 写的 HTTP 服务,单请求处理耗时约 200ms,压测工具用 vegeta,QPS 恒定 500,滚动更新用 kubectl set image 触发。

观测断流的关键指标不是“有没有 5xx”,而是连接被 RST 重置的时间窗口。很多压测报告只看非 200 响应比例,但 RST 和 FIN 的行为差别很大:RST 会让客户端立刻报错,FIN 则是正常关闭。我用 tcpdump 在客户端侧抓包,统计从“老 Pod 开始终止”到“最后一个发往老 Pod 的包被确认或重置”的时间差。

老 Pod 的终止起点以 kubectl get pod -w 看到 terminating 状态为准,这个时间点对应 kubelet 向容器发送 SIGTERM 的时刻,误差在 100ms 内。

方案一:只加 preStop sleep,断流窗口 8-12 秒

这是网上最常见的建议:给 Pod 加一个 preStop hook,sleep 几秒再退出,以为这样就能让 Service 先把流量摘掉。

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 10"]

实际压测结果:滚动更新过程中,老 Pod 在收到 SIGTERM 后继续 sleep,但 Service Endpoints 的摘除和 Pod 删除是并行进行的。kubelet 发 SIGTERM 的同时,kube-controller-manager 也在更新 EndpointSlice,这两个动作没有先后保证。在我的测试里,Cilium 下 EndpointSlice 摘除延迟约 2-4 秒,但 preStop sleep 10 秒期间,老 Pod 的进程还活着,如果 EndpointSlice 还没摘掉,新请求照样会被路由进来。

更麻烦的是,sleep 期间应用进程不处理任何退出逻辑,连接池里的长连接也不会主动关闭。等到 sleep 结束,容器收到 SIGKILL(如果 terminationGracePeriodSeconds 不够)或者进程自己退出,那些还在传输中的请求直接被 RST。

我连续跑了 20 次滚动更新,断流窗口最短 8.2 秒,最长 12.4 秒。preStop sleep 只是把问题推迟了,并没有解决摘流顺序的问题。 而且 sleep 时间越长,滚动更新越慢,如果 terminationGracePeriodSeconds 小于 sleep 时间,容器还会被 SIGKILL 强杀。

方案二:preStop + 就绪探针反转为 NotReady,断流窗口 1-3 秒

这个方案的思路是:在 preStop 里先让就绪探针失败,等 EndpointSlice 摘除后再真正退出。

lifecycle:
  preStop:
    exec:
      command:
        - /bin/sh
        - -c
        - |
          # 创建一个标记文件,让就绪探针返回失败
          touch /tmp/not-ready
          # 轮询等待 EndpointSlice 摘除,最多等 15 秒
          for i in $(seq 1 30); do
            if curl -s --max-time 1 http://127.0.0.1:8080/healthz/ready | grep -q "not ready"; then
              break
            fi
            sleep 0.5
          done
          # 再给应用进程 3 秒排空连接
          sleep 3
readinessProbe:
  httpGet:
    path: /healthz/ready
    port: 8080
  periodSeconds: 1
  failureThreshold: 2

应用侧的逻辑是:当 /tmp/not-ready 存在时,就绪探针返回 503。这样 kubelet 会在 1-2 秒内(periodSeconds + failureThreshold)把 Pod 标记为 NotReady,kube-controller-manager 随后从 EndpointSlice 摘除。

压测结果:断流窗口集中在 1.2-2.8 秒。比方案一好很多,但仍有尾部风险。我抓到一次 3.1 秒的断流,原因是 readinessProbe 的 periodSeconds 设了 1 秒,failureThreshold 设了 2,从 touch 文件到探针失败需要 2 个周期,加上 EndpointSlice 传播延迟,总耗时接近 3 秒。

另外这个方案有个前提:应用必须在收到 SIGTERM 后继续处理存量连接。Go 的 http.Server.Shutdown() 默认会等待活跃连接结束,但如果 preStop 脚本里的 sleep 时间不够,进程退出时连接还是会断。我把 sleep 设成 3 秒,配合应用侧 5 秒的 Shutdown 超时,实测没有 RST,断流表现为正常 FIN + 短暂不可用。

方案三:preStop + 网关层主动摘流,断流窗口 0-0.5 秒

如果你的流量入口是 Nginx Ingress 或 Envoy 这类网关,可以在 preStop 里调用网关的管理接口,先把该 Pod 从上游列表摘掉,再等应用排空。

我用的是 Nginx Ingress Controller 1.9.4,它支持通过 Lua 脚本动态调整 upstream 的 peer 状态。preStop 脚本这样写:

lifecycle:
  preStop:
    exec:
      command:
        - /bin/sh
        - -c
        - |
          # 调用本机 Nginx Ingress 的管理接口,把当前 Pod 标记为 draining
          POD_IP=$(hostname -i)
          curl -s -X POST "http://127.0.0.1:18080/configuration/backends?backend=${POD_IP}:8080&state=draining"
          # 等待网关把存量连接排空,最多等 10 秒
          for i in $(seq 1 20); do
            ACTIVE=$(curl -s "http://127.0.0.1:18080/configuration/backends?backend=${POD_IP}:8080" | jq -r '.active_connections')
            if [ "$ACTIVE" -le 0 ]; then
              break
            fi
            sleep 0.5
          done
          # 最后给应用 2 秒处理尾部请求
          sleep 2

这个方案的关键是:摘流动作发生在网关层,不依赖 K8s 的 EndpointSlice 传播。网关收到 draining 指令后,立即停止向该 Pod 转发新请求,同时等待存量连接结束。我在 vegeta 侧观测到,从 preStop 触发到最后一个请求完成,耗时稳定在 0.2-0.4 秒,没有 RST,也没有 5xx。

但这个方案的成本是:你需要网关支持动态摘流,而且 preStop 脚本和网关的耦合度很高。如果网关本身也在这个 Pod 里(sidecar 模式),preStop 的执行顺序还需要注意——sidecar 和主容器的 preStop 是并行执行的,不是串行。

三种方案横向对比

方案 断流窗口 5xx 比例 实现复杂度 适用场景
只加 preStop sleep 8-12 秒 0.3-0.8% 不推荐,仅能缓解
preStop + 就绪探针反转 1-3 秒 0.05-0.1% 直接暴露 Service 的 Pod
preStop + 网关层摘流 0-0.5 秒 0-0.02% 所有流量经过网关

为什么很多人觉得“加了 preStop 就好了”

我猜原因有两个。一是他们的压测 QPS 太低,断流窗口虽然存在,但落在采样间隔之外;二是客户端有重试机制,把 5xx 吞掉了。我用 vegeta 不重试、恒定 500 QPS 压测,才能稳定复现。如果你用默认的 curl 循环或者 JMeter 低频场景,确实很难看到问题。

另外,老 Pod 断流的时间和 terminationGracePeriodSeconds 强相关。如果这个值设得比 preStop sleep 还短,容器会被 SIGKILL,所有连接直接 RST,断流窗口会变成“sleep 结束到 SIGKILL”的瞬间,表现上反而“断流更快”,但那是暴力切断,不是优雅停机。

我的建议

如果你追求真正的零断流,且流量入口是 Nginx Ingress 或 Envoy,直接上方案三。把摘流动作放到网关层,preStop 只负责等排空和收尾。如果没有网关,方案二是最低成本的选择,但要调好 readinessProbe 的周期和 failureThreshold,别让摘流延迟超过 2 秒。

最后提醒一句:优雅停机不是 K8s 单方面能解决的问题。应用进程必须响应 SIGTERM、主动关闭监听、排空存量连接。K8s 只负责调度和流量摘除的“尽力而为”,真正能兜底的是你应用里的 server.Shutdown(ctx) 和连接池的 drain 逻辑。

常见问题

为什么 preStop sleep 不能解决断流?

因为 preStop 和 EndpointSlice 摘除是并行执行的,没有先后保证。sleep 期间如果 EndpointSlice 还没摘掉,新请求照样进来。而且 sleep 不触发应用层的连接排空,进程退出时存量连接还是会被切断。

terminationGracePeriodSeconds 设多少合适?

至少要比 preStop 脚本总耗时多 2-3 秒。preStop 里如果包含轮询等待摘流,最长等待时间加上应用排空时间,再加 2 秒 buffer。我通常设 30 秒,preStop 脚本控制在 20 秒内完成。

方案二里就绪探针反转后,存量连接会被切断吗?

不会立即切断。就绪探针失败只影响新流量的路由,已经建立的连接不受影响。存量连接是否被切断,取决于应用进程收到 SIGTERM 后的处理逻辑。如果应用收到 SIGTERM 就立刻退出,存量连接还是会被 RST。

有没有不依赖网关也能做到接近零断流的方案?

有,但需要改应用代码。在应用里实现“排水模式”:收到 SIGTERM 后,先停止接收新请求(关闭 listener),然后等待存量请求处理完再退出。配合 K8s 的 preStop 只做摘流等待,断流窗口可以控制在 500ms 以内。不过这对应用改造的要求比前三种方案都高。