Ceph monmap消息编解码过程




本文源码基于luminous-12.2.5版本分析

最近在分析H版本librbd client无法兼容L版本Ceph集群的问题,提前说明下最终结论是H版本被之前的同时修改过才导致的不兼容,官方版本应该是兼容的。这篇文章正是问题分析过程的一次整理总结。

需求描述

需求是想要在同一个OpenStack环境中同时使用H、L版本两个Ceph集群做云主机系统盘、云硬盘存储系统,因此计算节点(也即librbd client节点)就可能分为H、L两个版本,众所周知,升级librbd动态库,需要重启云主机才能生效(除非对librbd做过较大的改动),重启所有云主机这个操作用户显然是不能接受的(当然也可以执行热迁移操作,但大批量的热迁移也存在耗时长、风险大的问题,况且还有部分云主机不支持热迁移),因此如果要上线L版本集群就必须做到兼容H版本client,这样才能保证L版本集群的卷能被当前正使用H版本client的云主机挂载、读写。H版本是之前遗留的,不能升级到L版本(主要考虑2个问题,1是风险高,二是代价大。实际上还有一个小问题就是H版本client不兼容L版本server,这个正是我本次需要解决的问题)。

问题描述

有两个问题:
1. 不修改server的crush参数情况下,客户端执行ceph -s、rbd ls等命令报错:
2. 通过ceph osd crush tunables hammer命令修改crush参数之后,执行命令直接抛异常,原因是decode monmap的created字段时发生越界异常,位置是:

本次重点分析第二个问题,bt查看调用栈(调试的是rbd ls命令):

是在接收到服务端monmap消息后decode过程中抛的异常,具体是在decode created字段出错的。

单步调试发现,在此之前last_changed字段decode出来就是空的,时间都是0,因此怀疑是更早之前就发生错误了,调试后发现最初的错误是在decode mon_addr时就发生了。

通过调试服务端ceph-mon encode过程,发现mon_addr和last_changed、created字段都是被添加到monmap消息体内的,并且通过调试L版本client,这几个字段也都能正常decode出来,因此确认是客户端问题或者服务端发送的monmap消息客户端不兼容。但看了下L版本服务端encode代码,当前版本是v5,并且最低兼容v3版本,而H版本decode是v3版本,也就是说社区代码在设计上兼容性应该是没问题的。因此需要继续分析问题出在哪里。

仔细对比了mon_addr的decode和encode流程(主要是H版本和L版本的差异),发现L版本encode过程检查连接的features,并走了不同的encode过程。因此问题聚焦在了客户端和服务端协商features过程上。

也就是features的第59位是怎么设置上的(1<<59),看了下H版本的代码,发现了这个feature:

按道理在CEPH_FEATURE_HAMMER_0_94_4(1<<55)之后应该不会再有其他feature了才对,但这个CEPH_OSD_PARTIAL_RECOVERY为啥被加入了?去看了下官方代码,发现没有这个feature,然后git log看了下我们的代码仓库,果然是我们自己人加上的。至此问题原因已经清晰。

L版本中用CEPH_FEATURE_MSG_ADDR2(1<<59)来标记是否为新版本消息,这一位是0表示是老版本(未设置该bit表示老版本),但是H版本里面正好用到了这一位CEPH_OSD_PARTIAL_RECOVERY(1<<59),导致L版本服务端检测失败,误认为是新版本客户端,使用了新的编码方式对monmap中的monitor地址信息进行编码,导致客户端无法解码。

改动方案:

由于client端升级困难,因此对L版本服务端进行修改,使其兼容H版本client,增加对其他bit位的检查,当发现client是H版本后,就走老的encode流程,修改之后客户端可以正常使用(包括基本命令行和qemu启动云主机)。

 

此次问题分析的难点主要是:

  1. 不熟悉ceph源码,尤其是消息编解码过程
  2. 编译的第一个环境没有去掉编译优化(-O2),增加debug等级(-g),改成(-O0 -g3 -gdwarf-4)修改之后调试起来就很方便了,https://www-zeuthen.desy.de/unix/unixguide/infohtml/gdb/Inline-Functions.html
  3. 确定服务端是否把monitor地址信息编码到消息体过程被wireshark误导了一段时间

使用tcpdump+wireshark解析Ceph网络包

