《Spring Boot 实战》15.3 健康检查与优雅停机

讲清 Actuator 健康分组(liveness/readiness)与 K8s 探针的对应、probes.enabled 在 4.x 的默认变化、AvailabilityChangeEvent 与 ReadinessState/LivenessState 状态机、server.shutdown=graceful 与停机超时的配合及 preStop 时序对齐。

本节目标:把 Actuator 的健康端点分组、可用性状态机与容器停机时序讲成一条完整的链路,让 Pod 在扩容、缩容、滚动更新时既不早摘流量也不丢在途请求。
适用版本:Spring Boot 4.1.x(Java 21)

15.3 健康检查与优雅停机

上一节在 Deployment 里写了 livenessProbe 指向 /actuator/health/liveness、readinessProbe 指向 /actuator/health/readiness,但没有展开这两个端点是什么。这一节把应用侧的机制补齐:Actuator 怎么把内部状态映射成健康分组、这些分组怎么对应到 Kubernetes 探针、停机时 Tomcat 如何停止接收新请求并等待在途请求,以及 preStop 与 terminationGracePeriodSeconds 的时间预算怎么算。涉及集群行为的输出均为示例。

健康端点分组:liveness 与 readiness

/actuator/health 默认聚合所有健康指示器(数据库、磁盘、Redis……)。这个「全量」端点适合人看,不适合直接当探针——15.2 节讲过,把含外部依赖的检查接到 liveness 上会引发重启风暴。

Spring Boot 的做法是提供健康分组。默认自动配置两组:

分组端点默认包含对应探针
liveness/actuator/health/livenesslivenessStatelivenessProbe
readiness/actuator/health/readinessreadinessStatereadinessProbe

liveness 组只含内部状态指示器,不碰数据库,因此可以安全地接 liveness 探针。readiness 组反映「是否愿意接流量」,你可以把外部依赖加进去。两个端点的返回是标准的健康 JSON:

{"status":"UP"}

当进程内部状态为 BROKEN 时,/actuator/health/liveness 返回 DOWN;当实例主动拒绝流量时,/actuator/health/readiness 返回 OUT_OF_SERVICE(HTTP 503),Kubernetes 据此把 Pod 摘出 Service。

probes.enabled 与 4.x 的默认变化

这里有一个跨版本会踩的点:liveness/readiness 分组是否默认存在,取决于版本与运行环境。

  • 2.3 引入时,属性叫 management.health.probes.enabled,且默认关闭,只在检测到运行在 Kubernetes 上时自动打开。
  • 4.0 起,探针默认开启,属性也改名为 management.endpoint.health.probes.enabled(4.0 配置变更日志里的重命名项)。

所以在 Spring Boot 4.1.x 上,你不写任何配置,/actuator/health/liveness 与 /actuator/health/readiness 就已经可用。如果出于某种原因不想要它们(比如端点暴露面要收紧),用新名字显式关掉:

management.endpoint.health.probes.enabled=false

升级到 4.x 时若还写着旧名 management.health.probes.enabled,会被静默忽略——它不再是有效属性,表现为「我以为关了,其实没关」或反过来。这类改名在 4.0 的 Configuration Changelog 里有完整清单,升级前值得过一遍。

自定义健康组:把依赖放进 readiness

默认分组只含内部状态,很多时候不够:你希望「数据库不可用时这个 Pod 就不要再接流量」。正确做法是把依赖加进 readiness 组,而不是 liveness:

management.endpoint.health.group.readiness.include=readinessState,db
management.endpoint.health.group.liveness.include=livenessState

这样数据库断连时,/actuator/health/readiness 变 OUT_OF_SERVICE,Pod 被摘出流量;数据库恢复后自动加回,全程不重启。

