⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/7281486769290526757 「hj545king」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

1 概念

优雅停机是什么?网上说的优雅下线、无损下线,都是一个意思。

优雅停机,通常是指在设备、系统或应用程序中止运作前,先执行一定的流程或动作,以确保数据的安全、预防错误并保证系统的整体稳定。

一般来说,优雅停机可以参考以下步骤以实现:

  1. 备份数据:立即将内存中的所有未保存的修改、缓存等数据保存到数据库或磁盘中。
  2. 停止接收新的请求
  3. 处理未完成的请求
  4. 通知其他依赖组件
  5. 等待所有要素安全退出后,关闭系统

在具体实施时,不同的设备、不同的系统、不同的应用,所需要的优雅停机步骤也不尽相同,甚至需要根据不同的场景来选择不同的方法。

例如,在某些情况下,你可能需要让用户知道,系统即将关闭,并告诉他们应当保存所有的工作并退出系统;而在另一些情况下,你可能需要设计一种策略,能够让系统在无用户介入的情况下,自动保存所有的状态,并在下次启动时恢复之。

但是,无论在哪种情况下,优雅停机的目标都是保护数据,避免错误,并尽量减少到访用户或使用者的不便。

上面的步骤,其实还缺了不少基础的内容,比如,停止请求外,还要停止接收定时任务、停止接收mq消息,等待他们的完成,这2项都是我们微服务中必不可缺的能力。

因此,我希望通过本文,能够更清晰,更详细的讲解,在我已知的真实业务场景下,如何做优雅停机。

文中,很多内容不会讲得太详细,需要大家有一定的搜索能力或者经验!

2 用案例说话

随着微服务的兴起,运维方式由docker -> k8s 变化,优雅停机涉及到的点就越来越多!下面,我们用一个案例,说明优雅停机中的问题和问题解决方案。

案例前:k8s 停机流程

当程序员执行 kubectl delete pod 命令时,两个过程开始:

网络规则即将生效:

  • Kube-apiserver 收到 pod 删除请求,并将 pod 的状态更新为 Extinating at Etcd;
  • 终结点控制器从终结点对象中删除 Pod 的 IP;
  • Kuber-proxy 根据 Endpoint 对象的更改更新 iptables 的规则,并且不再将流量路由到已删除的 pod。

删除容器:

  • Kube-apiserver 收到 pod 删除请求,并将 pod 的状态更新为 Extinating at Etcd;
  • Kubelet 清理节点处的容器相关资源,如存储、网络;
  • 添加 Prestop hook 钩子,等待流量不再发给pod;
  • Kubelet 将SIGTERM发送到容器;
  • 如果容器在默认的 30 秒内没有退出,Kubelet 将发送 SIGKILL 并强制其退出。

k8s + springboot + nacos 案例

PreStopHook 做了2件事情:

  1. nacos反注册
  2. 休眠35秒

通过信号量关闭springboot程序;

其中,k8s的 terminationGracePeriodSeconds(宽限期)设置为35s。

问题

springBoot程序关闭时间只有2s, 那么该程序就无法处理完一些线程任务、异步消息、定时任务等。为什么呢?

宽限期设置了35s,PreStop休眠了35s + 一个请求的时间,超过了宽限期,那么 kubelet 就会给与 pod 增加一次性2s的宽限时间。Pod 的生命周期,2s不管程序是否正常结束,都会被Kill -9。

为什么反注册之后需要休眠35s?

这里涉及到nacos服务发现原理,nacos服务变更响应时间:实时;ribbon 默认缓存刷新时间30s;因此,一开始是设置30s的,发现还有feign请求失败的情况,所以设置成了35s以解决这个问题!

nacos服务变更响应时间真的是实时吗?

其实并不一定,nacos服务发现是通过http和udp实现的,udp是实时的,http最大等待时间是10s,但是,udp端口生产环境可能没有开放!所以,案例中的nacos服务发现仅通过http定时轮询实现。

案例优化

上面的案例可以优化的点

  • nacos 反注册后休眠35s,是否可以减少;
  • terminationGracePeriodSeconds 设置多少合理?

优化点1

反注册后休眠的35s时候受到nacos服务发现 + ribbon 缓存刷新时间影响,正常应该是 服务发现时间 + 缓存刷新时间 40s才能在极端情况下保证服务停机时,不会再有feign 请求进入。

如果想要缩短这个时间

  • 启用udp,这个需要和运维同学商量,否则10s等待少不了;
  • 监听nacos服务变更通知,发现服务下线后,及时刷新ribbon缓存;