记得之前在通读docs.ceph.com上的文档时(http://docs.ceph.com/docs/luminous/dev/wireshark/),有提到过wireshark支持对Ceph网络数据包进行解析,于是试着用tcpdump抓了monitor和client的数据包,然后导入到wireshark进行解析,对H版本来说确实好用,但对L版本来说,monmap解析貌似支持不太好,monitor address地址解析不出来,估计也是只支持低版本的编码协议。参考:https://www.wireshark.org/docs/dfref/c/ceph.html

tcpdump命令(在client节点执行数据包比较少,服务端对接的客户端比较多因此数据包也比较多,当然也可以加入更多的过滤条件来精确抓包):tcpdump -i eth0 host 192.168.0.2 and port 6789 -w ceph.cap

之后把抓到的ceph.cap数据导入到windows系统的wireshark软件中,我下载的是2.6.2版本(官网有2.6.4版本,但是下载困难,就在国内找了软件站下载了2.6.2的)。导入之后就可以自动分析出结果了。

L版本抓包结果(monmap包解析不太好,认为包有问题“malformed packet”,monitor地址信息解析失败,跟我遇到的问题一样,因此误导了我2天,我一直认为服务端就是没有把地址编码进来,客户端才解析不出来,最后在服务端和客户端分别单步调试才发现是编码进去了的):

 

H版本数据包解析就比较完美了:

Mon Map:

OSD Map:

 

Mon Map编解码过程分析

关键数据结构都定义在src\include\buffer.h中,主要包括:

  • buffer::ptr
    •  _raw:保存实际的编码消息数据
    • buffer::ptr::iterator :遍历ptr的迭代器类,提供各种函数用来寻址ptr中的数据
  • buffer::list
    • _buffers:保存ptr的list,也即保存多条编码数据
    • append_buffer:ptr类型,4K对齐,保存编码数据,,其append操作实际是append到ptr._raw,之后会把它push_back到_buffers中,push_back之前会通过ptr构造函数填充ptr的_raw、_off、_len等数据,也即把_raw中保存的数据的偏移量和长度也保存起来,解码时使用
    • buffer::list::iterator:遍历_buffers的迭代器,继承自buffer::list::iterator_impl,主要是对外提供接口,对内封装了iterator_impl的相关接口
    • buffer::list::iterator_impl:遍历_buffers的迭代器实际实现类,主要实现有advance、seek、copy等函数,用来从_buffers里取出数据,advance函数用来在copy函数执行过程中进行ptr内或_buffers的多个ptr前后跳转,有两种场景,一中是跳转还在当前ptr内(ptr内取数据),另外一种是_buffers list中一个ptr已经copy完(跨ptr取数据),需要跳转到下一个ptr对象继续copy。seek函数也是利用advance函数完成数据寻址操作。

以L版本代码为例,编解码代码如下:

encode调用栈:

decode调用栈:

 

接下来要说的是具体的encode和decode流程,以我调试的mon_addr为例:

encode

其他几个字段也是类似过程,就不做分析,差别就是编码的数据类型不一样,比如string、struct等,string属于基础数据类型,encode.h有对应的encode函数,struct数据类型则需要在对应的struct结构体定义其自己的encode函数,如entity_addr_t::encode (src\msg\msg_types.h)。

decode过程也是类似,根据decode的数据类型,找到对应的decode函数,按段解码即可。数据类型是预先定义好的,如decode(mon_addr, p),mon_addr的类型是已知的,因此其decode函数也可以找到(一般都是跟encode放在一起),只不过由于encode.h中有很多的宏,不好找到源码而已,配合gdb单步调试应该容易很多(记得在do_cmake.sh里的cmake命令那行加上-O0 -g3 -gdwarf-4这几个CXX_FLAGS编译选项: -DCMAKE_CXX_FLAGS="-O0 -g3 -gdwarf-4" -DCMAKE_BUILD_TYPE=Debug)。一般是先解码出消息体长度,然后再逐条解码。总之一切过程都是预先定义好的,一旦收到的消息内容与预设的解码方案不匹配,就会导致各种错误。这就是编解码协议存在的原因。(话说这也是我第一次接触编解码协议,看完这些流程还有点小激动)。

encode是把数据存入消息体,decode是从消息体取出数据,最底层的编码解码都是按字节完成的,编码时会强转为char *类型,底层解码也不区分数据类型,由上层使用方负责进行转换,对应的结构体都定义在src\include\buffer.h中,相关的函数实现在src\common\buffer.cc中。

decode的核心是ceph::buffer::ptr::copy_out () (/mnt/ceph/src/common/buffer.cc:1035),最终都是由它把数据从消息体里取出来的。相关的调用栈可以参考上面贴出来的decode调用栈。

下面附上我在调试过程中手绘的协议字段表格:

L版本:

H版本: