公司 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://www.jiangyi.space/articles/2026/06/28/1782636091508.html
公众号:服务端技术精选
