写在前面你好我是 Evan。“我就正常更新了一下服务怎么用户投诉就炸了”这是我在一次线上发布后听到的第一句话。那次我只是执行了一个常规的滚动更新Kubernetes 按部就班地拉起新 Pod、销毁旧 Pod。但就在旧 Pod 被销毁的那一刻一批正在处理的请求被硬生生切断了——支付回调写到一半、订单状态更新了但权益没发放、用户收到 502 错误页面。问题的根源很简单我用了kill但没用好。kill -9SIGKILL是“暴力拆除”房子倒了里面的人请求也被埋了。而生产环境需要的是“温柔遣散”——拒绝新客、服务完老客、再关门。今天这篇文章我想结合一次真实的线上事故排查经历系统性地聊聊在 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 的ThreadPoolTaskExecutorBean public ThreadPoolTaskExecutor threadPool() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setWaitForTasksToCompleteOnShutdown(true); // 等待任务完成[reference:14] executor.setAwaitTerminationSeconds(60); return executor; }3.3 虚拟线程的“坑”守护线程的特殊性如果你使用了 Java 21 的虚拟线程spring.threads.virtual.enabledtrue需要注意虚拟线程默认是守护线程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]为什么需要 sleepK8s 从 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 10Spring 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_SERVICEK8s 检测到就绪探针失败从 Service Endpoints 移除 Pod IP新流量不再进入该 PodPod 继续处理存量请求直到完成五、完整配置清单照抄就能用5.1 Spring Boot 配置application.ymlyaml 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 Deploymentyaml 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: 105.3 时间线总览六、常见踩坑与解决方案坑 1Scheduled定时任务在停机时被中断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 方法 }坑 2Nacos 服务未及时摘除如果用了 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] // 等待现有连接归还 }坑 4PreStop 时间 Spring 超时 terminationGracePeriodSeconds这是最常见的配置错误。务必确保preStop sleep时间spring.lifecycle.timeout-per-shutdown-phaseterminationGracePeriodSeconds建议预留 5-10 秒的余量。七、写在最后优雅是一种态度回到开头的事故——支付回调丢失的根本原因不是代码写得不好而是我们没有给服务一个“体面的告别方式”。优雅停机本质上是对用户请求的尊重。它告诉系统每一个到达的请求都值得被完整处理每一个用户的操作都不应该因为运维操作而被“腰斩”。配置清单回顾✅ Spring Bootserver.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 一次体面的告别——用户不会感知到任何波动而你也能睡个安稳觉。
告别 kill -9 的暴力美学:Kubernetes 下 Spring Boot 的优雅停机与零丢失实战
发布时间:2026/6/28 2:47:46
写在前面你好我是 Evan。“我就正常更新了一下服务怎么用户投诉就炸了”这是我在一次线上发布后听到的第一句话。那次我只是执行了一个常规的滚动更新Kubernetes 按部就班地拉起新 Pod、销毁旧 Pod。但就在旧 Pod 被销毁的那一刻一批正在处理的请求被硬生生切断了——支付回调写到一半、订单状态更新了但权益没发放、用户收到 502 错误页面。问题的根源很简单我用了kill但没用好。kill -9SIGKILL是“暴力拆除”房子倒了里面的人请求也被埋了。而生产环境需要的是“温柔遣散”——拒绝新客、服务完老客、再关门。今天这篇文章我想结合一次真实的线上事故排查经历系统性地聊聊在 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 的ThreadPoolTaskExecutorBean public ThreadPoolTaskExecutor threadPool() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setWaitForTasksToCompleteOnShutdown(true); // 等待任务完成[reference:14] executor.setAwaitTerminationSeconds(60); return executor; }3.3 虚拟线程的“坑”守护线程的特殊性如果你使用了 Java 21 的虚拟线程spring.threads.virtual.enabledtrue需要注意虚拟线程默认是守护线程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]为什么需要 sleepK8s 从 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 10Spring 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_SERVICEK8s 检测到就绪探针失败从 Service Endpoints 移除 Pod IP新流量不再进入该 PodPod 继续处理存量请求直到完成五、完整配置清单照抄就能用5.1 Spring Boot 配置application.ymlyaml 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 Deploymentyaml 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: 105.3 时间线总览六、常见踩坑与解决方案坑 1Scheduled定时任务在停机时被中断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 方法 }坑 2Nacos 服务未及时摘除如果用了 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] // 等待现有连接归还 }坑 4PreStop 时间 Spring 超时 terminationGracePeriodSeconds这是最常见的配置错误。务必确保preStop sleep时间spring.lifecycle.timeout-per-shutdown-phaseterminationGracePeriodSeconds建议预留 5-10 秒的余量。七、写在最后优雅是一种态度回到开头的事故——支付回调丢失的根本原因不是代码写得不好而是我们没有给服务一个“体面的告别方式”。优雅停机本质上是对用户请求的尊重。它告诉系统每一个到达的请求都值得被完整处理每一个用户的操作都不应该因为运维操作而被“腰斩”。配置清单回顾✅ Spring Bootserver.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 一次体面的告别——用户不会感知到任何波动而你也能睡个安稳觉。