ElIauk

Spring Boot 优雅停机与健康检查

一次发布为什么会丢请求 滚动发布时,旧实例收到 SIGTERM 就退出,这是很多“发布后偶发 502”的来源。请求已经进了 Tomcat 线程池,业务还在写库,进程却被关掉,客户端看到连接重置;定时任务跑到一半被中断,第二天对账才发现数据缺了一块。Spring Boot 的优雅停机就是为这个场景准备

评论
Spring Boot 优雅停机与健康检查

一次发布为什么会丢请求

滚动发布时,旧实例收到 SIGTERM 就退出,这是很多“发布后偶发 502”的来源。请求已经进了 Tomcat 线程池,业务还在写库,进程却被关掉,客户端看到连接重置;定时任务跑到一半被中断,第二天对账才发现数据缺了一块。Spring Boot 的优雅停机就是为这个场景准备的,但它只解决“Web 请求”,不解决你自家线程池和定时任务。

从 Spring Boot 2.3 开始,server.shutdown=graceful 会让 Web 容器先停止接收新请求,再等待正在处理的请求结束,超时后才强制关闭。3.x 延续了这套机制,配合 Actuator 的 readiness 端点,可以在 Kubernetes 或 Nginx 后面做到“先摘流量,再停进程”。

最小配置

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 40s
management:
  endpoint:
    health:
      probes:
        enabled: true
      show-details: always
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus

timeout-per-shutdown-phase 要大于最慢请求的耗时,否则等待时间一到,容器照样强停。这个值不是越大越好,太长会让发布卡住,一般按 P99 耗时的两到三倍来估。配置完成后,停机日志里能看到 Commencing graceful shutdown 和 Graceful shutdown complete,没有这两行说明配置没生效。

健康检查要分组

Actuator 的 /actuator/health 是聚合结果,直接拿它做存活探针很危险:数据库抖一下,所有实例都被判定为不健康,然后被一起重启。正确做法是区分 liveness 和 readiness:

management:
  endpoint:
    health:
      group:
        liveness:
          include: livenessState,ping
        readiness:
          include: readinessState,db,redis

liveness 只关心进程本身是否卡死,readiness 才关心能不能接流量。数据库、Redis 这些下游依赖放进 readiness,出问题时实例被摘掉,但不会被杀掉重启。Spring Boot 3.2 还支持 management.endpoint.health.probes.enabled=true 自动暴露 /actuator/health/liveness 和 /actuator/health/readiness。

自定义健康检查时,别把“慢”当成“挂”:

@Component
public class OrderQueueHealthIndicator implements HealthIndicator {

    private final OrderQueue orderQueue;

    public OrderQueueHealthIndicator(OrderQueue orderQueue) {
        this.orderQueue = orderQueue;
    }

    @Override
    public Health health() {
        int backlog = orderQueue.backlog();
        if (backlog > 10_000) {
            return Health.down()
                    .withDetail("backlog", backlog)
                    .withDetail("reason", "队列积压超过阈值")
                    .build();
        }
        return Health.up().withDetail("backlog", backlog).build();
    }
}

摘流水的顺序比配置更重要

只加 server.shutdown=graceful 还不够。Kubernetes 删除 Pod 时,会同时从 Service 端点摘除并发送 SIGTERM,这两个动作是并行的,网关可能还有几百毫秒在往这个 Pod 转发请求。常见做法是加一个 preStop 钩子,先睡几秒再让进程退出:

spec:
  terminationGracePeriodSeconds: 60
  containers:
    - name: order-service
      lifecycle:
        preStop:
          exec:
            command: ["sh", "-c", "sleep 8"]

terminationGracePeriodSeconds 是 Pod 级别的,preStop 是容器级别的,两个要一起看。preStop 睡 8 秒,是给 Service 摘除端点和网关刷新留时间;进程收到 SIGTERM 后,再走 Spring Boot 的优雅停机流程。

Nginx 侧则依赖被动的健康检查:max_fails=2 fail_timeout=10s,实例连续失败两次后暂时摘除。它不是实时的,所以应用停机前最好主动把 readiness 置为 REFUSING_TRAFFIC,等一个健康检查周期再退出。如果网关不支持主动摘流,至少要保证 preStop 的时间大于健康检查周期,别让请求打到已经收到 SIGTERM 的实例。

本机验证可以直接发信号:

kill -TERM $(pgrep -f order-service.jar)
docker stop -t 40 order-service

观察日志中正在处理的请求是否在超时前完成,再确认线程池、连接池的关闭顺序。

常见坑

1. 只配了 Web 优雅停机

server.shutdown=graceful 只管 Tomcat/Netty 的请求线程。@Async 线程池、@Scheduled 任务、MQ 消费线程都不在它的管理范围内。这些组件要么自己实现 SmartLifecycle,要么在 @PreDestroy 里先停止接收任务、再等待、最后强制关闭。

2. 探针配置反了

把数据库检查放进 liveness,数据库一抖动就触发全量重启,故障被放大。liveness 只判断进程是否还能响应,readiness 才判断业务依赖是否就绪。

3. 超时时间互相打架

terminationGracePeriodSeconds 小于应用的 shutdown timeout,Kubernetes 会先发 SIGKILL;反过来,应用等待太久,发布窗口被拉长。两个值要一起算,通常容器侧留出 10 到 20 秒余量。

4. 健康检查接口暴露太多信息

show-details: always 会把数据库地址、磁盘路径、依赖状态返回给调用方。生产环境要么只在内网暴露,要么改成 when-authorized,并给 Actuator 加独立端口和访问控制。

5. 用 kill -9 做“快速发布”

SIGKILL 没有优雅停机可言,连接池、缓冲区都不会刷盘。脚本里图省事写 kill -9,丢数据只是概率问题。

小结

优雅停机的完整链路是:网关停止转发 → readiness 变为拒绝流量 → 进程收到 SIGTERM → Web 容器等待在途请求 → 业务线程池和连接池依次关闭。任何一环缺失,配置看起来生效了,实际仍可能丢请求。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者