K8s 滚动更新与 Spring Boot 优雅停机实战:如何做到真正的平滑发布?

文章目录
- [K8s 滚动更新与 Spring Boot 优雅停机实战:如何做到真正的平滑发布?](#K8s 滚动更新与 Spring Boot 优雅停机实战:如何做到真正的平滑发布?)
-
- 引言:发布时的"幽灵报错"
- 第一阶段:解决"启动慢导致流量过早打入"问题
-
- 痛点
- 解决方案:引入就绪探针 (Readiness Probe)
- [⚠️ 避坑指南:Actuator 健康检查"误报"](#⚠️ 避坑指南:Actuator 健康检查“误报”)
- [第二阶段:解决"旧 Pod 被销毁时中断任务"问题](#第二阶段:解决“旧 Pod 被销毁时中断任务”问题)
-
- 痛点
- [K8s 的终止流程与隐患](#K8s 的终止流程与隐患)
- [解决方案:利用 `preStop` 钩子"争取时间"](#解决方案:利用
preStop钩子“争取时间”) - [⚠️ 时间配比铁律](#⚠️ 时间配比铁律)
- 终极平滑发布配置模板
- [附录:关于 Revision 的小知识](#附录:关于 Revision 的小知识)
- 总结
引言:发布时的"幽灵报错"
在微服务和云原生时代,将 Spring Boot 应用部署到 Kubernetes (K8s) 已经是标准操作。然而,很多团队在初次上云时都会遇到一个极其头疼的问题:每次滚动更新或重启,前端总会零星报错,甚至用户会投诉"系统卡了一下"或"提示服务不可用"。
排查日志后发现,往往是因为应用启动极慢(例如启动需要 100 秒以上),但 K8s 看到 Pod 处于 Running 状态就把流量打进来了;或者,当旧 Pod 被销毁时,它正在处理的长耗时任务被直接"腰斩"。
本文将结合实际运维场景,从 就绪探针(Readiness Probe) 到 优雅停机(Graceful Shutdown),手把手教你如何组合配置,实现真正的平滑发布。
适用边界提示 :本文的"Readiness Probe + preStop Hook + 宽限期"组合主要面向 Web 服务:就绪探针负责摘除入站流量,
preStop为在途请求争取缓冲时间。对于定时任务/Job 类 Pod,没有 Service 流量可摘,就绪探针不能解决任务中断问题,preStop sleep也只是缓冲,并不保证当前任务一定完成。此类应用需要额外实现:收到SIGTERM后停止调度新 Job、等待或主动中断当前 Job、保存 checkpoint 或保证任务幂等,并把terminationGracePeriodSeconds当作硬上限而非"等完承诺"。不建议用超长宽限期或固定sleep来掩盖任务时长不可预测的问题。
第一阶段:解决"启动慢导致流量过早打入"问题
痛点
K8s 默认只要容器的进程启动了(状态变为 Running),就会认为该 Pod 可以接收流量,并立刻将其加入 Service 的 Endpoints 中。但此时 Spring Boot 的内部组件(如 Tomcat、数据库连接池、工作流引擎)可能还在初始化,导致请求直接报 502 或超时。
解决方案:引入就绪探针 (Readiness Probe)
我们需要通过探针告诉 K8s:"虽然我活着,但我还没准备好,别给我发流量。"
在 Deployment 的 YAML 中,针对容器加入如下配置:
yaml
readinessProbe:
httpGet:
path: /actuator/health
port: 8080
scheme: HTTP
initialDelaySeconds: 130 # 留足启动时间(根据实际启动耗时调整)
periodSeconds: 10 # 每10秒探测一次
failureThreshold: 3 # 连续3次失败视为未就绪
效果: 在新 Pod 启动后的前 130 秒,它处于 0/1(未就绪)状态,不接收任何流量。130 秒后探针成功,变为 1/1 开始接管流量。
⚠️ 避坑指南:Actuator 健康检查"误报"
很多项目引入了 spring-boot-starter-actuator 作为探针接口,但往往忽略了它的自动装配机制。例如,项目引入了 Redis 依赖但实际并未使用,导致 /actuator/health 探测 Redis 失败,返回 DOWN,进而导致 Pod 永远无法就绪。
修复方法: 在 application.yml 中关闭无用的健康检查,并开启详情:
yaml
management:
health:
redis:
enabled: false # 关闭误报的依赖检查
endpoint:
health:
show-details: always
第二阶段:解决"旧 Pod 被销毁时中断任务"问题
痛点
当新 Pod 就绪后,K8s 会开始销毁旧 Pod。此时,旧 Pod 会从 Endpoints 中被剔除(停止接收新流量)。但是,那些已经在处理中(正在进行中)的请求怎么办?
K8s 的终止流程与隐患
当 Pod 被标记为删除时,K8s 会发送 SIGTERM 信号给容器主进程。在 Spring Boot 中,如果不做特殊配置,Tomcat 会立刻关闭,正在处理的请求直接报错断开。
虽然 K8s 有 terminationGracePeriodSeconds(默认30秒)的宽限期,但如果是较老的 Spring Boot(2.3 版本之前),它根本不认识什么优雅停机,收到信号直接"自杀"。
解决方案:利用 preStop 钩子"争取时间"
如果你的 Spring Boot 版本较低,无法使用自带的 server.shutdown: graceful,我们可以利用 K8s 的 preStop 钩子,上演一出"缓兵之计"。
修改 Deployment YAML,在容器生命周期中加入钩子:
yaml
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 15"]
这 15 秒在底层发生了什么?
- K8s 决定销毁旧 Pod,先将其从 Service 路由表中剔除(不再有新流量进入)。
- 此时不发送
SIGTERM,而是执行preStop脚本,即睡 15 秒。 - 在这 15 秒内,旧 Pod 进程完全存活,TCP 连接不中断,正在处理的接口可以顺利跑完并把响应返回给客户端。
- 15 秒后,
preStop结束,K8s 发送SIGTERM。此时旧 Pod 手里基本没活了,瞬间安全关闭。
⚠️ 时间配比铁律
使用了 preStop 后,必须确保 K8s 的宽限期足够长,否则会发生"强杀":
-
terminationGracePeriodSeconds: 30(总计 30 秒) -
preStop sleep 15(拿走 15 秒) -
留给 JVM 关闭的时间:15 秒(足够安全)
该配比适用于请求可排干的 Web 场景;对于运行时长不定的定时任务,应先让任务具备可中断、可收尾或可断点续跑能力,再谈宽限期预算。
终极平滑发布配置模板
结合以上所有经验,一份标准的、能够应对绝大多数生产级场景的 Deployment 容器配置片段如下:
yaml
spec:
containers:
- name: my-app
image: my-registry/my-app:v1.0.0
ports:
- containerPort: 8080
# 1. 就绪探针:防止未启动完就接客
readinessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 130
periodSeconds: 10
failureThreshold: 3
# 2. 优雅停机钩子:防止正在处理的请求被强杀
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 15"]
# 3. 宽限期:必须大于 preStop 的 sleep 时间,给 JVM 留足关闭时间
terminationGracePeriodSeconds: 30
(注:如果你的应用中有耗时超过 30 秒的重度任务,建议将其改造为异步任务机制,而非无限延长 K8s 的宽限期。)
附录:关于 Revision 的小知识
在 K8s Deployment 的 YAML 中,常常会看到一个元数据注解 deployment.kubernetes.io/revision: '88'。
这是 K8s 自动维护的版本号。只有当你修改了 Pod 模板(spec.template) ,比如改了镜像、改了探针配置、加了 preStop 时,它才会自动 +1。
如果仅仅是修改了副本数(replicas),它不会变化。这个版本号是 K8s 保存历史 ReplicaSet 用于回滚的核心依据,默认最多保留 10 个历史版本。
总结
平滑发布不是单一的配置,而是一个系统性的工程。Readiness Probe(控制入流量) + preStop Hook(控制出流量缓冲) + 宽限期(兜底强杀),这套组合拳打下来,才能让用户在无感知的情况下完成版本迭代,彻底告别发布时的深夜报警。