如果你也在用ZK,那这个导致集群挂掉的坑一定得注意!

0 结论

结论先行:ZK在3.4.6及以下版本存在选举端口(默认3888)失效而无法选举的重大漏洞。相关issue如下:

简单来说,ZK的选举端口3888在收到错乱的数据包时,可能会因创建负数大小的数组而抛出NegativeArraySizeException,导致选举端口的监听线程QuorumCnxManager$Listener整体退出,从而无法选举。 集群还能正常读写,但无法选举,一旦有节点重启就加入不了,坑不坑!!!

1 问题背景

12月20日,一个阳光明媚的寒冬午后,刚从午睡中醒来,两眼朦胧,运维部大C带着一脸坏笑走过来,如下是我俩的对话。

大C:云杰,咱们的ZK集群有两个节点加入不了集群了。

:(立马就清醒了)5个挂了俩,现在就剩下3个工作,再挂1个集群就整体挂了啊!怎么回事?

大C:是啊。我们也没改啥配置,就是正常重启下,发现加入不了了,以前也都没事。

:不会吧?这么神奇!关键我们对ZK也没经验啊!

大C:就是这么神奇!ZK是Java写的,我们运维基本不用Java,你们架构肯定更专业点。

:好吧,你说的好像也没错。

抱着求知的心态,开启了本篇的探索之旅。

2 现象

2.1 环境配置

5个节点的zoo.cfg配置如下:

ini 复制代码
server.6=10.40.xx.81:2888:3888
server.7=10.40.xx.41:2888:3888
server.8=10.40.xx.51:2888:3888
server.9=10.40.xx.111:2888:3888
server.10=10.40.xx.121:2888:3888

2.2 现象

  • 6号和8号已重启,但无法加入;
  • 7号、9号和10号未重启,仍能正常读写,10号为Leader

2.2.1 已重启节点

6号ZK日志报错如下: 表明无法与未重启节点的3888进行选举通信。

在6号上使用zkCli.sh登陆本机也失败: 表示无法在本机上读写,完全处于游离状态。

2.2.2 未重启节点

10号结节上使用zkCli.sh登陆本机仍能正常读写:

3 排查过程

3.1 10号3888端口的状态

重启节点无法与未重启节点的3888端口进行选举通信,那10号的3888端口是什么状态呢?

我们在10号上用netstat命令看下3888端口的状态: 表明3888端口仍处于LISTEN的状态,并伴有大量的CLOSE_WAIT连接。大C说这些10.177开头的IP是用于安全扫描的机器,心里也在嘀咕是不是跟这个有关。

既然3888是LISTEN状态,那我们telnet看下建立连接的状态,如下所示: 完了,虽然是LISTEN状态,但根本无法建立连接!!! 我们再从telnet端用netstat看下TCP连接的情况: 我去,都是SYN_SENT状态!也就是说,SYN包发送成功了,但根本没收到10号3888端口的建连ACK。这也就表明10号的3888已处于假死状态,根本不会响应。 查看10号的日志,已经滚动26GB,也没发现什么有效的异常。

我们又telnet下6号的3888端口,发现却正常:

3.2 10号的jstack状态

到了这里,虽然确定10号3888端口假死,但再往下走毫无头绪。这时灵光一现,我们ITCP联盟有个架构群,可以看看大家有没有相关的经验,于时紧急求助。

果然,群里去哪儿网架构的小伙伴也来了兴趣,积极响应。

去哪儿小伙伴也拿他们ZK节点的jstack跟我们的10号节点对比了下,果然发现了问题:

跟他们相比,我们缺少了个QuorumCnxManager$Listener线程: 这个线程是负责监听3888端口,并accept选举请求的。我们未重启节点里这个线程都没了,怪不得不能接收6号的选举请求!!!

3.3 6号的3888端口也挂了

到了这里,已经知道是因为QuorumCnxManager$Listener线程挂了,但什么原因导致的还没头绪。

这时,突然发现6号的3888端口也telnet不通,并出现跟10号一样有大量CLOSE_WAIT

