结论先行:ZK在3.4.6及以下版本存在选举端口(默认3888)失效而无法选举的重大漏洞。相关issue如下:
issue-3016:Follower QuorumCnxManager$Listener thread died due to incorrect client packet
issue-2186:QuorumCnxManager#receiveConnection may crash with random input
简单来说,ZK的选举端口3888在收到错乱的数据包时,可能会因创建负数大小的数组而抛出NegativeArraySizeException
,导致选举端口的监听线程QuorumCnxManager$Listener
整体退出,从而无法选举。 集群还能正常读写,但无法选举,一旦有节点重启就加入不了,坑不坑!!!
12月20日,一个阳光明媚的寒冬午后,刚从午睡中醒来,两眼朦胧,运维部大C带着一脸坏笑走过来,如下是我俩的对话。
大C
:云杰,咱们的ZK集群有两个节点加入不了集群了。
我
:(立马就清醒了)5个挂了俩,现在就剩下3个工作,再挂1个集群就整体挂了啊!怎么回事?
大C
:是啊。我们也没改啥配置,就是正常重启下,发现加入不了了,以前也都没事。
我
:不会吧?这么神奇!关键我们对ZK也没经验啊!
大C
:就是这么神奇!ZK是Java写的,我们运维基本不用Java,你们架构肯定更专业点。
我
:好吧,你说的好像也没错。
抱着求知的心态,开启了本篇的探索之旅。
5个节点的zoo.cfg
配置如下:
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
Leader
。6号ZK日志报错如下:
表明无法与未重启节点的3888进行选举通信。
在6号上使用zkCli.sh
登陆本机也失败:
表示无法在本机上读写,完全处于游离状态。
10号结节上使用zkCli.sh
登陆本机仍能正常读写:
重启节点无法与未重启节点的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端口,发现却正常:
到了这里,虽然确定10号3888端口假死,但再往下走毫无头绪。这时灵光一现,我们ITCP
联盟有个架构群,可以看看大家有没有相关的经验,于时紧急求助。
果然,群里去哪儿网架构的小伙伴也来了兴趣,积极响应。
去哪儿小伙伴也拿他们ZK节点的jstack
跟我们的10号节点对比了下,果然发现了问题:
跟他们相比,我们缺少了个QuorumCnxManager$Listener
线程:
这个线程是负责监听3888端口,并accept
选举请求的。我们未重启节点里这个线程都没了,怪不得不能接收6号的选举请求!!!
到了这里,已经知道是因为QuorumCnxManager$Listener
线程挂了,但什么原因导致的还没头绪。
这时,突然发现6号的3888端口也telnet
不通,并出现跟10号一样有大量CLOSE_WAIT
:
我们赶紧看了下6号的最近日志,果然发现了一条报错:
报了NegativeArraySizeException
异常,导致3888的监听线程挂掉。在6号jstack
下,发现果然没有QuorumCnxManager$Listener
线程。
紧接着我们把6号重启下,果然发现又有了:
有了这么多信息(QuorumCnxManager$Listener
、NegativeArraySizeException
等),我们足可以去网上检索求证了。果然,搜索前列即是结论中的issue。那是什么原因导致抛出NegativeArraySizeException
呢?
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.177
IP的CLOSE_WAIT
连接,原来是安全扫描的锅!!!
看issue-2186说,3.4.7
版本已经修复了,我们对比了下目前在用的3.4.6
:
果然,对num_remaining_bytes
值进行了判断。
至此,经过近6小时的排查,原因也水落石出了:安全扫描的错乱数据包把ZK的3888选举端口搞挂了!解决方案就是升级高版本ZK!!运维大C也投来了敬佩的目光:
同时也不由感慨ZK的QuorumCnxManager$Listener
监听线程太脆弱了:
去哪儿大佬小黑哥更直观形象的表示用telnet
再发送个-1就可能把ZK集群选举搞挂!
ITCP
联盟小伙伴的及时相助;关于作者
杜云杰,高级架构师,转转架构部负责人,转转技术委员会执行主席,腾讯云TVP。负责服务治理、MQ、云平台、APM、IM、分布式调用链路追踪、监控系统、配置中心、分布式任务调度平台、分布式ID生成器、分布式锁等基础组件。微信号:waterystone
,欢迎建设性交流。
道阻且长,拥抱变化;而困而知,且勉且行。