优雅停机,听起来像是给程序一个体面的离场方式。可是在 SpringBoot + Nacos + Kubernetes 这个组合拳里,这个“体面”真不是说说就行,操作不好就变成“仓皇下线”现场。
我是真见过那种程序还在忙着处理 MQ 消息,Kubernetes 一把 kill 掉,堆栈一地鸡毛,数据丢失、调用失败,报警像下雨一样哗啦啦往群里砸。那时候你别说优雅了,连个吭都没来得及打。今天这篇文章,就是来把这件事讲清楚,怎么让 SpringBoot 程序在 K8s 中真正做到优雅停机。
Kubernetes 的套路:不是说关就能关的
在 K8s 里,当你执行kubectl delete pod xxx这个命令,Pod 的死亡倒计时就开始了,但并不是立即“啪”一下消失,而是走一个流程。K8s 先会告诉 API Server:“我要删这个 Pod”,然后状态会变成Terminating。
接下来 kubelet 给容器发一个 SIGTERM 信号,告诉你,“我准备关你了,自己收拾收拾”。这时候程序有一段宽限时间,默认是 30 秒,叫terminationGracePeriodSeconds。你得在这个时间里自己优雅地退出,比如把 MQ 消息处理完、线程池任务执行完、缓存刷盘等等。如果你超时还没搞完,抱歉,K8s 会给你来个 SIGKILL,强制下线,谁也救不了你。
所以问题来了:SpringBoot 应用真的能在这个时间内把所有事情都处理完吗?
SpringBoot 真能做到“优雅”吗?
别看 SpringBoot 启动一个 main 方法就搞定,真要优雅停机,里面门道多着呢。首先,从 SpringBoot 2.3 开始,引入了Lifecycle和SmartLifecycle接口,可以注册 shutdown hook,控制停机流程。
但要实现真正的优雅停机,下面这两句配置必不可少:
threadPoolTaskExecutor.setWaitForTasksToCompleteOnShutdown(true);
threadPoolTaskExecutor.setAwaitTerminationSeconds(30);
什么意思呢?简单说就是,“我等你把线程池的任务执行完再关机”,而不是不管三七二十一就停掉。否则,你那还在处理的异步请求、定时任务直接一刀切,全挂。
还有一点,SpringBoot 的 actuator 提供了shutdown端点,表面上看可以用它来主动触发停机,看起来很优雅,实际操作的时候你会发现,它只是让 Spring 容器开始退出,并没有等你所有任务都处理完,所以依旧有可能在任务还没跑完的时候,被 K8s 杀掉。
nacos 的“假反应”:反注册 ≠ 没有流量
讲到这,咱得把 Nacos 拉出来讲一讲。K8s 的 PreStop Hook 很好用,可以在 Pod 被杀之前做些操作,比如发个 HTTP 请求告诉别的服务“我准备下线了”,然后 deregister 掉 Nacos 注册中心里的实例,理论上别的服务就不会再访问它了。
但是你知道吗?Nacos 反注册的延迟其实是个坑。
Nacos 使用的是 HTTP 和 UDP 来做服务发现
UDP 是即时通知,但是很多线上环境压根儿没开 UDP
那么就只能走 HTTP 轮询,而默认的刷新时间是 10 秒
再加上 Ribbon 默认的缓存时间是 30 秒
也就是说,从你反注册开始,别的服务最多可能还要 30-40 秒才知道你“死了”。这期间还有请求打进来咋办?靠自己顶着呗。
所以,一些程序员在 PreStop 里加了一个大招:反注册完后 sleep 35 秒,等大家都发现自己已经死透了再关闭服务。
听起来好像靠谱?但是这时候又踩坑了。
你的 terminationGracePeriodSeconds 是 30 秒,sleep 35 秒,等你睡醒了,K8s 已经不耐烦给你一刀了,Pod 直接 SIGKILL。SpringBoot 应用啥都没干完就挂掉了,一地鸡毛又回来了。
解决方法:睡觉不如做点事
怎么解?别盲目 sleep,把时间浪费在等别人刷新缓存上,不如主动出击。你可以在服务内部监听 Nacos 的实例变更事件,比如这样:
@Component
@Slf4j
publicclass NacosInstancesChangeEventListener extends Subscriber<InstancesChangeEvent> {
@Resource
private SpringClientFactory springClientFactory;
@PostConstruct
public void registerToNotifyCenter(){
NotifyCenter.registerSubscriber(this);
}
@Override
public void onEvent(InstancesChangeEvent event) {
String service = event.getServiceName();
String ribbonService = service.substring(service.indexOf("@@") + 2);
log.info("Nacos 服务变更:{}", ribbonService);
ILoadBalancer loadBalancer = springClientFactory.getLoadBalancer(ribbonService);
if (loadBalancer != null) {
((ZoneAwareLoadBalancer<?>) loadBalancer).updateListOfServers();
}
}
@Override
public Class<? extends Event> subscribeType() {
return InstancesChangeEvent.class;
}
@Override
public boolean scopeMatches(InstancesChangeEvent event) {
returntrue;
}
}
这一段代码的核心是:一旦某个服务实例变更(比如某个实例下线),就主动刷新 Ribbon 的缓存,不用再等 30 秒。这样一来,你 Pod 反注册之后,别的服务几秒钟内就能更新缓存,不会再有请求误打进来。
如果 UDP 开得通,那更好,刷新更快。如果不行,也比原地等死强。
定时任务和 MQ 的“死角”:你以为停了,其实没停
再说一个坑,优雅停机不只是关闭 Web 请求,你还有 MQ 消息监听、定时任务,这些都是后台线程,一不留神就让服务在停机过程中继续干活。
有些人可能会在@PreDestroy方法里手动关掉 MQ 监听器,停掉定时任务。但有没有更自动的办法?当然有。
你也可以监听 Nacos 的反注册事件,一旦发现自己“即将下线”,就关掉 MQ 消息和定时任务,比如这样:
@EventListener
public void onNacosDeregistered(InstancesChangeEvent event) {
if (event.getServiceName().contains("你的服务名") && 是当前实例) {
// 停止 MQ 消费
consumer.stop();
// 停止定时任务
scheduler.shutdown();
}
}
这样你就能在停机前,干净地退出所有“后台活”,不给别人添麻烦,也不给自己留炸弹。
terminationGracePeriodSeconds 到底该设多少?
这个值设多少,不是凭感觉。你得做一个加法:
SpringBoot 自己优雅停机时间:默认是 30 秒
PreStop 中你的处理时间:比如反注册 + Ribbon 缓存刷新,假如是 10 秒
那terminationGracePeriodSeconds至少得 40 秒。再多留一点 buffer,设成 45 或 50 秒都行。别设置得比你 sleep 的时间还短,白忙活。
SpringCloud Gateway 的那一票也不能漏
最后还有个细节,如果你流量是通过 SpringCloud Gateway 进来的,那 Gateway 也要做一件事:监听服务反注册事件,刷新自己的缓存,不要再把请求转发到快死的服务上。
否则就算你服务已经准备“咽气”了,Gateway 还在猛塞请求,真成了“临终被折腾”。
写在最后
说到底,优雅停机的真正挑战,不是这些配置或者流程,而是你有没有认真考虑过你的程序“离开舞台”时要做的每一步。
有没有超过 30 秒的大任务?是同步还是异步的?
MQ、定时任务、线程池有没有收尾逻辑?
你是不是把所有“开着的门”都关好了?
很多时候,服务挂了不是因为 Kubernetes 杀得快,而是我们自己收拾得慢。
程序终有一死,优雅与否,全看生前修行。希望今天这篇,能帮你少走点坑,让你的服务能体面下线,不留后患。
最后,我为大家打造了一份deepseek的入门到精通教程,完全免费:https://www.songshuhezi.com/deepseek
领取专属 10元无门槛券
私享最新 技术干货