在微服务发版更新时,如何实现平滑发布(优雅停机),避免正在处理的请求被强制中断?
在微服务架构中,实现平滑发布(优雅停机,Graceful Shutdown)是一个涉及多层组件协同的系统性工程。如果仅仅在代码层面上配置停机,往往无法避免请求报错。
要实现真正的零掉单、零报错,需要从应用层、注册中心、网关层、容器编排(K8s)以及异步任务五个维度进行配合。
以下是实现微服务优雅停机的标准方案和最佳实践:
一、 核心逻辑与时序(标准流程)
优雅停机的核心原则是:先切断新流量,再处理老流量,最后释放资源。
完整的停机时序如下:
- 触发停机:系统或容器(如 K8s)发出停机指令。
- 服务下线:主动向注册中心(如 Nacos/Eureka)注销自己,告诉其他服务不要再路由过来。
- 等待同步:等待一段“缓冲时间”,让网关和其他服务刷新路由列表。
- 拒绝新请求:Web 容器(如 Tomcat)或 RPC 框架停止接受新连接。
- 处理存量:等待正在处理的请求和消息队列任务执行完毕。
- 释放资源:关闭数据库连接池、Redis 连接、线程池等。
- 彻底退出:进程安全结束。
二、 各层的具体实现配置
1. 应用层:开启 Web 容器的优雅停机
以最常用的 Spring Boot 为例,默认情况下 Tomcat 是直接强制关闭的。需要开启配置让其等待存量请求处理完。
application.yml 配置:
server:
# 开启优雅停机(默认是 IMMEDIATE)
shutdown: graceful
spring:
lifecycle:
# 设定等待存量请求处理的最大超时时间(如 30 秒)
# 如果 30 秒后还有请求没处理完,也会被强制关闭
timeout-per-shutdown-phase: 30s
2. 注册中心层:解决“服务已停,但流量还来”
当应用触发停机时,虽然 Web 容器准备优雅退出了,但注册中心(如 Nacos)的实例状态同步是异步且有延迟的。如果直接停机,网关和上游服务可能还没收到下线通知,依然会把流量打过来,导致 Connection Refused。
解决方案:在准备停机前,先主动注销,并休眠等待。
在 Spring Boot 中可以监听 ContextClosedEvent:
@Component
@Slf4j
public class GracefulShutdownHook implements ApplicationListener<ContextClosedEvent> {
@Autowired
private NacosRegistration registration;
@Autowired
private NacosServiceRegistry nacosServiceRegistry;
@Override
public void onApplicationEvent(ContextClosedEvent event) {
log.info("准备优雅停机,先从 Nacos 注销实例...");
// 1. 主动从 Nacos 注销
nacosServiceRegistry.deregister(registration);
// 2. 核心:休眠等待!让网关和上游服务有时间刷新本地缓存的路由表
try {
Thread.sleep(10000); // 建议休眠 5-10 秒,根据你的注册中心同步延迟决定
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
log.info("Nacos 注销并等待完毕,开始关闭 Spring 容器...");
}
}
3. 容器编排层(Kubernetes):解决 K8s 路由延迟
如果你使用 K8s 部署微服务,K8s 停止 Pod 的流程是:
- 向 Pod 发送
SIGTERM信号。 - 同时(异步)从 Service 的 Endpoints 中摘除该 Pod 的 IP。
由于这两个动作是并行的,Pod 收到 SIGTERM 开始执行 Java 的优雅停机了,但 K8s 的 iptables/IPVS 还没更新完,这时候集群内的流量还是会打到正在停机的 Pod 上。
解决方案:利用 K8s 的 preStop 钩子,让 Pod 收到停机指令后先“挂起”一段时间。
deployment.yaml 配置:
lifecycle:
preStop:
exec:
# 在接收到 SIGTERM 信号前,先睡眠 10-15 秒
# 留出时间让 K8s master 把该 Pod 的 IP 从 Service Endpoints 中彻底剔除
command: ["/bin/sh", "-c", "sleep 15"]
注意: K8s 默认的强制杀进程时间(terminationGracePeriodSeconds)是 30 秒。如果你配置了 preStop 休眠 15 秒,Spring Boot 优雅停机等待 30 秒,总时间超过了 30 秒,K8s 会在 30 秒时无情地发送 SIGKILL 杀死进程。
因此,必须调大 K8s 的宽限期:
spec:
containers:
...
# 调大至 60 秒 (preStop 15s + Spring Boot 30s + 冗余)
terminationGracePeriodSeconds: 60
4. 异步任务层(MQ 消费者、定时任务)
HTTP 请求好处理,但如果是 Kafka/RabbitMQ 消费者或 @Scheduled 定时任务呢?如果在执行一半时被中断,会导致数据不一致。
- MQ 消费者:Spring AMQP (RabbitMQ) 和 Spring Kafka 默认支持优雅停机。它们会在容器关闭时停止拉取新消息,并等待当前正在执行的
@RabbitListener/@KafkaListener方法执行完毕。 - 线程池:如果你自定义了
ThreadPoolTaskExecutor,必须配置优雅停机参数,否则线程池会直接被销毁。
自定义线程池的正确配置:
@Bean
public ThreadPoolTaskExecutor myThreadPool() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// ... 其他配置 ...
// 开启线程池优雅停机
executor.setWaitForTasksToCompleteOnShutdown(true);
// 设置线程池最大等待时间(需小于 Spring Boot 的总等待时间)
executor.setAwaitTerminationSeconds(20);
return executor;
}
三、 发布策略的配合(部署层面)
实现了代码和容器的优雅停机后,还需要配合正确的发布策略(平滑发布):
滚动更新(Rolling Update):这是 K8s 默认的策略。
- 保证
maxUnavailable不能为 100%(例如设置为 25% 或 1),确保旧 Pod 停止时,有足够的新 Pod 在服务。 - 配置
readinessProbe(就绪探针):非常关键! 只有新启动的 Pod 就绪探针返回 200 后,K8s 才会把流量导入新 Pod,并开始销毁旧 Pod。
- 保证
蓝绿发布 / 灰度发布:
- 通过网关(如 Spring Cloud Gateway / APISIX / Nginx)控制权重。先将旧版本的流量权重调为 0,观察日志确认无流量后,再进行下线替换。
四、 避坑指南总结(Checklist)
实施优雅停机时,核对以下关键点:
- 🚫 绝对禁止使用
kill -9:kill -9是SIGKILL信号,进程会被立刻强制回收,任何优雅停机代码都不会执行。必须使用kill -15(SIGTERM)。 - ⏱️ 时间配置梯队(最重要):各层的超时配置必须有大小关系,否则会互相打断。
- 公式:
K8s宽限时间 > preStop休眠 + SpringBoot停机时间 > 线程池停机时间 > 网关/RPC超时时间 - 举例:K8s
terminationGracePeriodSeconds: 60>sleep 10+timeout-per-shutdown-phase: 30s> 线程池20s。
- 公式:
- 🔄 服务幂等性兜底:即便做到了极简的优雅停机,也无法保证 100% 绝对不中断(例如物理机突然断电)。因此,核心接口必须设计幂等性,配合网关层的自动重试(Retry)机制(只针对 GET 请求或幂等请求重试),做到客户端对发版无感知。