/**
* 订阅 nacos 实例变更通知
* 手动刷新 ribbon 服务实例缓存
* nacos client 1.4.6 【1.4.1有重大缺陷,要注意】
*/
@Component
@Slf4j
public class 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();
// service: DEFAULT_GROUP@@demo ribbonService: demo
String ribbonService = service.substring(service.indexOf("@@") + 2);
log.info("#### 接收到微服务nacos实例变更事件:{} ribbonServiceName: {}", event.getServiceName(), ribbonService);
ILoadBalancer loadBalancer = springClientFactory.getLoadBalancer(ribbonService);
if(loadBalancer != null){
((ZoneAwareLoadBalancer<?>) loadBalancer).updateListOfServers();
log.info("刷新 ribbon 服务实例:{} 缓存成功", ribbonService);
}
}

@Override
public Class<? extends com.alibaba.nacos.common.notify.Event> subscribeType() {
return InstancesChangeEvent.class;
}


/**
* nacos 1.4.4 ~ 1.4.6 需要加这个方法的实现, 2.1.2以后版本修复了该问题
* 多注册中心时,变更事件没有隔离,因此需要实现该方法来判断事件是否需要处理
* @see <a href="https://github.com/alibaba/nacos/issues/8428">ISSUE #8428 - Nacos InstancesChange Event Scope</a>
* **/
@Override
public boolean scopeMatches(InstancesChangeEvent event) {
return true;
}

}

优化点2

terminationGracePeriodSeconds 的值应该略大于 PreStop耗时 + springBoot 停机时间,springBoot 停机时间是由程序业务决定的(mq消息、定时任务、线程池任务、以及备份数据),网上的推荐做法是启用springBoot的优雅停机功能,并实现自定义的关闭逻辑。

springBoot优雅停机的默认缓冲时间是30s,因此,terminationGracePeriodSeconds的时间个人建议10 + 30s即可。

经过优化后

使用 actuator shutdown 方案

有些网贴推荐使用 actuator shutdown 进行优雅停机,那么看下其流程图:

其实,真正的情况并非如上图所示,因为调用shutdown后,springBoot就会进入优雅停机流程,但是这个流程没有结束,然后就会被kill -15 中断,如果线程池没有做好配置,线程池任务没有结束,服务就会关闭。

// 没有设置下面参数,在kill -15时,线程池没有执行结束,会被强制关闭
threadPoolTaskExecutor.setWaitForTasksToCompleteOnShutdown(true);
threadPoolTaskExecutor.setAwaitTerminationSeconds(30);

3 再次优化

mq 和 定时任务

上面的方案中,提到nacos反注册时,其他服务监听反注册事件,进行ribbon缓存刷新,那么,反注册的服务(停机服务)自身,是否可以也监听该事件呢?答案是可以的。

停机的服务监听nacos反注册事件,判断是自己反注册了,表示准备关机,那么就可以停止对mq消息的监听,停止定时任务,这样就比在优雅停机时,进行mq 和 定时任务的停止更完美。

流量控制

如果没有使用k8s进行pod节点的流量控制,那么大概率会使用 springCloud gateway作为服务网关,因此,gateway 服务也应该监听nacos的反注册事件,从而及时刷新ribbon的缓存,关闭停机服务的流量。

4 小结

经过大量的资料参考、学习,最终得到的一份自己认为合格的优雅停机方案,里面可能有较多的不专业表述,敬请谅解和指正,谢谢。

在本文的最后,还要说下,优雅停机最大的挑战并不是来源于这个优雅停机流程,机械化的流程前人都帮忙躺过了,剩下的是业务服务自身的逻辑:

  • 有没有包含超过30s的业务逻辑,如执行超过30s的请求,定时任务、线程池任务或mq消息;
  • 服务关闭时,如何保存未完成的任务、数据,实现自定义的关闭逻辑;
  • 接口逻辑是否做了幂等;
文章目录
  1. 1. 1 概念
  2. 2. 2 用案例说话
    1. 2.1. 案例前:k8s 停机流程
      1. 2.1.0.1. 网络规则即将生效:
      2. 2.1.0.2. 删除容器:
  3. 2.2. k8s + springboot + nacos 案例
    1. 2.2.0.1. 问题
  • 2.3. 案例优化
    1. 2.3.0.1. 优化点1
    2. 2.3.0.2. 优化点2
    3. 2.3.0.3. 经过优化后
    4. 2.3.0.4. 使用 actuator shutdown 方案
  • 3. 3 再次优化
    1. 3.1. mq 和 定时任务
    2. 3.2. 流量控制
  • 4. 4 小结