Kubernetes 的滚动更新机制本身设计得相当完善,但不少团队把 Spring Boot 应用迁移上去之后,依然会在每次发版时观察到一波 502 错误或者注册中心里的调用失败。问题的根源不在于 Kubernetes,而在于应用侧和集群侧的配置没有形成配合。要让滚动发布真正做到业务无感知,需要理解 Kubernetes 杀死一个 Pod 的完整时序,并在此基础上把 Spring Boot 的优雅停机、健康检查探针和 preStop 钩子都调校到位。

一、先搞清楚 Kubernetes 删除一个 Pod 时到底发生了什么
很多人配置优雅停机是凭感觉抄配置,结果线上还是报错。要彻底解决问题,必须从原理入手。当我们执行 kubectl delete pod,或者 Deployment 触发滚动更新时,Kubernetes 对旧 Pod 的处理遵循一套固定的时序。
第一步,Pod 的状态被标记为 Terminating,此时端点控制器会把这个 Pod 从 Service 的 endpoints 列表中移除,同时 kube-proxy 会更新 iptables 或 ipvs 规则。第二步,如果 Pod 定义了 preStop 钩子,kubelet 会先执行它,并且在钩子执行完毕之前,不会发送 SIGTERM 信号。第三步,preStop 执行完后 kubelet 发送 SIGTERM 给容器主进程,容器进入正常关闭流程。第四步,如果超过 terminationGracePeriodSeconds(默认 30 秒)进程还没退出,kubelet 会发送 SIGKILL 强制杀死进程。
这里有一个非常关键的隐藏问题:endpoints 的更新、kube-proxy 规则的同步、客户端负载均衡器的感知,这三件事都是异步发生的。也就是说,即便 Kubernetes 已经把 Pod 从 endpoints 移除了,仍可能有已经在传输路上的流量打到这个 Pod 上。这就是为什么只配置 Spring Boot 的优雅停机还不够,还需要 preStop 钩子来争取缓冲时间。
二、Spring Boot 侧的优雅停机配置
Spring Boot 从 2.3.0 开始原生支持优雅停机,通过 server.shutdown=graceful 开启。开启之后,应用收到 SIGTERM 时不再立刻关闭 HTTP 端口,而是停止接收新请求,把存量请求处理完再退出。等待的上限由 spring.lifecycle.timeout-per-shutdown-phase 控制,默认 30 秒。
推荐的配置如下,以 Spring Boot 2.7+ 为例,写在 application.yml 中:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
management:
endpoint:
health:
probes:
enabled: true
endpoints:
web:
exposure:
include: health,info,metrics
注意几个细节。第一,优雅停机只对 MVC 同步请求生效,如果你用的是 WebFlux,行为略有不同但基本可用;而 SSE、WebSocket 这类长连接, graceful shutdown 并不会替你断开,需要自己在 shutdown 钩子里处理。第二,如果应用里还有定时任务、消息消费者,需要配合 @PreDestroy 或实现 SmartLifecycle 来先停止拉取新消息、再等待任务完成:
@Component
public class ConsumerShutdown implements SmartLifecycle {
private volatile boolean running = true;
@Override
public void stop() {
running = false; // 先停止消费新消息
}
@Override
public boolean isRunning() {
return running;
}
}
三、Kubernetes 侧:readiness 探针与 preStop 钩子的黄金组合
Spring Boot 侧配置好后,接下来要让 Kubernetes 侧正确摘除流量。首先要启用 readiness 探针。readiness 探针失败时,Pod 会被从 Service endpoints 中移除,这是主动摘流量的手段。启动了 management.endpoint.health.probes.enabled 后,可以直接使用 /actuator/health/readiness 路径。
但这里有个反直觉的点:Pod 进入 Terminating 状态时,readiness 探针失败并不能完全解决问题,因为前文提到的异步延迟依然存在。所以生产环境的标准做法是给容器加上 preStop 钩子,强制 sleep 几秒,给 kube-proxy 和上游注册中心足够的反应时间:
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
replicas: 3
template:
spec:
terminationGracePeriodSeconds: 45
containers:
- name: order-service
image: registry.ippipp.com/order-service:1.8.0
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 20
periodSeconds: 5
failureThreshold: 3
这段配置的完整时序是:Pod 被标记 Terminating → endpoints 移除 + readiness 开始失败 → preStop sleep 10 秒(此期间应用还在正常处理请求,流量已逐渐切走)→ SIGTERM 发出 → Spring Boot graceful shutdown 等待存量请求最多 30 秒 → 进程退出。注意 terminationGracePeriodSeconds 必须大于 preStop 时长加上 shutdown 超时,这里设置为 45 秒,留了少量余量。
四、注册中心延迟与常见踩坑点
如果服务走的是 Nacos、Eureka 这类注册中心,而不是纯 Service 直连,情况会更复杂。注册中心的客户端通常有本地缓存,消费者拉取服务列表存在周期性延迟,比如 Nacos 默认的推送和拉取机制下,一次下线通知到全量生效可能需要几秒。preStop 里的 sleep 时间要覆盖这个延迟,或者更彻底的方案是在 preStop 中主动调用注册中心的下线接口,把实例标记为不健康:
lifecycle:
preStop:
exec:
command:
- sh
- -c
- "curl -X DELETE 'http://nacos:8848/nacos/v1/ns/instance?serviceName=order-service&ip=${POD_IP}&port=8080'; sleep 8"
最后整理几个高频踩坑点:第一,把 terminationGracePeriodSeconds 忘了调大,默认 30 秒,preStop 加 shutdown 很容易超时被 SIGKILL 杀掉,前面的所有配置全部白费。第二,Java 应用打包成镜像时用了 shell 形式的 ENTRYPOINT,导致 1 号进程是 sh 而不是 java,SIGTERM 根本传不到 JVM,一定要用 exec 形式 ENTRYPOINT ["java", "-jar", "app.jar"]。第三,readiness 探针的 initialDelaySeconds 太小,应用还没启动完就被反复标记失败,造成发布期间大量请求失败。第四,滚动更新策略建议配合 maxUnavailable: 0 和合理的 maxSurge,保证更新过程中可用副本数不下降。
把以上几层配置串联起来,Spring Boot 应用在 Kubernetes 上的滚动发布基本可以做到请求数为零丢失。核心思路只有一句话:让流量摘除先于进程终止发生,并给这个摘除过程足够的时间窗口。
Spring Boot优雅停机Kubernetes滚动发布preStop钩子修改时间:2026-09-09 01:10:49