一次发布为什么会丢请求
滚动发布时,旧实例收到 SIGTERM 就退出,这是很多“发布后偶发 502”的来源。请求已经进了 Tomcat 线程池,业务还在写库,进程却被关掉,客户端看到连接重置;定时任务跑到一半被中断,第二天对账才发现数据缺了一块。Spring Boot 的优雅停机就是为这个场景准备的,但它只解决“Web 请求”,不解决你自家线程池和定时任务。
从 Spring Boot 2.3 开始,server.shutdown=graceful 会让 Web 容器先停止接收新请求,再等待正在处理的请求结束,超时后才强制关闭。3.x 延续了这套机制,配合 Actuator 的 readiness 端点,可以在 Kubernetes 或 Nginx 后面做到“先摘流量,再停进程”。
最小配置
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 40s
management:
endpoint:
health:
probes:
enabled: true
show-details: always
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
timeout-per-shutdown-phase 要大于最慢请求的耗时,否则等待时间一到,容器照样强停。这个值不是越大越好,太长会让发布卡住,一般按 P99 耗时的两到三倍来估。配置完成后,停机日志里能看到 Commencing graceful shutdown 和 Graceful shutdown complete,没有这两行说明配置没生效。
健康检查要分组
Actuator 的 /actuator/health 是聚合结果,直接拿它做存活探针很危险:数据库抖一下,所有实例都被判定为不健康,然后被一起重启。正确做法是区分 liveness 和 readiness:
management:
endpoint:
health:
group:
liveness:
include: livenessState,ping
readiness:
include: readinessState,db,redis
liveness 只关心进程本身是否卡死,readiness 才关心能不能接流量。数据库、Redis 这些下游依赖放进 readiness,出问题时实例被摘掉,但不会被杀掉重启。Spring Boot 3.2 还支持 management.endpoint.health.probes.enabled=true 自动暴露 /actuator/health/liveness 和 /actuator/health/readiness。
自定义健康检查时,别把“慢”当成“挂”:
@Component
public class OrderQueueHealthIndicator implements HealthIndicator {
private final OrderQueue orderQueue;
public OrderQueueHealthIndicator(OrderQueue orderQueue) {
this.orderQueue = orderQueue;
}
@Override
public Health health() {
int backlog = orderQueue.backlog();
if (backlog > 10_000) {
return Health.down()
.withDetail("backlog", backlog)
.withDetail("reason", "队列积压超过阈值")
.build();
}
return Health.up().withDetail("backlog", backlog).build();
}
}
摘流水的顺序比配置更重要
只加 server.shutdown=graceful 还不够。Kubernetes 删除 Pod 时,会同时从 Service 端点摘除并发送 SIGTERM,这两个动作是并行的,网关可能还有几百毫秒在往这个 Pod 转发请求。常见做法是加一个 preStop 钩子,先睡几秒再让进程退出:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: order-service
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 8"]
terminationGracePeriodSeconds 是 Pod 级别的,preStop 是容器级别的,两个要一起看。preStop 睡 8 秒,是给 Service 摘除端点和网关刷新留时间;进程收到 SIGTERM 后,再走 Spring Boot 的优雅停机流程。
Nginx 侧则依赖被动的健康检查:max_fails=2 fail_timeout=10s,实例连续失败两次后暂时摘除。它不是实时的,所以应用停机前最好主动把 readiness 置为 REFUSING_TRAFFIC,等一个健康检查周期再退出。如果网关不支持主动摘流,至少要保证 preStop 的时间大于健康检查周期,别让请求打到已经收到 SIGTERM 的实例。
本机验证可以直接发信号:
kill -TERM $(pgrep -f order-service.jar)
docker stop -t 40 order-service
观察日志中正在处理的请求是否在超时前完成,再确认线程池、连接池的关闭顺序。
常见坑
1. 只配了 Web 优雅停机
server.shutdown=graceful 只管 Tomcat/Netty 的请求线程。@Async 线程池、@Scheduled 任务、MQ 消费线程都不在它的管理范围内。这些组件要么自己实现 SmartLifecycle,要么在 @PreDestroy 里先停止接收任务、再等待、最后强制关闭。
2. 探针配置反了
把数据库检查放进 liveness,数据库一抖动就触发全量重启,故障被放大。liveness 只判断进程是否还能响应,readiness 才判断业务依赖是否就绪。
3. 超时时间互相打架
terminationGracePeriodSeconds 小于应用的 shutdown timeout,Kubernetes 会先发 SIGKILL;反过来,应用等待太久,发布窗口被拉长。两个值要一起算,通常容器侧留出 10 到 20 秒余量。
4. 健康检查接口暴露太多信息
show-details: always 会把数据库地址、磁盘路径、依赖状态返回给调用方。生产环境要么只在内网暴露,要么改成 when-authorized,并给 Actuator 加独立端口和访问控制。
5. 用 kill -9 做“快速发布”
SIGKILL 没有优雅停机可言,连接池、缓冲区都不会刷盘。脚本里图省事写 kill -9,丢数据只是概率问题。
小结
优雅停机的完整链路是:网关停止转发 → readiness 变为拒绝流量 → 进程收到 SIGTERM → Web 容器等待在途请求 → 业务线程池和连接池依次关闭。任何一环缺失,配置看起来生效了,实际仍可能丢请求。
评论