一次线上问题引发的对dubbo优雅下线的思考

一.背景
       我们经常聊到dubbo的启动,是如何暴露接口的,如何注册到注册中心的,但是就一个完整的生命周期而言,有上线就必然有下线,而下线这一部分往往被人忽略,这次就一次线上发布问题为入口,来分析dubbo下线的过程和其中遇到的问题,从另一个方面加深dubbo整个生命周期的理解。

二.案例
       某次生产发布,虽是对外停机发布,一般内部来讲,仍采用的是蓝绿发布,即先新起服务,等新的服务健康检查通过后,在杀掉原来的服务进程。开始正常,新服务健康起来,旧的容器(公司使用的是docker封装的服务)被杀掉,测试同学开始验证,然后问题来了,新的服务接口时不时出现超时,平均每三次出现俩次,这个不像是网络或者环境抖动,一开始检查业务是不是有慢查询,但是发现出问题的接口没有更新,属于原来的回归,开始还比较慌,有点摸不着头脑,后来慢慢把视线转到超时日志上,超时日志打印出来超时的provider的地址,这个地址不是新发布的服务端地址!!

   不是新服务的地址,是哪的呢? 赶紧打开ZK的控制台,发现这个服务的provider的有两个,新发布的服务只是其中一个,如下图所示:

 

 

这个服务因为不是核心业务,目前是单例的,为什么会有两个provider,那另一个是从哪来的?

找了一通,然后我们在被杀死的容器里面找到了这个地址

 

这不是被杀死的服务吗,为什么还在ZK的列表里面? 是服务没下线,还是下线了ZK那里没有注销掉? 问题已经出来,然后我们来分析

三.分析
       首先要确定到底是ZK没下线,还是服务本身就没下线(公司的容器调度平台是自研,也有出bug的可能),然后现场就联系的平台部的同事,反馈是容器确实本杀掉了,也就是进程已经不存在了,但是ZK上这个provider还存在。为什么?

    1> dubbo的注册机制
       要回答这个问题,我们先来看看dubbo的注册机制,在dubbo启动的时候,会进行provider的初始化,这里面包括暴露服务端口,注册服务,启动底层通信模块等一系列的动作,网上有很多资料可查,这里不在赘述,就注册服务这个环节进行分析

       注册服务,即 将应用需要暴露的接口注册到注册中心(zookeeper或nacos),供消费者订阅以及控制台进行配置和管理,以ZK(zookeeper)为例,provider 注册到ZK上的节点形式一般如下:

ls /dubbo/com.test.demo.shard.dubbo.DubboDeno/providers/dubbo%3A%2F%2F30.43.89.110%3A26880%2Fcom.test.demo.shard.dubbo.DubboDeno%3Fanyhost%3Dtrue%26application%3Doms-starter%26dubbo%3D2.6.2%26generic%3Dfalse%26interface%3Dcom.test.demo.shard.dubbo.DubboDeno%26methods%3DgetValue%26pid%3D44050%26retries%3D0%26revision%3D1.0.0%26side%3Dprovider%26timestamp%3D1579164865375%26version%3D1.0.0

 


组成结构是 dubbo + 接口类名 + providers/consumers/configration/router/ + 具体属性值(地址,版本,方法和参数等等),providers下面是一个list,每注册一个provider实例,就会添加进去。

ZK上面的节点一般有两种类型,持久节点和临时节点,

持久节点(persisit):指节点不随session变化而变化,一直存在的节点;

临时节点(ephemeral):一旦注册这个节点的客户端连接断开或者session超时,zk会自动将其注册的临时节点和监听器给清除掉;

另外注意,临时节点不能有叶子节点。

dubbo的provider是什么类型呢,我们看下对应的源码,dubbo的与注册相关的接口都封装在ZookeeperRegistry里面(以ZK为例,其他类型注册中心会在XXRegistry),看下注册的过程:

@Override
protected void doRegister(URL url) {
try {
zkClient.create(toUrlPath(url), url.getParameter(Constants.DYNAMIC_KEY, true));
} catch (Throwable e) {
throw new RpcException("Failed to register " + url + " to zookeeper " + getUrl() + ", cause: " + e.getMessage(), e);
}
}

 


这个注意到那个url.getParameter表达式,如果没有取到值的话,默认为true,,接着往下看