我们赶紧看了下6号的最近日志,果然发现了一条报错: 报了NegativeArraySizeException异常,导致3888的监听线程挂掉。在6号jstack下,发现果然没有QuorumCnxManager$Listener线程。

紧接着我们把6号重启下,果然发现又有了:

4 原因分析

4.1 网上求证

有了这么多信息(QuorumCnxManager$ListenerNegativeArraySizeException等),我们足可以去网上检索求证了。果然,搜索前列即是结论中的issue。那是什么原因导致抛出NegativeArraySizeException呢?

java 复制代码
public boolean receiveConnection(Socket sock) {
    Long sid = null;
...
            sid = din.readLong();
            // next comes the #bytes in the remainder of the message                                                                             
            int num_remaining_bytes = din.readInt();
            byte[] b = new byte[num_remaining_bytes];
            // remove the remainder of the message from din                                                                                      
            int num_read = din.read(b);

从代码上看,是因为QuorumCnxManager$Listener接收到数据包后,会调用receiveConnection()进行处理。该函数从数据包里读出一个int类型到变量num_remaining_bytes,并据此创建一个byte数组。但读到的num_remaining_bytes为负数,从而导致创建数组失败,抛出NegativeArraySizeException异常。 为什么读到的是负值呢?那肯定是接收到错乱的数据包了!这时我们就联想到10号上有大量10.177IP的CLOSE_WAIT连接,原来是安全扫描的锅!!!

4.2 高版本优化

issue-2186说,3.4.7版本已经修复了,我们对比了下目前在用的3.4.6 果然,对num_remaining_bytes值进行了判断。

至此,经过近6小时的排查,原因也水落石出了:安全扫描的错乱数据包把ZK的3888选举端口搞挂了 !解决方案就是升级高版本ZK!!运维大C也投来了敬佩的目光:

同时也不由感慨ZK的QuorumCnxManager$Listener监听线程太脆弱了: 去哪儿大佬小黑哥更直观形象的表示用telnet再发送个-1就可能把ZK集群选举搞挂!

5 总结

  • 遇到认知外的问题是好事,勇敢面对,要有刨根问底的决心,过程全是成长;
  • 最大的成长不是把问题解决了,而是排查思路;
  • 借用雷总的一句话:"99%的问题,都有标准答案,找个懂的人问问"。及时向懂的人求助也不失为一个好办法,在此也感谢ITCP联盟小伙伴的及时相助;
  • 很多看似强大的东西遇到点意外可能会很脆弱。

关于作者

杜云杰,高级架构师,转转架构部负责人,转转技术委员会执行主席,腾讯云TVP。负责服务治理、MQ、云平台、APM、IM、分布式调用链路追踪、监控系统、配置中心、分布式任务调度平台、分布式ID生成器、分布式锁等基础组件。微信号:waterystone,欢迎建设性交流。

道阻且长,拥抱变化;而困而知,且勉且行。

相关推荐
超爱吃士力架1 小时前
邀请逻辑
java·linux·后端
AskHarries3 小时前
Spring Cloud OpenFeign快速入门demo
spring boot·后端
isolusion4 小时前
Springboot的创建方式
java·spring boot·后端
zjw_rp4 小时前
Spring-AOP
java·后端·spring·spring-aop
TodoCoder5 小时前
【编程思想】CopyOnWrite是如何解决高并发场景中的读写瓶颈?
java·后端·面试
凌虚6 小时前
Kubernetes APF(API 优先级和公平调度)简介
后端·程序员·kubernetes
机器之心6 小时前
图学习新突破:一个统一框架连接空域和频域
人工智能·后端
.生产的驴7 小时前
SpringBoot 对接第三方登录 手机号登录 手机号验证 微信小程序登录 结合Redis SaToken
java·spring boot·redis·后端·缓存·微信小程序·maven
顽疲7 小时前
springboot vue 会员收银系统 含源码 开发流程
vue.js·spring boot·后端
机器之心7 小时前
AAAI 2025|时间序列演进也是种扩散过程?基于移动自回归的时序扩散预测模型
人工智能·后端