写在前面

        你好,我是 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 时到底发生了什么:

整个流程中有三个关键时间点,任何一个没配置好,都会导致请求丢失:

  1. Service Endpoints 移除(通常几毫秒到几秒)

  2. PreStop Hook 执行(用户自定义)

  3. 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

工作流程

  1. Pod 收到删除请求,Spring Boot 开始优雅停机

  2. /actuator/health/readiness 返回 OUT_OF_SERVICE

  3. K8s 检测到就绪探针失败,从 Service Endpoints 移除 Pod IP

  4. 新流量不再进入该 Pod

  5. 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 秒的余量。

七、写在最后:优雅是一种态度

回到开头的事故——支付回调丢失的根本原因,不是代码写得不好,而是我们没有给服务一个“体面的告别方式”

优雅停机,本质上是对用户请求的尊重。它告诉系统:每一个到达的请求,都值得被完整处理;每一个用户的操作,都不应该因为运维操作而被“腰斩”。

配置清单回顾

  1. ✅ Spring Boot:server.shutdown: graceful

  2. ✅ Spring Boot:设置合理的 timeout-per-shutdown-phase

  3. ✅ K8s:配置 preStop sleep,给流量摘除留时间

  4. ✅ K8s:设置 terminationGracePeriodSeconds ≥ preStop + Spring timeout + 余量

  5. ✅ K8s:配置 readinessProbe 指向 /actuator/health/readiness

  6. ✅ 代码:自定义线程池实现 DisposableBean 或设置 waitForTasksToCompleteOnShutdown

  7. ✅ 代码:@Scheduled 任务实现 SmartLifecycle 控制停止

  8. ✅ 代码:Nacos 等服务注册中心手动摘除

下次滚动更新时,记得给你的 Pod 一次体面的告别——用户不会感知到任何波动,而你,也能睡个安稳觉。

更多推荐