本节目标:把 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/liveness | livenessState | livenessProbe |
| readiness | /actuator/health/readiness | readinessState | readinessProbe |
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 包:
| 类型 | 取值 | 含义 |
|---|---|---|
LivenessState | CORRECT / BROKEN | 进程内部状态是否正常 |
ReadinessState | ACCEPTING_TRAFFIC / REFUSING_TRAFFIC | 是否愿意接收流量 |
AvailabilityChangeEvent | 携带上述状态的事件 | 状态变更的载体 |
ApplicationAvailability | Bean | 读取当前状态的接口 |
这些状态由 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 时,事件顺序大致是:
- Pod 被标记
Terminating,同时从 Service 的 Endpoints 中移除(这一步是异步的)。 preStophook 执行(若配置了)。preStop结束后,向容器发送SIGTERM。- 应用开始优雅停机:readiness 变
REFUSING_TRAFFIC,Tomcat 停收新连接、等在途请求。 - 从「开始 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 与指标 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。