Spring Boot 优雅停机:从 SIGTERM 到连接排空,为什么配了 graceful 还是 502
一个挺讽刺的现状
Spring Boot 3.4 之后,优雅停机是默认开着的。Jetty、Reactor Netty、Tomcat、Undertow 四个内嵌容器都一样,Servlet 栈和 Reactive 栈也都一样。你要做的不是打开它,而是——如果你不想要,把它关掉。
但线上发布还是会掉请求。滚动更新的时候,总有几个请求返回 502,或者更难受的:请求发出去了,连接被重置,客户端拿不到任何响应,日志里连个异常都没有。
这不是配置没生效,而是"优雅停机"这四个字被过度解读了。它做的只有一件事:在关闭 Web 容器的过程中,给已经进来的 HTTP 请求一段完成时间,同时不再接纳新请求。 仅此而已。它不管你线程池里排队的任务,不管你的 Kafka 消费者,不管你 @PreDestroy 里那个远程调用,更不管 Kubernetes 什么时候把你的 Pod 从 Endpoints 里摘掉。
把这些边界摸清楚,502 才解释得通。
从信号到进程退出,中间发生了什么
先建立时序概念,后面所有结论都挂在这条链上。
k8s / docker stop / kill -TERM / Ctrl+C
│
▼
SIGTERM 送到 JVM
│
▼
JVM 执行已注册的 Shutdown Hook
│
▼
SpringApplicationShutdownHook
│ (发布 ContextClosedEvent)
▼
ApplicationContext.close()
│
├─ LifecycleProcessor.onClose()
│ │ 按 phase 从大到小依次停止 SmartLifecycle
│ ▼
│ Web 服务器所在的生命周期(最早被停的一批)
│ │
│ ▼
│ 暂停接收新连接 / 新请求
│ │
│ ▼
│ 等待 in-flight 请求完成,最长 timeout-per-shutdown-phase
│ │
│ ▼
│ 超时或全部完成 → 真正关闭连接器
│
├─ 销毁 Bean:@PreDestroy → DisposableBean.destroy()
│ (线程池、连接池、消息监听容器在这里停)
│
▼
进程退出
有两个位置值得单独指出。
第一,Web 服务器的停止发生在 SmartLifecycle 阶段,而且是最早被停的那一批(SmartLifecycle 停止时按 phase 降序执行,Web 服务器的 phase 值刻意设得很大,保证它比数据源、比业务 Bean 先停)。这个设计意图很明确:先停止对外服务,再拆内部设施。顺序反了就会有一堆"请求进来但数据源已关"的报错。
第二,@PreDestroy 和 DisposableBean.destroy() 在 Web 层关闭之后才执行,而且不受 grace period 保护。这段逻辑不占那 30 秒的配额,它是独立进行的。
这两点合起来解释了大部分"配了 graceful 还是出问题"的疑惑:你以为整个停机过程都被保护了,实际被保护的只有 HTTP 请求的收尾。
三个默认值,版本之间差别很大
很多人脑子里装的还是 2020 年的知识,那会儿优雅停机要手动开。现在的默认值已经变了,先对齐这个:
| 项目 | Spring Boot 2.3 – 3.3 | Spring Boot 3.4 及以后 |
|---|---|---|
server.shutdown 默认值 |
immediate |
graceful |
| 是否需要手动开启 | 需要 | 不需要(要关掉才配) |
spring.lifecycle.timeout-per-shutdown-phase 默认值 |
30s | 30s |
| 支持的容器 | Tomcat / Jetty / Undertow / Netty | 同上 |
3.4 的 release notes 原话是:内嵌 Web 服务器的优雅停机现在默认启用,如果需要恢复旧行为,把 server.shutdown 设成 immediate。
所以如果你在 3.4+ 的项目里看到 server.shutdown=graceful 这一行,它是冗余的——不报错,但会误导后来的人以为这是开关。更麻烦的是它会掩盖一个事实:你的停机行为其实是默认值给的,不是你配的。这在排查时会造成判断偏差。
顺带纠正一个常见的误读:spring.lifecycle.timeout-per-shutdown-phase 是每个 phase 的超时,不是整个停机过程的总时长。属性名里的 per-shutdown-phase 写得很清楚。如果某个环节有多个 phase 需要停,理论上总耗时可以是超时值的好几倍。实际上 Web 服务器那个 phase 是主要消耗者,其他 phase 很快,所以体感上接近 30 秒,但别把它当成硬性的总预算去做 SLA 承诺。
grace period 到底在等什么
官方描述是:关闭应用上下文的过程中执行,发生在停止 SmartLifecycle Bean 的最早阶段,期间已有的请求被允许完成,新请求不被接纳。
关键词是"已有的请求"——指已经被容器接受、正在处理中的 HTTP 请求。判断标准是容器层面的 active request 计数,不是你以为的"业务上还没处理完的事"。
这就带出第一个大坑。
坑一:线程池里的异步任务,不在保护范围
这是最典型的一种。接口很快返回了 202 Accepted,实际活儿丢给了 @Async 线程池。发布时 Pod 收到 SIGTERM,graceful 一看:HTTP 请求数 0,可以关。于是上下文继续销毁,线程池被 shutdown,任务被中断。
ThreadPoolTaskExecutor 的默认行为是什么?它的 shutdown() 方法里,如果 waitForTasksToCompleteOnShutdown 为 false(这是默认值),走的是 shutdownNow()——队列里的任务直接丢弃,正在执行的任务收到中断信号。
于是你看到的现象是:发布期间一批异步任务凭空消失,没有异常日志,因为任务被中断时如果没写 try-catch,InterruptedException 只会在被吞掉之后消失得无影无踪;账目对不上,但对账时间点在几小时后,根本联系不到"昨晚发了个版"。
修法分两种情况,取决于你的线程池是自动配置的还是自己 new 的。
如果用的是 Spring Boot 自动配置的 applicationTaskExecutor(也就是直接 @Async 不指定 executor,或者注入 AsyncTaskExecutor),配两个属性就行:
spring:
task:
execution:
shutdown:
await-termination: true # 默认 false,必须显式打开
await-termination-period: 20s # 等待上限
但如果项目里自己 new 了一个 ThreadPoolTaskExecutor 注册成 Bean(国内项目里极常见,因为要自定义线程名前缀、拒绝策略、队列容量),上面这两个属性对它完全无效。全局属性只作用于 Boot 自己创建的那个线程池,一旦你接管了创建过程,就得自己在代码里设:
@Bean("applicationTaskExecutor")
public ThreadPoolTaskExecutor applicationTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("app-async-");
// 关键两行:等待已提交任务执行完,最多等 20 秒
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(20);
executor.initialize();
return executor;
}
这个"属性不生效"的坑很隐蔽:yml 里明明写了 await-termination: true,启动日志也不报错,停机时任务照样丢。因为压根没有代码去读这个属性——它只被 TaskExecutorBuilder 使用,而你绕过了 builder。
setWaitForTasksToCompleteOnShutdown(true) 让 shutdown() 变成温和的 shutdown()(不再 shutdownNow),setAwaitTerminationSeconds(20) 给一个上限,避免任务卡死导致进程永远退不掉。
注意这两个值要和 grace period 配合看:如果任务最长可能跑 40 秒,而 awaitTerminationSeconds 只给 20 秒,那还是一样会丢。停机总时长 = grace period + 线程池等待时间 + 其他销毁时间,部署平台的 terminationGracePeriodSeconds 必须大于这个总和,否则 Kubernetes 会在你收尾之前直接 SIGKILL,前面配的一切全部作废。
这个链条是最容易被忽略的:你以为在配 Spring,实际在配容器的杀进程时间。
坑二:SSE、WebSocket、长轮询会把 grace period 耗满
grace period 等的是 active request 归零。而 SSE 的特点恰恰是——一个请求可以挂几十分钟。
如果你的项目里有实时推送(SseEmitter 或者直接 GraphQL subscription 那类长连接),发布时会出现:容器一直在等,日志里看不出任何异常,30 秒过去了,超时强关,所有在线用户的连接同时断掉。客户端重连,用户体验上就是"每隔一段时间界面闪一下"。
这类连接不能靠 graceful 解决,得自己管。思路是:在停机开始前,主动通知客户端"我要走了",让它自己重连到别的实例。
@Component
public class SseConnectionRegistry {
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
/** 停机时主动向所有客户端推一条关闭事件,再关闭连接 */
@EventListener(ContextClosedEvent.class)
public void onShutdown() {
emitters.forEach((id, emitter) -> {
try {
emitter.send(SseEmitter.event().name("server-shutdown").data("reconnect"));
emitter.complete();
} catch (Exception ignored) {
// 客户端已断开,忽略
}
});
emitters.clear();
}
}
ContextClosedEvent 在 SmartLifecycle 停止之前发布,所以这个监听器比 Web 容器关闭更早执行——用它做"提前通知"是合适的。但要注意:这个监听器里做的事是同步的,别在里面做耗时操作,否则会直接拖慢整个停机。
同样的问题也存在于 WebSocket:容器不会因为你握手了就认为它该结束。要在停机前主动发 close 帧(CloseStatus.SERVICE_RESTARTED 是为此准备的语义),客户端收到特定状态码就知道是重启而非故障,重连策略可以更激进。
坑三:超时之后是强杀,客户端看到的东西不一样
30 秒到了还有请求没完成,容器不会继续等,会直接关闭。这时候客户端体验分几种情况,取决于请求处在哪个阶段:
- 请求还没发出去、连接还没建立:连接被拒绝,
ConnectException - 请求已发出、服务端还没开始响应:连接被重置,
SocketException: Connection reset - 响应已经开始写、但没写完:客户端拿到截断的响应体,
Content-Length对不上,解析失败
第三种最坑,因为它看起来像"业务数据损坏",不像"发布导致的"。如果接口返回的是分页 JSON,客户端可能解析到一半报错;如果是文件下载,用户拿到的是半个文件,而且没有任何提示。
这也解释了为什么"发布期间偶发 502"的锅经常被甩给网关。链路是这样的:Spring Boot 在 30 秒后关掉连接器,网关那边看到 upstream 连接断了,按自己的策略返回 502 或 504。日志里是网关的错,根因在应用。
排查这一步的关键是看时间:502 的时间点,是不是正好在 SIGTERM 之后 30 秒左右。 如果是,就不用查网关配置了,去查应用里哪个接口跑得比 30 秒还久。
坑四:本地根本测不出来
这是让人怀疑人生的地方。你在 IDEA 里点红色停止按钮,配了 graceful,结果请求秒断——是不是没生效?
官方文档专门提了这一句:在 IDE 里停机可能是立即的而不是优雅的,因为 IDE 可能没有发真正的 SIGTERM 信号。
IDEA 的停止按钮在多数情况下走的是"销毁进程"路径,不是发信号;有些版本的"Exit"和"Stop"行为还不一样。于是你在本地永远看不到 grace period 的效果,只能靠猜。
想真实验证,用命令行发信号:
# 启动应用后拿到 PID,先发起一个慢请求,再发 TERM
curl -N http://localhost:8080/api/slow &
kill -TERM <pid>
慢接口随便写一个:
@GetMapping("/api/slow")
public String slow() throws InterruptedException {
Thread.sleep(10_000);
return "done";
}
预期现象:kill -TERM 之后,curl 依然会在 10 秒后拿到 done,应用随后退出。如果 curl 立刻报错退出,说明 graceful 没生效——这时候去查 server.shutdown 是不是被谁显式设成了 immediate(配置中心、profile 覆盖、甚至某个 starter 的默认值都可能改它)。
同样值得验证的是"新请求被拒":kill -TERM 之后立刻再 curl 一次,应该被拒绝或者在网络层失败。这一步能确认容器确实停止接受新请求了,而不是"还在收,只是慢慢处理"。
坑五:信号到不了 JVM
容器化部署下,这条链路经常断在 Dockerfile 上。
# 错误:shell 形式,PID 1 是 /bin/sh,Java 进程收不到 SIGTERM
ENTRYPOINT java -jar /app.jar
# 正确:exec 形式,Java 直接是 PID 1
ENTRYPOINT ["java", "-jar", "/app.jar"]
shell 形式下,/bin/sh 成为 PID 1,它不会把信号转发给子进程。docker stop 发出 SIGTERM 后等 10 秒,看不到进程退出就直接 SIGKILL。表现就是:每次停容器都是"硬停",优雅停机一次都没跑起来,而且没有任何日志能提示你。
这在按装依赖、写 Dockerfile 的时候最容易出错,因为两种写法在本地都能跑起来,看不出区别。
另外,PID 1 在 Linux 上对信号的处理有特殊语义——内核不会给 PID 1 应用默认的信号处理行为。Java 自己注册了 shutdown hook,所以直接作为 PID 1 是能正常收到 SIGTERM 的;但如果中间隔了一层 shell,信号就丢了。实在需要用 shell 启动(比如要展开环境变量),加 exec:
ENTRYPOINT exec java $JAVA_OPTS -jar /app.jar
坑六:Kubernetes 的时序,比你想的慢半拍
这是 502 最主要的来源,而且和 Spring 一点关系都没有。
SIGTERM 送达时,Kubernetes 其实同时做两件事:给 Pod 发信号,以及把 Pod 从 Service 的 Endpoints 里摘掉。但"摘掉"是异步的:kube-proxy 要更新 iptables/IPVS 规则,各节点的同步有时间差。这段时间里,仍然会有新请求被转发到这个正在关闭的 Pod。
结果就是:Spring 已经开始 grace period、不再接受新连接,但流量还在往这儿打,打到就失败。窗口期通常是几百毫秒到几秒,取决于集群规模和网络插件。
标准解法是 preStop 钩子,让 Pod 在收到信号后先"装死"一会儿,等 Endpoints 摘干净再真正开始关闭:
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"]
terminationGracePeriodSeconds: 60
这个 sleep 5 的作用不是"等请求处理完",而是"等流量停止进来"。两者完全不同,很多人配了 preStop 却以为它在等业务逻辑,于是把时间设成 30 秒,白等。
更严谨的做法是配合就绪探针:把 readiness probe 指向一个能反映"准备停机"状态的端点,在 preStop 里先把这个状态置为 false,等一个探针周期(默认 10 秒一次,失败 3 次才摘),再 sleep。但多数场景下,sleep 5 + 合理的 terminationGracePeriodSeconds 已经够用,不必上复杂方案。
terminationGracePeriodSeconds 的默认值是 30 秒。如果你的 grace period 也是 30 秒,那么 Kubernetes 会在 Spring 还在收尾的时候发 SIGKILL——两个 30 秒撞在一起,必然强杀。这是一个非常隐蔽的配置冲突,因为它不会报错,只会表现为"停机日志打了一半就没了"。
经验值是:terminationGracePeriodSeconds ≥ preStop sleep + grace period + 线程池等待 + 5 秒余量。上面那套配置就是 5 + 30 + 20 + 5 = 60。
比 sleep 更准的做法:主动交出流量
preStop 里 sleep 5 本质上是在盲等——赌"5 秒内流量一定摘干净"。集群大一点、网络插件慢一点,这个赌就可能输。
Spring Boot 2.3 起内置了一套可用性状态模型,可以把这个"盲等"变成"确定的握手":ApplicationAvailability 加 AvailabilityChangeEvent。它维护两个状态:
- LivenessState:
CORRECT/BROKEN,表示"我这个进程还该不该活着"。挂了 Kubernetes 会重启容器; - ReadinessState:
ACCEPTING_TRAFFIC/REFUSING_TRAFFIC,表示"我该不该接流量"。挂了只是从 Service 里摘掉,不重启。
配合 management.endpoint.health.probes.enabled=true(在 Kubernetes 环境下 Boot 会自动开启),Actuator 会暴露 /actuator/health/liveness 和 /actuator/health/readiness 两个端点,探针直接指过去就行。
关键在于 readiness 是可以被应用自己改的。这样可以做成一次显式握手:先主动把 readiness 置为 REFUSING_TRAFFIC,给"停止转发流量"一个明确信号,再让进程退出。
这里要说清楚一件事,避免把它的作用夸大了:Pod 进入 Terminating 状态时,Kubernetes 本来就会把它从 Endpoints 里摘掉,不需要应用配合。主动置 readiness 的价值在于另外两处——一是流量不经过 Service Endpoints 而由负载均衡器直接探测 Pod readiness 的场景(不少 Ingress 和云厂商 ALB 是这种模式),这一步能让它立刻停止转发;二是让状态在 /actuator/health/readiness 上可见,停机过程变得可观测,不用靠猜。
@RestController
@RequiredArgsConstructor
public class PreStopController {
private final ApplicationAvailability availability;
private final ApplicationContext context;
/** preStop 钩子调用:主动放弃流量,让 K8s 尽快摘掉本实例 */
@PostMapping("/internal/prestop")
public String preStop() {
AvailabilityChangeEvent.publish(context, ReadinessState.REFUSING_TRAFFIC);
return "refusing-traffic";
}
}
对应的 preStop 就不再是盲睡了,而是"通知 + 等待摘流量完成":
lifecycle:
preStop:
exec:
command:
- sh
- -c
- "curl -s -X POST http://127.0.0.1:8080/internal/prestop; sleep 8"
那个 sleep 8 是留给 kube-proxy 同步规则的时间,不是留给业务的。8 秒大约是两三个探针周期,比 5 秒稳妥,又比 30 秒克制。
这个做法有三个必须注意的点。
第一,/internal/prestop 千万别暴露到公网。 谁都能调,就等于谁都能让你的实例下线。至少要限制来源(只允许 127.0.0.1 或 Pod 网段),或者干脆走 management.server.port 的管理端口,不对外暴露。
第二,别去动 LivenessState。 停机时把 liveness 置为 BROKEN 会让 Kubernetes 重启容器——本来是要优雅退出,结果变成"杀掉重来",前面的配置全废。这两个状态的分工必须清楚:liveness 是"我坏了",readiness 是"我暂时不接活了"。
第三,这个端点要在停机开始前调用。 preStop 的执行时机是 SIGTERM 之前(更准确地说,Kubernetes 先执行 preStop,等它返回后才发 SIGTERM)。所以这时候 Web 容器还完全正常,端点能正常响应。一旦 SIGTERM 到了,容器开始 grace period,这个端点自己也就调不通了——顺序不能反。
把这套接上之后,停机链路就变成了一条明确的流水线:preStop 主动置为拒绝流量 → 等规则同步 → SIGTERM → grace period 收尾 in-flight 请求 → 线程池等待 → 进程退出。每一环都有明确的信号,而不是靠"睡一会儿应该够了"。
容器之间的差异,值得知道
官方说明里有一句很容易漏掉:Jetty、Reactor Netty、Tomcat 都是在网络层停止接受新请求;Undertow 不一样,它会接受新连接,但立刻返回 503。
这个差别在排查时很有用:
- 用 Tomcat,发布期间新请求表现为连接被拒绝或连接重置,客户端看到的是网络错误;
- 用 Undertow,新请求会拿到一个干净的 503,客户端能明确知道"服务暂时不可用"。
从客户端友好度看,503 更好——它可重试、有明确语义。从网关统计看,网络错误更容易被算成 5xx 之外的东西,反而不好定位。
另外官方还提到,持久连接(keep-alive)会改变"停止接受新请求"的具体表现。原因是复用的连接已经建立了,容器不能再"在 TCP 层拒绝",只能在协议层处理。所以同一个服务,短连接客户端和长连接客户端在停机期间的表现可能不一样。这一点没有统一的答案,只能在自己的环境里实测。
一套能落地的配置
把上面的结论收成一份清单,可以直接抄。
server:
shutdown: graceful # 3.4+ 可省略,低于 3.4 必填
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 单 phase 超时,不是总时长
task:
execution:
shutdown:
await-termination: true # 只对自动配置的线程池生效
await-termination-period: 20s
pool:
core-size: 8
max-size: 32
queue-capacity: 200
注意 spring.task.execution.* 这一整组属性都只作用于 Boot 自动创建的线程池。只要你自定义了 ThreadPoolTaskExecutor Bean,这些配置全部失效,必须回到代码里设。先确认自己的项目属于哪种情况,再决定改哪儿。
消息监听容器要注意顺序:Kafka、RabbitMQ 的监听容器也是 SmartLifecycle,它们的 phase 比 Web 服务器小,所以是后于 Web 层停止。这个顺序意味着:Web 已经停止接收请求了,消费者还在拉消息。多数场景没问题,但如果消息处理依赖"Web 层已经不再产生新消息",就会出问题。要改顺序就自定义 SmartLifecycle 的 phase,但要清楚自己在动的是全局的停机序列。
最后是验证清单,我建议每次调整停机参数后都跑一遍:
- 用
kill -TERM发信号,确认慢请求能完成; - 发信号后立刻发新请求,确认被拒;
- 观察总耗时,和
terminationGracePeriodSeconds对比; - 查日志里有没有
Commencing graceful shutdown一类的字样,确认走的是 graceful 路径而不是被强杀; - 在测试环境真实滚动更新一次,抓发布窗口内的 5xx,逐个对时间点。
第 5 步最有价值。配置对不对,看日志是看不出全貌的,只有真实滚动一次,才能知道 502 出现在哪个环节。
什么时候该把优雅停机关掉
也有该关的场景,说清楚免得走极端。
批处理型应用。如果这个服务不接 HTTP,只跑定时任务,server.shutdown 配了也没意义——它本来就是 graceful,但没有 Web 服务器可停。真正要管的是任务本身的取消逻辑。
启动耗时长、但请求很短的场景。grace period 30 秒对短请求来说太长,发布一次白等半分钟,滚动十台就是五分钟。可以调到 10 秒,配合 preStop 的 5 秒,总停机时间压到 15 秒以内,滚动速度明显变快。
明确会被强杀的边缘计算节点。某些边缘环境不保证优雅退出,SIGKILL 随时可能来。这种情况别指望 grace period,得靠业务幂等和状态机兜底——停机策略解决不了"说死就死"。
还有一种反直觉的情况:graceful 会让故障传播变慢。如果某个实例已经处于不健康状态(比如数据库连接池耗尽、请求全部卡住),graceful 会让它在关闭前多撑 30 秒,这 30 秒里它仍在接受流量(如果还没收到停止信号),或者仍在占着连接。所以健康检查和熔断不能因为配了优雅停机就省掉,两者解决的是不同问题。
说到底
优雅停机不是一个"打开就完事"的开关,它只解决"HTTP 请求收尾"这一个问题,而且只在正确的部署时序下才有效。
真正决定发布质量的,是四个东西同时对齐:Spring 的 grace period、线程池的等待策略、容器的信号传递方式、编排平台的摘流量时序。少了任何一个,另外三个都白配。
这也解释了为什么这个问题反复出现:它横跨了框架、JVM、容器、编排四层,而大多数人只在自己熟悉的那一层找答案。下次再遇到发布掉请求,先别改代码,按 SIGTERM → 摘流量 → grace period → 线程池 → SIGKILL 这条链走一遍,大概率能定位到具体是哪一环没对上。