告别 kill -9 的暴力美学:Kubernetes 下 Spring Boot 的优雅停机与零丢失实战
写在前面
你好,我是 Evan。
“我就正常更新了一下服务,怎么用户投诉就炸了?”
这是我在一次线上发布后听到的第一句话。那次我只是执行了一个常规的滚动更新,Kubernetes 按部就班地拉起新 Pod、销毁旧 Pod。但就在旧 Pod 被销毁的那一刻,一批正在处理的请求被硬生生切断了——支付回调写到一半、订单状态更新了但权益没发放、用户收到 502 错误页面。问题的根源很简单:我用了 kill,但没用好。kill -9(SIGKILL)是“暴力拆除”,房子倒了,里面的人(请求)也被埋了。而生产环境需要的是“温柔遣散”——拒绝新客、服务完老客、再关门。
今天这篇文章,我想结合一次真实的线上事故排查经历,系统性地聊聊:在 Kubernetes 环境下,如何让 Spring Boot 应用真正做到“优雅停机”——不丢一个请求、不伤一个用户。

一、事故还原:一个支付回调引发的“惨案”
先来看一段“看起来没什么问题”的代码:
@PostMapping("/callback")
public void handleCallback(Payment payment) {
// 步骤1:更新订单状态
orderService.updateStatus(payment.getOrderId(), PAID);
// 步骤2:发放会员权益
benefitService.grantVip(payment.getUserId());
}
在某次滚动更新中,Kubernetes 向这个 Pod 发送了 SIGTERM 信号。问题发生在步骤 1 和步骤 2 之间——订单状态已经更新为“已支付”,但权益还没来得及发放,Pod 就被强制终止了。
结果就是:用户付了钱,没拿到权益。客诉、排查、回滚、补发——一个“优雅停机”配置能解决的问题,变成了一个通宵。
而事故的元凶,是下面这两个配置的“脱节”:
# ❌ 危险配置组合
# Spring Boot: 没有开启优雅停机(默认是 immediate)
# Kubernetes: terminationGracePeriodSeconds: 30(默认值)
Spring Boot 默认是 immediate 模式——收到停止信号立即中断所有请求。而 K8s 默认给 Pod 30 秒宽限期,到期就发 SIGKILL 强制杀死。两者叠加,等于告诉系统:“给你 30 秒,但你一秒都不等”——结果就是请求被粗暴中断。
优雅停机(Graceful Shutdown) 的核心定义是:在服务终止前,系统能拒绝新请求进入、完成存量请求处理、释放所有资源、通知上下游服务。
二、Kubernetes Pod 终止流程:一张图看懂“死亡倒计时”
在深入配置之前,先搞清楚 Kubernetes 删除一个 Pod 时到底发生了什么:

