场景:为什么"停机"这件事不简单

一次普通的发布滚动更新,监控却报出一批 5xx 和连接重置。排查后发现:Kubernetes 给 Pod 发了 SIGTERM,JVM 立刻退出,而此时还有几十个 HTTP 请求正在处理、数据库事务尚未提交、消息消费者刚拉了一批消息还没 ack。这些请求被硬生生掐断,对外表现就是偶发的失败。

优雅停机(graceful shutdown)要解决的核心问题是:收到停机信号后,先停止接收新流量,把存量请求处理完,再释放资源、退出进程。 而健康检查则是流量调度层判断"这个实例还能不能接活"的依据。两者必须配合,否则单独打开优雅停机反而可能拉长 502 窗口。

机制拆解:一次干净停机的完整时序

理解优雅停机,要先理解信号和容器调度的交互。一个典型的 K8s 下线流程:

1
2
3
4
5
6
7
8
1. 控制面将 Pod 标记为 Terminating,同时:
a. 从 Service 的 Endpoints 中摘除该 Pod(异步,有传播延迟)
b. 向容器 PID 1 发送 SIGTERM
2. 应用收到 SIGTERM,开始优雅停机:
- 关闭 Web server 的 acceptor(不再接受新连接)
- 等待在途请求处理完毕(有超时上限)
- 关闭线程池、连接池、消息消费者
3. 若超过 terminationGracePeriodSeconds 仍未退出,发送 SIGKILL 强杀

这里有一个极其容易被忽略的竞态:步骤 1a(摘除 Endpoint)和 1b(发 SIGTERM)是并行的,且摘除会经过 kube-proxy / iptables / ingress 多级传播。如果应用收到 SIGTERM 后立刻拒绝新连接,但此时上游负载均衡还没更新路由,新请求仍会被打过来,直接连接拒绝。

所以正确做法不是"立即停",而是"先睡一会儿,等路由收敛,再开始停"。这就是所谓的 pre-stop 静默期。

Spring Boot 的实现

Spring Boot 内建了优雅停机支持。关键配置只有两行:

1
2
3
4
5
server:
shutdown: graceful # 默认是 immediate
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 等待在途请求的上限

开启后,当容器关闭时,WebServerGracefulShutdownLifecycle 会被触发,内部对 Tomcat/Netty 做这样的事:暂停 connector 的 acceptor 线程,然后轮询工作线程池,直到活跃请求归零或超时。

源码层面,Spring 通过 SmartLifecycle 的 phase 机制来编排关闭顺序。Web server 属于最高 phase(Integer.MAX_VALUE),所以最先关闭——这保证了在关掉业务线程池、数据源之前,先停掉流量入口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// GracefulShutdown(Tomcat 实现简化)
public void shutDownGracefully(GracefulShutdownCallback callback) {
// 1. 暂停每个 connector,不再 accept 新连接
for (Connector connector : connectors) {
connector.pause();
}
// 2. 后台线程轮询活跃请求数
new Thread(() -> {
while (System.currentTimeMillis() < endTime) {
if (activeRequestCount() == 0) {
callback.shutdownComplete(GracefulShutdownResult.IDLE);
return;
}
Thread.sleep(50);
}
// 超时仍未完成,强制结束
callback.shutdownComplete(GracefulShutdownResult.REQUESTS_ACTIVE);
}).start();
}

注意一个边界:优雅停机只覆盖"在途的同步 HTTP 请求"。异步任务、@Async 线程池、Kafka/RabbitMQ 消费者、定时任务,Spring 不会自动等它们——这些需要你自己声明 DisposableBean 或注册 shutdown hook,并通过 phase 排在 Web server 之后关闭。

健康检查:liveness 与 readiness 的本质区别

很多人把这两个探针混为一谈,但它们的语义完全不同:

  • readiness(就绪):我现在能不能接收流量?失败 → 从 Endpoints 摘除,但不重启
  • liveness(存活):我这个进程还活着、有救吗?失败 → 直接重启容器

把它们搞反会出严重事故。典型反模式:把 readiness 探针配成检查下游数据库连通性。当数据库抖动,所有实例的 readiness 同时失败,全部被摘流量,服务整体雪崩——即使数据库只是短暂慢了一下。liveness 探针更要谨慎:如果它依赖外部资源,一次外部抖动会触发全集群滚动重启,把小故障放大成大故障。

经验法则:

  • liveness 只检查进程自身是否死锁/卡死,不依赖任何外部系统
  • readiness 可以检查关键依赖,但要对"非致命依赖"宽容。

Spring Boot Actuator 把这套模型直接暴露了出来:

1
2
3
4
5
6
7
8
9
10
management:
endpoint:
health:
probes:
enabled: true # 暴露 /actuator/health/{liveness,readiness}
group:
readiness:
include: readinessState, db, redis
liveness:
include: livenessState

更妙的是,Spring 的 ApplicationAvailability 会在优雅停机开始的瞬间,把 readiness 状态自动切到 REFUSING_TRAFFIC。配合 K8s 的 readiness 探针,就能在进程还活着、还在处理存量请求时,主动告诉调度层"别再给我发新的了"。

工程权衡:把时间预算对齐

最常见的线上踩坑是各层超时没对齐,导致优雅停机形同虚设。必须满足这个不等式:

1
preStop 静默期 + Spring 在途请求超时 < terminationGracePeriodSeconds

举例,一套自洽的配置:

1
2
3
4
5
6
# K8s
terminationGracePeriodSeconds: 60
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 15"] # 等路由收敛
1
2
# Spring
spring.lifecycle.timeout-per-shutdown-phase: 30s

时间账:15s 静默 + 最多 30s 处理在途 = 45s < 60s,留 15s 余量给连接池等其它资源关闭。如果反过来,terminationGracePeriodSeconds 设成 30 而 Spring 超时设了 30,那么静默期一过就被 SIGKILL,优雅完全失效。

另一个权衡是静默期的代价sleep 15 意味着每个 Pod 下线都要多花 15 秒,大规模滚动发布时整体发布时长被显著拉长。静默期不是越长越好,取决于你的负载均衡路由收敛速度——纯 K8s Service 通常几秒内收敛,而经过外部 LB / Ingress / 网格的链路可能需要更久,要按实测调。

还要注意:即使配置完美,长连接(WebSocket、SSE、gRPC 流) 仍然是难点。它们的"在途请求"可能持续数分钟,优雅停机超时根本等不起,只能靠客户端重连机制兜底。

小结

优雅停机的精髓不是"等请求处理完"这一句话,而是理解摘流量与停进程之间的竞态:先用 preStop 静默期等上游路由收敛,再借 Spring 的 SmartLifecycle phase 让 Web 入口最先关闭、业务资源随后释放,最后用对齐的超时预算兜底。健康检查则要严守 liveness 与 readiness 的语义边界——liveness 不碰外部依赖,readiness 对非致命依赖宽容——否则探针本身就会成为故障放大器。把这三者(信号时序、关闭顺序、超时预算)拧成一个整体,发布时那批莫名其妙的 5xx 才会真正消失。