Spring Boot 优雅停机实战:等在途请求处理完再退出,配合 K8s preStop 别丢请求

Spring Boot 优雅停机实战:server.shutdown=graceful 等在途请求处理完再退出,配合 K8s preStop 别丢请求

线上滚动发布时你有没有遇到过这种诡异现象:代码没改坏,监控却在每次发版的那几秒钟准时冒出一小撮 502 / 连接重置,业务方追过来问「刚才下单失败了几单」。你翻日志,发现请求打到了一个正在关闭的实例,进程已经把 HTTP 线程池干掉了,可这个请求才处理到一半。

这不是偶发抖动,而是**默认的「立即停机」**在作祟。这篇文章手把手把它改成优雅停机,并讲清楚为什么光配 Spring Boot 还不够,得和 Kubernetes 的 preStop 配合才真正不丢请求。

先看默认停机为什么丢请求

Spring Boot 2.2 之前,收到 SIGTERM 后进程直接开始销毁 Bean、关闭内嵌 Tomcat。此时:

  • 已经 accept 但还没处理完的请求,连接被强行掐断 → 客户端收到 Connection reset
  • 线程池里排队的任务直接丢弃。

写个接口模拟一下慢请求就能复现:

java 复制代码
@RestController
public class OrderController {

    @GetMapping("/order")
    public String create() throws InterruptedException {
        // 模拟一个耗时 5 秒的下单流程(扣库存、写单、发消息)
        Thread.sleep(5000);
        return "order created";
    }
}

启动后用 curl 发一个请求,趁它没返回时 kill <pid>(注意是默认的 SIGTERM,不是 -9):

bash 复制代码
curl http://localhost:8080/order &
sleep 1
kill $(pgrep -f 'java.*app.jar')   # 发 SIGTERM

默认情况下 curl 会立刻收到连接中断,那笔「下单」就这么没了。

正确写法:开启 graceful shutdown

从 Spring Boot 2.3 起,优雅停机是内置能力,两行配置搞定:

yaml 复制代码
# application.yml
server:
  shutdown: graceful          # 关键:开启优雅停机
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s   # 最长等 30s 让在途请求跑完,超时才强关

开启后,收到 SIGTERM 的行为变成:

  1. Web 容器停止接收新连接(新来的请求会被拒绝/由前面的 LB 转走)。
  2. 已经进来的在途请求,继续处理直到完成
  3. 全部处理完 或 到达 timeout-per-shutdown-phase 上限,才真正关闭线程池、销毁 Bean。

再跑一遍上面的复现脚本,这次那笔慢请求会正常返回 order created 之后进程才退出。

一个容易忽略的点:timeout-per-shutdown-phase大于你最慢接口的处理时间,否则超时后照样强关。别拍脑袋写 5s,先看你 P99 接口耗时。

想在关闭前做自己的清理?用 SmartLifecycle

有时候你还想在停机时主动做点事:给注册中心发下线通知、把本地缓冲区刷盘、关掉自己起的定时线程池。别去 hook Runtime.addShutdownHook(它和 Spring 生命周期不同步),用 SmartLifecycle:

java 复制代码
@Component
public class GracefulTasks implements SmartLifecycle {

    private volatile boolean running = false;

    @Override
    public void start() {
        running = true;
    }

    @Override
    public void stop() {
        // 这里在「停止接收新请求」之后、销毁 Bean 之前执行
        // 适合:主动从注册中心摘除、flush 缓冲、优雅关自建线程池
        System.out.println("[shutdown] 主动下线 + 刷缓冲...");
        running = false;
    }

    @Override
    public boolean isRunning() {
        return running;
    }

    @Override
    public int getPhase() {
        // phase 越大越先 stop。想在 Web 容器停之后再执行,取一个较小值
        return Integer.MAX_VALUE - 1;
    }
}

getPhase() 控制顺序:数值大的先启动、先停止。Web 服务器所在的 phase 很大,所以给你的清理逻辑一个稍小的值,能保证它在「不再收新请求」之后跑。

关键坑:只配 Spring Boot,K8s 里照样丢请求

