Spring Boot 优雅停机实战:server.shutdown=graceful 等在途请求处理完再退出,配合 K8s preStop 别丢请求
线上滚动发布时你有没有遇到过这种诡异现象:代码没改坏,监控却在每次发版的那几秒钟准时冒出一小撮 502 / 连接重置,业务方追过来问「刚才下单失败了几单」。你翻日志,发现请求打到了一个正在关闭的实例,进程已经把 HTTP 线程池干掉了,可这个请求才处理到一半。
这不是偶发抖动,而是**默认的「立即停机」**在作祟。这篇文章手把手把它改成优雅停机,并讲清楚为什么光配 Spring Boot 还不够,得和 Kubernetes 的 preStop 配合才真正不丢请求。
先看默认停机为什么丢请求
Spring Boot 2.2 之前,收到 SIGTERM 后进程直接开始销毁 Bean、关闭内嵌 Tomcat。此时:
- 已经 accept 但还没处理完的请求,连接被强行掐断 → 客户端收到
Connection reset。 - 线程池里排队的任务直接丢弃。
写个接口模拟一下慢请求就能复现:
java
@RestController
public class OrderController {
@GetMapping("/order")
public String create() throws InterruptedException {
// 模拟一个耗时 5 秒的下单流程(扣库存、写单、发消息)
Thread.sleep(5000);
return "order created";
}
}
启动后用 curl 发一个请求,趁它没返回时 kill <pid>(注意是默认的 SIGTERM,不是 -9):
bash
curl http://localhost:8080/order &
sleep 1
kill $(pgrep -f 'java.*app.jar') # 发 SIGTERM
默认情况下 curl 会立刻收到连接中断,那笔「下单」就这么没了。
正确写法:开启 graceful shutdown
从 Spring Boot 2.3 起,优雅停机是内置能力,两行配置搞定:
yaml
# application.yml
server:
shutdown: graceful # 关键:开启优雅停机
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 最长等 30s 让在途请求跑完,超时才强关
开启后,收到 SIGTERM 的行为变成:
- Web 容器停止接收新连接(新来的请求会被拒绝/由前面的 LB 转走)。
- 已经进来的在途请求,继续处理直到完成。
- 全部处理完 或 到达
timeout-per-shutdown-phase上限,才真正关闭线程池、销毁 Bean。
再跑一遍上面的复现脚本,这次那笔慢请求会正常返回 order created 之后进程才退出。
一个容易忽略的点:timeout-per-shutdown-phase 要大于你最慢接口的处理时间,否则超时后照样强关。别拍脑袋写 5s,先看你 P99 接口耗时。
想在关闭前做自己的清理?用 SmartLifecycle
有时候你还想在停机时主动做点事:给注册中心发下线通知、把本地缓冲区刷盘、关掉自己起的定时线程池。别去 hook Runtime.addShutdownHook(它和 Spring 生命周期不同步),用 SmartLifecycle:
java
@Component
public class GracefulTasks implements SmartLifecycle {
private volatile boolean running = false;
@Override
public void start() {
running = true;
}
@Override
public void stop() {
// 这里在「停止接收新请求」之后、销毁 Bean 之前执行
// 适合:主动从注册中心摘除、flush 缓冲、优雅关自建线程池
System.out.println("[shutdown] 主动下线 + 刷缓冲...");
running = false;
}
@Override
public boolean isRunning() {
return running;
}
@Override
public int getPhase() {
// phase 越大越先 stop。想在 Web 容器停之后再执行,取一个较小值
return Integer.MAX_VALUE - 1;
}
}
getPhase() 控制顺序:数值大的先启动、先停止。Web 服务器所在的 phase 很大,所以给你的清理逻辑一个稍小的值,能保证它在「不再收新请求」之后跑。
关键坑:只配 Spring Boot,K8s 里照样丢请求
很多人配完上面就以为万事大吉,结果在 Kubernetes 里滚动更新还是有 502。原因在于两个动作是并发的、没有先后保证:
- kube-proxy / Ingress 把这个 Pod 从 Endpoints 里摘掉(停止转发流量)。
- kubelet 给容器发 SIGTERM。
这两件事几乎同时发生,而 Endpoints 的更新要经过 apiserver → kube-proxy → 刷新 iptables/ipvs,有延迟。于是会出现:进程已经收到 SIGTERM 开始「不收新连接」了,但 LB 那边还没更新完,继续把新请求转进来 → 被拒 → 502。
解法是加一个 preStop 钩子,先睡几秒,给 Endpoints 更新争取时间,然后才让 SIGTERM 真正开始停机:
yaml
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
terminationGracePeriodSeconds: 45 # 要 > preStop sleep + Spring 停机超时
containers:
- name: app
image: my-app:latest
lifecycle:
preStop:
exec:
# 睡 5~10s,等 kube-proxy 把本 Pod 从转发表里删掉,期间还能正常处理存量请求
command: ["sh", "-c", "sleep 8"]
时序就变成了:preStop sleep(此时旧流量还在处理,新流量被 LB 逐步切走)→ sleep 结束 → 容器收到 SIGTERM → Spring 优雅停机等在途请求跑完 → 退出。三段时间要满足:
terminationGracePeriodSeconds > preStop_sleep + timeout-per-shutdown-phase
比如 preStop 睡 8s、Spring 超时 30s,那 terminationGracePeriodSeconds 至少给 45s,否则 kubelet 等不及会补一刀 SIGKILL,又把在途请求砍了。
顺手把 readiness 探针也想清楚
优雅停机期间,进程还活着但不该再被当作「健康可用」。如果你用的是 Spring Boot Actuator,开启 readiness 状态联动能让探针在停机阶段自动转为 OUT_OF_SERVICE,配合 K8s readinessProbe 更快摘流量:
yaml
management:
endpoint:
health:
probes:
enabled: true
health:
readinessstate:
enabled: true
配合 K8s:
yaml
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 3
这样进入停机流程后,readiness 立刻变红,探针一失败就从 Endpoints 摘除,和 preStop sleep 形成双保险。
小结
- 默认停机会掐断在途请求,滚动发布时的偶发 502 十有八九是它。
- 开启优雅停机只需两行:
server.shutdown: graceful+spring.lifecycle.timeout-per-shutdown-phase,超时要大于最慢接口耗时。 - 需要自定义清理(下线、刷盘、关线程池)用
SmartLifecycle的stop(),别用裸的 shutdown hook。 - 光配 Spring 不够 :K8s 里 Endpoints 更新有延迟,必须加
preStop: sleep 若干秒先让 LB 切走流量,再让 SIGTERM 触发停机。 - 三段时间要满足
terminationGracePeriodSeconds > preStop sleep + Spring 停机超时,否则会被 SIGKILL 补刀白忙活。
一句话记忆点:优雅停机 = 先让流量断奶(preStop + readiness 摘流量),再让进程断电(等在途请求跑完),顺序反了就丢请求。