组定义有几条规则要记住:

  • include 里写的是健康指示器的名字(db、redis、readinessState),不是 Bean 名。
  • 3.1 起有 management.endpoint.health.validate-group-membership(默认 true),若 include 引用了不存在的指示器,启动时直接失败。这个「快速失败」是好事——它把拼错的组配置挡在启动阶段,而不是等到线上摘流量时才发现组是空的。
  • 同样可以给组配 show-details、show-components,控制返回的详细程度。生产上别对匿名调用者暴露细节。

一个需要权衡的点:把 db 放进 readiness 意味着数据库一抖,所有 Pod 一起被摘流量,整站返回 503。 有些团队更愿意让 readiness 只反映「应用自身能否处理请求」,数据库故障由应用层的降级/重试兜住,避免「依赖一抖就整站摘空」。这是可用性策略选择,没有唯一答案,但必须有意选择,而不是默认踩坑。

AvailabilityChangeEvent 与状态机

分组背后是一套可用性状态机,核心是 org.springframework.boot.availability 包:

类型取值含义
LivenessStateCORRECT / BROKEN进程内部状态是否正常
ReadinessStateACCEPTING_TRAFFIC / REFUSING_TRAFFIC是否愿意接收流量
AvailabilityChangeEvent携带上述状态的事件状态变更的载体
ApplicationAvailabilityBean读取当前状态的接口

这些状态由 Spring Boot 在生命周期节点自动发布:应用启动到可以服务时把 readiness 置为 ACCEPTING_TRAFFIC,收到停机信号时置为 REFUSING_TRAFFIC。你可以在代码里读当前状态:

import org.springframework.boot.availability.ApplicationAvailability;
import org.springframework.boot.availability.LivenessState;
import org.springframework.boot.availability.ReadinessState;
import org.springframework.stereotype.Component;

@Component
public class AvailabilityReporter {

    private final ApplicationAvailability availability;

    public AvailabilityReporter(ApplicationAvailability availability) {
        this.availability = availability;
    }

    public boolean canServe() {
        ReadinessState readiness = availability.getReadinessState();
        LivenessState liveness = availability.getLivenessState();
        return readiness == ReadinessState.ACCEPTING_TRAFFIC
                && liveness == LivenessState.CORRECT;
    }
}

更常见的是主动发布事件来摘流量。典型场景:book-loan 依赖的一个下游系统进入维护,你希望本实例先停止接流量、等在途请求处理完,再让运维重启它:

import org.springframework.boot.availability.AvailabilityChangeEvent;
import org.springframework.boot.availability.ReadinessState;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
public class DrainService {

    private final ApplicationEventPublisher publisher;