很多人配完上面就以为万事大吉,结果在 Kubernetes 里滚动更新还是有 502。原因在于两个动作是并发的、没有先后保证:

  • kube-proxy / Ingress 把这个 Pod 从 Endpoints 里摘掉(停止转发流量)。
  • kubelet 给容器发 SIGTERM。

这两件事几乎同时发生,而 Endpoints 的更新要经过 apiserver → kube-proxy → 刷新 iptables/ipvs,有延迟。于是会出现:进程已经收到 SIGTERM 开始「不收新连接」了,但 LB 那边还没更新完,继续把新请求转进来 → 被拒 → 502。

解法是加一个 preStop 钩子,先睡几秒,给 Endpoints 更新争取时间,然后才让 SIGTERM 真正开始停机:

yaml 复制代码
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 45   # 要 > preStop sleep + Spring 停机超时
      containers:
        - name: app
          image: my-app:latest
          lifecycle:
            preStop:
              exec:
                # 睡 5~10s,等 kube-proxy 把本 Pod 从转发表里删掉,期间还能正常处理存量请求
                command: ["sh", "-c", "sleep 8"]

时序就变成了:preStop sleep(此时旧流量还在处理,新流量被 LB 逐步切走)→ sleep 结束 → 容器收到 SIGTERM → Spring 优雅停机等在途请求跑完 → 退出。三段时间要满足:

复制代码
terminationGracePeriodSeconds > preStop_sleep + timeout-per-shutdown-phase

比如 preStop 睡 8s、Spring 超时 30s,那 terminationGracePeriodSeconds 至少给 45s,否则 kubelet 等不及会补一刀 SIGKILL,又把在途请求砍了。

顺手把 readiness 探针也想清楚

优雅停机期间,进程还活着但不该再被当作「健康可用」。如果你用的是 Spring Boot Actuator,开启 readiness 状态联动能让探针在停机阶段自动转为 OUT_OF_SERVICE,配合 K8s readinessProbe 更快摘流量:

yaml 复制代码
management:
  endpoint:
    health:
      probes:
        enabled: true
  health:
    readinessstate:
      enabled: true

配合 K8s:

yaml 复制代码
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  periodSeconds: 3

这样进入停机流程后,readiness 立刻变红,探针一失败就从 Endpoints 摘除,和 preStop sleep 形成双保险。

小结

  • 默认停机会掐断在途请求,滚动发布时的偶发 502 十有八九是它。
  • 开启优雅停机只需两行:server.shutdown: graceful + spring.lifecycle.timeout-per-shutdown-phase,超时要大于最慢接口耗时。
  • 需要自定义清理(下线、刷盘、关线程池)用 SmartLifecyclestop(),别用裸的 shutdown hook。
  • 光配 Spring 不够 :K8s 里 Endpoints 更新有延迟,必须加 preStop: sleep 若干秒 先让 LB 切走流量,再让 SIGTERM 触发停机。
  • 三段时间要满足 terminationGracePeriodSeconds > preStop sleep + Spring 停机超时,否则会被 SIGKILL 补刀白忙活。

一句话记忆点:优雅停机 = 先让流量断奶(preStop + readiness 摘流量),再让进程断电(等在途请求跑完),顺序反了就丢请求。

相关推荐
摇滚侠1 小时前
《Spring Boot 3:高级与架构设计》第 1 章 Bean 与 BeanDefinition 个人理解 2
java·spring boot·笔记·后端
qq_269506751 小时前
第30课-请求与响应
java
代码不停1 小时前
Spring Boot日志
spring boot·spring
qq_269506751 小时前
第28课-AJAX与前端框架
java
IT_陈寒1 小时前
Redis主从切换竟让业务卡了3秒?这个坑我替你踩了
前端·人工智能·后端
程序员清风1 小时前
Java 后端如何接入大语言模型
java·spring boot·架构·aigc
xieliyu.1 小时前
JVM 垃圾回收机制详解:从标记过程、回收算法到垃圾收集器
java·jvm·笔记·java-ee
JavacKaka1 小时前
Redis大Key导致生产事故复盘博客-2026-09-18
java
逃逸线LOF1 小时前
Spring Boot4 整合 Shiro3 & Thymeleaf
spring boot·spring