文章 548
评论 5
浏览 173083
SpringBoot 微服务优雅停机失败:K8s 滚动更新丢请求?PreStop 钩子+连接排空机制实战

SpringBoot 微服务优雅停机失败:K8s 滚动更新丢请求?PreStop 钩子+连接排空机制实战

公司 K8s 每次发版都有几个 502。查了监控,规律很明显——总是在旧 Pod 被 kill 的那几秒。原来是 K8s 滚动更新的时候,先发 SIGTERM 给旧 Pod,然后立刻把流量切到新 Pod。但旧 Pod 上还有正在处理的请求——SIGTERM 一来,进程直接退,请求半途而废。前端就 502 了。

Spring Boot 2.3 以后支持优雅停机,但光配 server.shutdown=graceful 还不够。K8s 的流量切换和 Pod 停机之间有时间差——你得让 Pod 在被 kill 之前有足够时间把正在处理的请求跑完。


问题拆解:K8s 发版一分钟内发生了什么

K8s 滚动更新流程:

T+0s   新 Pod 创建,等待就绪
T+5s   新 Pod Ready → 加入 Service Endpoint
T+5s   K8s 发 SIGTERM 给旧 Pod
T+5s   旧 Pod 上的请求还在跑 → 但 Service 已经不分配新请求了
T+5s   旧 Pod 收到 SIGTERM → Spring 开始优雅停机
        ├─ 不再接受新请求
        ├─ 等待正在处理的请求完成(30s)
        └─ 超时 → 强制关闭
T+35s  K8s 等不及了 → SIGKILL(硬杀)

两个关键时间窗口:

窗口一:SIGTERM 到 Endpoint 摘除之间有延迟。 K8s 发 SIGTERM 的同时也在更新 Service 的 Endpoint。但这不一定同步——旧 Pod 可能已经收到 SIGTERM 了,但 Service 还在给它分配新请求。这就是 PreStop 钩子要填的坑。

窗口二:正在处理的请求需要时间跑完。 Graceful shutdown 的 timeout-per-shutdown-phase 就是这个等待时间。


PreStop 钩子:在被 SIGTERM 之前先把自己摘出去

PreStop 是 K8s Pod 生命周期里的一个钩子,在容器被终止之前执行。关键是:PreStop 执行期间,Pod 从 Service 摘除了,但还没收到 SIGTERM。

lifecycle:
  preStop:
    exec:
      command:
        - /bin/sh
        - -c
        - |
          # 1. 从 Service 摘除自己(不接新请求)
          # 2. sleep 等待流量切换完成
          sleep 10
          # 3. sleep 结束后容器收到 SIGTERM

这个 sleep 10 不是凑数——它的目的是等 K8s 把 Service 的 Endpoint 更新完。从 Pod 标记为 Terminating 到 Service 真正不再分配流量给它,中间可能有几秒延迟。sleep 给了这个延迟缓冲。


Spring Boot 侧:优雅停机配置

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

Spring Boot 收到 SIGTERM 后会:

  • 停止接收新 HTTP 请求(返回 503)
  • 等待正在处理的请求执行完毕
  • 超过 30 秒强制关闭

但这里有个坑:如果请求是长连接(WebSocket、SSE、大文件上传),30 秒可能不够。 这种情况下需要配合 PreStop 的 sleep 时间,让长连接有更早的信号去做迁移。


组合打法:PreStop + 优雅停机 + Readiness Probe

三层保障串起来:

PreStop 钩子:
  sleep 10 → 等 Service 完成 Endpoint 更新
  (此时 Pod 还在跑,但已不再接收新请求)

Spring 优雅停机:
  SIGTERM → 不再接新连接 → 等现有请求跑完(30s)

Readiness Probe:
  优雅停机开始 → /actuator/health/readiness 返回 OUT_OF_SERVICE
  → K8s 确认 Pod 已不可用 → 不分配新请求

Readiness Probe 是最后一道防线。即使 Service 更新慢了一步,Readiness Probe 也能告诉 K8s"这个 Pod 不健康了,别发请求过来"。

完整的 deployment 配置:

spec:
  template:
    spec:
      terminationGracePeriodSeconds: 45  # PreStop(10s) + Spring(30s) + 余量
      containers:
      - name: app
        lifecycle:
          preStop:
            exec:
              command: ["/bin/sh", "-c", "sleep 10"]
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080

terminationGracePeriodSeconds 必须大于 PreStop sleep + Spring 优雅停机超时之和。否则 K8s 会先发 SIGKILL,PreStop 白等了。


总结

K8s 滚动更新丢请求,不是因为优雅停机不好用,而是因为从"要杀 Pod"到"Pod 真的不接请求"之间有时间差。

时间线的关键改动:

没开 PreStop:SIGTERM → 立刻被杀 → 请求断
开 PreStop:  Mark Terminating → sleep 10 → SIGTERM → 优雅停机 → 干净退出

三件套:

  • PreStop sleep —— 给自己摘出 Service 留时间
  • server.shutdown=graceful —— 钱已经收了,把手里的请求处理完
  • readinessProbe 配合 /health/readiness —— 最后一道防线

配完后发版日志里不再有 502。



标题:SpringBoot 微服务优雅停机失败:K8s 滚动更新丢请求?PreStop 钩子+连接排空机制实战
作者:jiangyi
地址:http://jiangyi.space/articles/2026/06/28/1782636091508.html
公众号:服务端技术精选

服务端开发博客:后端架构、高并发、性能优化与微服务实战教程

取消