@Override
public void create(String path, boolean ephemeral) {
int i = path.lastIndexOf('/');
if (i > 0) {
String parentPath = path.substring(0, i);
if (!checkExists(parentPath)) {
create(parentPath, false);
}
}
if (ephemeral) {
createEphemeral(path);
} else {
createPersistent(path);
}
}

 


这个先检查路径是否创建,路径是什么,就是之前结构里面的前半部分dubbo + 接口类名 +providers/consumers/configration/router/,这里面可以看到,路径都是持久化节点,为什么,因为路径临时节点不能有子节点,真正的provider是挂在路径下面的;

下面继续看,创建具体节点的时候,if ..else, 这个里面的判断条件就是之前那个url.getParameter,这个的默认值,我们看

//AbstractServiceConfig
// whether to register as a dynamic service or not on register center
protected Boolean dynamic;

 


在AbstractServiceConfig里面,一般是没有默认值的,所以根据上面的语句,给出表达式给出默认值值true;注意,这个不是所有的版本都没有默认值,比如在dubbo 2.7.1的版本里面,dynamic就直接默认是false,后面会专门说。

这样的话,再看创建节点,实际上ephemeral =true的话,那么创建的就是临时节点,dubbo服务接口在ZK上的注册的节点都是临时节点,也就是说,一旦provider断开连接,或者宕机,ZK会把所有的这个实例注册的节点全部清除掉,然后把删除后的节点内容推送到所有的消费端,这样消费者在进行负载均衡的时候,就不会在选择到已下线的provider,这恰恰是我们服务治理所需要的! 从这点来讲,ZK 非常契合dubbo对注册中心能力的需求。

       看完了注册上线的流程,我们再看服务下线的流程,上面已经说了一种情况,非正常下线,连接断开或者服务宕机,就是ZK自动清理节点;还是一种是主动去ZK上把之前注册的节点都注销,这是更为可靠和优雅的做法,这就是dubbo的优雅停机。列举下,

1.自动下线,就是如上面所述,通过断开连接,由zk自动清理

2.服务下线时,执行destroy方法,注销所有暴露的接口,以及在注册中心的节点和监听器,回收相关的资源

第二种就是dubbo的优雅停机,怎么触发,我们来专门看

   2> dubbo的优雅停机
       dubbo的优雅停机实际上一个重要的问题,也是一个很容易的被忽略的问题,了解它,对保证线上的平稳运行,理解dubbo的完整生命周期都有很强的启发意义,说句题外话,之前也是不怎么关注这块 ,直到线上出了这次的问题。

       抛开现有成熟方案,单就思考,如果这样的问题丢出来给我们,应该怎么做;熟悉spring的朋友应该不陌生,spring里面的bean都可以配置一个destroy方法,这个方法会在bean被销毁的时候执行,然后可以实现这个方法来实现自己的销毁逻辑,比如释放一些资源,或者打印日志,统计参数之类,那么这个方法什么时候执行,当然是bean被销毁的时候,那么什么时候被销毁,一般来讲,bean的生命都是由spring容器管理的,额外的讲,这个方法,不会说让你怎么销毁bean,而是spring销毁bean的时候,你还可以额外做些什么,属于你定制的东西,也就是钩子,会在容器销毁bean的时候调用,而就一般的业务逻辑,不会单独去销毁一个bean,所以实际是整个容器生命周期结束的时候,或者容器本身被销毁的时候,也就是服务被停掉的时候。讲这些想说明什么?是想说,一般好的框架设计,会提供若干钩子函数或者事件,实现或者复写这些方法可以实现我们定制的逻辑,在框架启停或者对应特定时刻,来执行这些逻辑。

       事实上,dubbo也确实是这么设计的,一般来讲,除非热加载的情形,我们不太会单独去销毁或替换某个bean, 更常见的是在应用即将关闭的时候销毁所有的bean,dubbo提供了一个专门用于优雅停机的钩子DubboShutdownHook,这个钩子会在spring上下文关闭的时候被调用,用于释放与dubbo相关的资源,包括dubbo底层网络通信的服务,在zk上注册的节点,dubbo本身的配置对象等等。我们来看下对应的源码和触发时机。

@Override
public void run() {
if (logger.isInfoEnabled()) {
logger.info("Run shutdown hook now.");
}
doDestroy();
}


/**
* Destroy all the resources, including registries and protocols.
*/
public void doDestroy() {
if (!destroyed.compareAndSet(false, true)) {
return;
}
// destroy all the registries
AbstractRegistryFactory.destroyAll();
// destroy all the protocols
destroyProtocols();
}

 


   里面主要是做两个事情,销毁所有与注册的中心相关的节点和监听器;消息所有的dubbo protocol。

   那么什么时候触发呢,看注册机制,

private static class ShutdownHookListener implements ApplicationListener {
@Override
public void onApplicationEvent(ApplicationEvent event) {
if (event instanceof ContextClosedEvent) {
DubboShutdownHook shutdownHook = DubboShutdownHook.getDubboShutdownHook();
shutdownHook.doDestroy();
}
}
}

 


很明显,是在监听到spring 的容器关闭事件后触发的,一般来讲,也就是服务被停掉的时候。

       这个里面还有一个问题,就是并不是所有的时候都会触发这个事件,我们知道spring本身也是运行在java虚拟机里面的,java虚拟机是运行在操作系统里面,从广义角度讲,一个容器里面的元素提供钩子,供承载它的平台在结束时调用;这里面容器和元素是相对的,对于bean而言,spring就是它的容器;对于spring而言,虚拟机就是他们容器;对于虚拟机而言,操作系统就是它的容器,这是个嵌套。实际上,JVM本身就提供对应的销毁实例的钩子,dubbo对spring的钩子和JVM的钩子都做了兼容处理,因为早期就存在non-spring类型的应用,而对于spring这两个只需要调一个就够了,关于优雅停机更深层次的内容,见Kirito大佬的文章:

https://www.cnkirito.moe/dubbo-gracefully-shutdown/

这里面在讨论另外一个问题,什么时候spring的钩子会被执行

程序正常退出
程序中使用System.exit()退出JVM
系统发生OutofMemory异常
使用kill pid干掉JVM进程的时候
注意,第四条,实际上杀进程常用的命令有两个,kill -9 和kill -15,正常我们业务服务下线应该使用的是 kill -15,也就是kill sig,通知对应的进程结束,进程执行内置的清理函数,然后平稳结束;而kill -9直接从操作系统层面杀掉,直接清除占用的资源,很多的后置函数也没有执行的,dubbo的DubboShutdownHook也同样不会被执行。所以除非必要,一般不要使用kill -9来杀进程。

四.结论
       很惭愧,到现在为止,找了很多线索仍没有找到问题的真正原因,在加上问题没有复现,当时留着的证据并不多,很多线索查到一半就断了,但其中遇到的很多思路让我受益匪浅,这个分享出来,大家如果遇到类似的问题也可以参考

1.dubbo服务注销之后,ZK的监听器没有注销,具体的PR https://github.com/apache/dubbo/pull/1792/commits,在dubbo 2.6.2版本中存在,2.6.3之后修复,实际上笔者的版本也是2.6.2,但实际排查后发现不是这个问题。

2.dubbo的配置 dynamic默认是false, 导致创建的节点是永久节点,如果是kill - 9杀掉的,就不会主动在ZK上注销,在dubbo 2.7.1版本中出现,之后修复,见issue  https://github.com/apache/dubbo/issues/3785

3.ZK的心跳的检测,在上面可能的原因都被一一排除掉之后,最后的关注点落到了ZK的心跳检测上,怀疑(或者很有可能)是ZK的服务端的心跳检测出了问题,客户端下线后,ZK仍不断为其延续session 的存续时间,导致原有的临时节点仍然存在,但这个涉及ZK更深层次的东西,其实和dubbo的关系就不是那么密切了,由于个人精力问题,就没有在深入下去,有兴趣的同学或者大佬有这方面经验的欢迎随时交流指正。

       最后的做法是,手动移除已经下线的节点,然后恢复正常,后续仍需观察,看是否还有类似的情况发生,以及验证升级ZK版本是否会有类型情况。虽然最后终极的原因没有找到,不过在此过程中还是学到很多,如果有类似场景处理经验的大佬欢迎随时指教。

posted @ 2021-05-31 17:41  姚春辉  阅读(1440)  评论(1编辑  收藏  举报