    public DrainService(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    /** 手动把本实例标记为"拒绝流量",readiness 端点随即变 OUT_OF_SERVICE */
    public void startDraining() {
        AvailabilityChangeEvent.publish(publisher, this, ReadinessState.REFUSING_TRAFFIC);
    }

    /** 故障排除后恢复接流量 */
    public void resume() {
        AvailabilityChangeEvent.publish(publisher, this, ReadinessState.ACCEPTING_TRAFFIC);
    }
}

发布 REFUSING_TRAFFIC 后,readinessProbe 会在下一个探测周期失败,Pod 被摘出 Service 端点。这就是「主动排水(drain)」的标准做法。注意不要用它去改 LivenessState:把 liveness 置 BROKEN 等于请求 Kubernetes 重启自己,除非你真的想让容器重启,否则别碰。

优雅停机:server.shutdown=graceful

Pod 被删除或滚动更新时,kubelet 向容器主进程发 SIGTERM。如果应用不处理,JVM 默认直接退出,正在处理的请求会被硬断,客户端看到连接重置。

Spring Boot 的优雅停机让 Web 服务器在收到停机信号后先停止接受新连接、再等待在途请求完成。这里有个必须纠正的认知:server.shutdown 从 3.4 起默认就是 graceful,不是需要手动开启的选项。也就是说在 4.1.x 上,你什么都不配,优雅停机就已经生效。

# 4.x 默认即为 graceful;这一行写出来只是"显式声明意图"
server.shutdown=graceful

# 每个停机阶段等待在途请求的最长时间,默认 30s
spring.lifecycle.timeout-per-shutdown-phase=25s

若因故要退回「立即关闭」(例如无状态的批处理任务,无需等请求),显式设:

server.shutdown=immediate

Tomcat 侧的机制是:收到停机信号后,连接器不再接受新的连接,已建立的连接允许把当前请求跑完,直到超时。下面是本机实测的 4.1.1 停机日志(来自纯 Spring Boot 应用的优雅停机,非外部依赖场景):

2026-10-04T18:00:08.968+08:00  INFO 43496 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown         : Commencing graceful shutdown. Waiting for active requests to complete
2026-10-04T18:00:09.015+08:00  INFO 43496 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown         : Graceful shutdown complete

注意 4.x 的日志类名是 o.s.boot.tomcat.GracefulShutdown(3.x 是 o.s.b.w.e.tomcat.GracefulShutdown)——这是 4.0 模块化重构的直接证据,写排查文档时别照抄旧包名。

timeout-per-shutdown-phase 决定「等待多久」:如果某个在途请求处理时间超过这个值,Tomcat 会放弃等待并关闭。所以这个值要大于你最长请求的 P99,否则最慢的那批请求仍会被截断。它同时是 spring.lifecycle 的通用阶段超时,作用于所有 SmartLifecycle Bean 的停机阶段。

与 preStop / terminationGracePeriodSeconds 的时序对齐

只开优雅停机还不够,必须理解 Kubernetes 的停机时序,否则会出现「应用已经很努力地在等了,请求还是丢了」。删除一个 Pod 时,事件顺序大致是:

  1. Pod 被标记 Terminating,同时从 Service 的 Endpoints 中移除(这一步是异步的)。
  2. preStop hook 执行(若配置了)。
  3. preStop 结束后,向容器发送 SIGTERM。
  4. 应用开始优雅停机:readiness 变 REFUSING_TRAFFIC,Tomcat 停收新连接、等在途请求。
  5. 从「开始 Terminating」起算,超过 terminationGracePeriodSeconds 后,kubelet 发 SIGKILL 强杀。

问题出在第 1 步的异步性:Endpoints 变更要经 kube-proxy / ingress 控制器传播,通常需要几秒。在这几秒里,负载均衡器仍可能把新请求发到正在关闭的 Pod。如果 Pod 收到 SIGTERM 就立刻停收连接,这段窗口里的请求会得到连接被拒。

标准解法是用 preStop 加一段「等负载均衡摘除」的延迟,让 SIGTERM 晚于端点传播完成:

lifecycle:
  preStop:
    exec:
      command: ["sh", "-c", "sleep 5"]
terminationGracePeriodSeconds: 45

preStop 里睡 5 秒,等于给端点传播留出缓冲;这 5 秒里应用仍在正常服务。等 SIGTERM 到达时,负载均衡器通常已经不再往这个 Pod 发新请求,优雅停机只需要处理存量在途请求。

时间预算必须算清楚,否则第 5 步会前功尽弃:

terminationGracePeriodSeconds
    ≥ preStop 耗时 + 最长在途请求耗时 + 停机收尾开销

上例是 45s ≥ 5s + ~25s(受 timeout-per-shutdown-phase 约束) + 收尾,留了余量。如果 terminationGracePeriodSeconds 小于「preStop + 等待在途」之和,kubelet 会在应用还没排空时就 SIGKILL,表现为「优雅停机配了但偶尔仍有请求被断」。排查这类问题时,先核对这三个数字的大小关系。

另一种替代 preStop sleep 的做法是让应用自己延迟响应停机信号:在收到 SIGTERM 后先等几秒再开始关连接。两种都能解决端点传播窗口,preStop sleep 更简单、与语言无关,是社区更常用的做法。

把实例从负载均衡摘除

把上面的机制串起来,「摘除」其实有两条路径,它们互补:

  • 被动路径:停机时 Spring Boot 自动把 ReadinessState 置为 REFUSING_TRAFFIC,/actuator/health/readiness 变 OUT_OF_SERVICE,readinessProbe 失败后 kubelet 把 Pod 从 Endpoints 移除。但探测有周期(上例 periodSeconds: 5),存在检测延迟。
  • 主动路径:preStop sleep 人为留出传播时间;或者业务侧在维护前调用前面的 DrainService.startDraining() 提前排水。

只依赖被动路径时,检测延迟 = readiness 探测周期 + 端点传播时间,可能长达十几秒,期间新请求仍会打到正在关闭的 Pod。所以**preStop sleep 是零中断停机里性价比最高的一步**。此外要确认 Service 的 Endpoints 真的被更新了——kubectl get endpoints book-loan 在滚动更新期间应当看到 Pod IP 的增删,如果 Endpoints 一直是空的或不变的,说明 readiness 探针根本没生效,后面的时序讨论都无从谈起。

一份完整的应用侧配置

把本节的应用侧配置汇总(对应的 Deployment 侧见 15.2):

server:
  shutdown: graceful              # 4.x 默认即此,显式写出便于阅读

spring:
  lifecycle:
    timeout-per-shutdown-phase: 25s

management:
  endpoint:
    health:
      probes:
        enabled: true             # 4.x 默认开启,此处仅为显式
      group:
        liveness:
          include: livenessState
        readiness:
          include: readinessState,db
  endpoints:
    web:
      exposure:
        include: health,info

配套的 Deployment 关键片段(preStop + 宽限期):

spec:
  template:
    spec:
      terminationGracePeriodSeconds: 45
      containers:
        - name: app
          lifecycle:
            preStop:
              exec:
                command: ["sh", "-c", "sleep 5"]
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            periodSeconds: 10

常见坑

  • 继续用旧属性名 management.health.probes.enabled:4.0 已改名为 management.endpoint.health.probes.enabled,旧名被静默忽略,行为与预期不符。
  • 以为优雅停机要手动开:3.4 起 server.shutdown 默认就是 graceful;真正的坑是以为默认是 immediate 而重复配置,或反过来在需要立即关闭时忘了显式设 immediate。
  • timeout-per-shutdown-phase 小于最长请求:最慢的在途请求仍被截断,表现为「偶发 502」。
  • terminationGracePeriodSeconds 没算上 preStop:宽限期太短,应用还没排空就被 SIGKILL。
  • 没有 preStop,只靠 readiness 摘除:端点传播的几秒窗口里,新请求会打到正在关闭的 Pod。
  • 把依赖放进 liveness 组:数据库抖动导致全体重启(雪崩),应放 readiness 组。
  • 用 LivenessState.BROKEN 来做排水:那是在请求 Kubernetes 重启容器,不是摘流量。

小结

健康检查与优雅停机是一条链:Actuator 用健康分组把内部状态映射成 liveness / readiness 两个端点,Kubernetes 用三种探针消费它们;probes.enabled 在 4.0 起默认开启且已改名,AvailabilityChangeEvent 让你能主动排水。停机侧,server.shutdown=graceful 自 3.4 起就是默认,Tomcat 会停收新连接并等待在途请求,等待上限由 spring.lifecycle.timeout-per-shutdown-phase 控制;要真正做到零中断,还必须用 preStop 覆盖端点传播窗口,并保证 terminationGracePeriodSeconds 覆盖「preStop + 在途请求 + 收尾」的总预算。

健康端点解决的是「活着、能服务」,下一个问题是对运行中的服务「看得见」——指标、追踪与日志。这正是下一章的主题。

阅读导航:上一节:15.2 Kubernetes 部署要点 · 下一节:16.1 Actuator 与指标 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计