整个流程中有三个关键时间点,任何一个没配置好,都会导致请求丢失:
-
Service Endpoints 移除(通常几毫秒到几秒)
-
PreStop Hook 执行(用户自定义)
-
SIGTERM → SIGKILL 宽限期(默认 30 秒)
三、Spring Boot 侧:开启优雅停机,给它“体面的告别”
3.1 基础配置:一行 YAML 的事
从 Spring Boot 2.3 开始,优雅停机支持变得非常简单。只需要在 application.yml 中增加:
server:
shutdown: graceful # 开启优雅停机[reference:8]
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 等待存量请求完成的最大时间[reference:9]
配置后,Spring Boot 的行为会变成:
-
收到 SIGTERM 后,立即停止接收新请求(返回 503)
-
等待正在处理的请求完成(最多 30 秒)
-
超时后强制关闭
注意:
timeout-per-shutdown-phase是“Spring Boot 自己的宽限期”,不等于 K8s 的terminationGracePeriodSeconds。两者需要协调配合。
3.2 线程池也要“温柔”:别让异步任务死在半路
Spring Boot 的 Web 容器会优雅停机,但你自定义的线程池不会——除非你主动告诉它要等待。
@Bean
public ExecutorService bizExecutor() {
return Executors.newFixedThreadPool(10);
}
@Bean
public DisposableBean shutdownExecutor(ExecutorService bizExecutor) {
return () -> {
bizExecutor.shutdown(); // 停止接收新任务
if (!bizExecutor.awaitTermination(20, TimeUnit.SECONDS)) {
bizExecutor.shutdownNow(); // 超时则强制终止
}
};
}
或者使用 Spring 的 ThreadPoolTaskExecutor:
@Bean
public ThreadPoolTaskExecutor threadPool() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setWaitForTasksToCompleteOnShutdown(true); // 等待任务完成[reference:14]
executor.setAwaitTerminationSeconds(60);
return executor;
}
3.3 虚拟线程的“坑”:守护线程的特殊性
如果你使用了 Java 21 的虚拟线程(spring.threads.virtual.enabled=true),需要注意:虚拟线程默认是守护线程(daemon thread)。这意味着如果所有线程都是守护线程,JVM 会直接退出,不等任务完成。
解决方案:确保至少有一个非守护线程(如主线程或 Web 容器线程)在运行,或者手动管理虚拟线程的生命周期。
四、Kubernetes 侧:三个配置,锁定“零丢失”
4.1 PreStop Hook:给流量摘除留出“缓冲时间”
PreStop 是 K8s 在发送 SIGTERM 之前执行的生命周期钩子。它最常见的用途就是 sleep 一段时间,给 Service 摘除 Pod IP 留出时间。
spec:
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"] # 等待 10 秒[reference:19]
为什么需要 sleep?
-
K8s 从 Endpoints 移除 Pod IP 需要时间(尤其在大集群中)
-
sleep 期间,K8s 会完成路由表更新,新请求不再进入该 Pod
-
但 Pod 仍在运行,存量请求可以正常处理
从 Kubernetes v1.29 开始,还支持更简洁的 sleep 动作:
yaml
lifecycle:
preStop:
sleep:
seconds: 10 # Kubernetes v1.29+ 支持[reference:21]
4.2 terminationGracePeriodSeconds:给应用“最后的体面”
这是 K8s 从发送 SIGTERM 到发送 SIGKILL 的总宽限期。默认 30 秒。
计算公式:
terminationGracePeriodSeconds≥preStop sleep时间+spring.lifecycle.timeout-per-shutdown-phase+ 安全余量
例如:preStop: sleep 10 + Spring timeout: 30s + 余量 5s = 至少 45 秒
如果这个值太小,Spring Boot 还在处理请求,K8s 的 SIGKILL 就到了——直接“斩首”,前功尽弃。
4.3 ReadinessProbe:让流量“自然断流”
readinessProbe 决定 Pod 是否“就绪”接收流量。关键技巧是:让 readinessProbe 在停机时主动失败。
Spring Boot 的 /actuator/health/readiness 端点会在优雅停机期间自动返回 OUT_OF_SERVICE。
yaml
spec:
containers:
- name: app
readinessProbe:
httpGet:
path: /actuator/health/readiness # Spring Boot 2.3+ 支持
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
工作流程:
-
Pod 收到删除请求,Spring Boot 开始优雅停机
-
/actuator/health/readiness返回OUT_OF_SERVICE -
K8s 检测到就绪探针失败,从 Service Endpoints 移除 Pod IP
-
新流量不再进入该 Pod
-
Pod 继续处理存量请求,直到完成
五、完整配置清单:照抄就能用
5.1 Spring Boot 配置(application.yml)
yaml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
# 开启 Actuator 健康端点(用于 readinessProbe)
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
probes:
enabled: true # 启用 /actuator/health/readiness 和 /liveness[reference:30]
5.2 Kubernetes Deployment
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-spring-boot-app
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # 滚动更新期间始终保持服务可用
template:
spec:
terminationGracePeriodSeconds: 45 # 总宽限期[reference:31]
containers:
- name: app
image: my-app:latest
ports:
- containerPort: 8080
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"] # 给流量摘除留时间[reference:32]
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
5.3 时间线总览

六、常见踩坑与解决方案
坑 1:@Scheduled 定时任务在停机时被中断
Spring 的 @Scheduled 任务不会被 Web 容器的优雅停机自动管理。需要手动控制:
@Component
public class ScheduledTaskManager implements SmartLifecycle {
private final AtomicBoolean running = new AtomicBoolean(true);
@Scheduled(fixedDelay = 5000)
public void doTask() {
if (!running.get()) return; // 停机时跳过新任务
// 执行任务
}
@Override
public void stop() {
running.set(false); // 停止接收新任务
}
// 实现其他 SmartLifecycle 方法
}
坑 2:Nacos 服务未及时摘除
如果用了 Nacos 服务注册,停机时服务可能还在注册中心,其他服务继续调用。
解决方案:在 @PreDestroy 中手动摘除注册:
@PreDestroy
public void deregister() throws NacosException {
namingService.deregisterInstance(serviceName, ip, port);
}
坑 3:数据库连接池未释放
@PreDestroy
public void closeDataSource() {
HikariPool pool = dataSource.getHikariPoolMXBean();
pool.suspendPool(); // 停止借出新连接[reference:36]
// 等待现有连接归还
}
坑 4:PreStop 时间 + Spring 超时 > terminationGracePeriodSeconds
这是最常见的配置错误。务必确保:
preStop sleep时间+spring.lifecycle.timeout-per-shutdown-phase<terminationGracePeriodSeconds
建议预留 5-10 秒的余量。
七、写在最后:优雅是一种态度
回到开头的事故——支付回调丢失的根本原因,不是代码写得不好,而是我们没有给服务一个“体面的告别方式”。
优雅停机,本质上是对用户请求的尊重。它告诉系统:每一个到达的请求,都值得被完整处理;每一个用户的操作,都不应该因为运维操作而被“腰斩”。
配置清单回顾:
-
✅ Spring Boot:
server.shutdown: graceful -
✅ Spring Boot:设置合理的
timeout-per-shutdown-phase -
✅ K8s:配置
preStopsleep,给流量摘除留时间 -
✅ K8s:设置
terminationGracePeriodSeconds≥ preStop + Spring timeout + 余量 -
✅ K8s:配置
readinessProbe指向/actuator/health/readiness -
✅ 代码:自定义线程池实现
DisposableBean或设置waitForTasksToCompleteOnShutdown -
✅ 代码:
@Scheduled任务实现SmartLifecycle控制停止 -
✅ 代码:Nacos 等服务注册中心手动摘除
下次滚动更新时,记得给你的 Pod 一次体面的告别——用户不会感知到任何波动,而你,也能睡个安稳觉。
更多推荐
所有评论(0)