如果要排查网络问题,tcpdump 或者 Wireshark 是非常好的工具,但有时候我们想要更多。譬如前一段时间我们在排查一个奇怪的问题,TiKV 在处理一个请求的时候报了 key not in range 的 bug,直观的说,就是我们想在某段 range 里面处理一个 key,但实际这个 key 根本不是这个 range 里面的。通常出现这种错误,无非就是两种情况,要不是 TiKV 自己内部有个 bug,导致了 key 错乱,要不就上层的 TiDB 给 TiKV 的请求错了,发过来一个错误的 key。
然后 TiDB 这边,也可能有几种情况,要不就是自己内部有个 bug,导致了 key 错乱(可能给 PD 或者 TiKV 发送了错误的 key),要不就是从 PD 获取这个 key 的路由信息的时候错了。
为了确定到底是哪个组件出了问题,我们需要登录到三个组件,详细的排查日志,但多数时候,请求是没有日志的,因为他们都是正确的请求,只有最终出问题那里才有。于是另一个做法就是将日志的级别调成 debug,这样是能看到很多请求了,但也会面临日志太多,不容易排查的问题。或者干脆重新定制一个加了特殊 log 的版本,但这样多数时候在用户那边是不可能的。
所以这时候,如果我们能直接监控三个组件的数据交互,那么就能快速的确定到底是哪个组件出了问题。具体到我们这边,各个组件是使用 gRPC 交互的,所以我们只需要将 gRPC 的协议给解析出来,最好能解码成我们实际定义的 protobuf,这事情就成了。但比较悲催的是,并没有这样的工具。不过对于程序员来说,没有就撸一个呗,于是就有了 gRPC assembly(后面简称 assembly)。
要实现一个 assembly,其实比较简单,无非几步:
- 捕获各个模块之间的 TCP 数据流
- 将这个 TCP 数据通过 HTTP/2 协议解析
- 通过 HTTP/2 协议映射到对应的 gRPC service
- 通过我们定义的 protobuf 格式解析出实际的数据
gopacket
对于网络包的捕获,Go 里面已经有一个非常方便的库 - gopacket,这个库方便到你通过这个库能很快速的自己撸一个 tcpdump 出来。
在 gopacket 里面,有一个 httpassembly,我们可以直接解析 HTTP/1 的协议。自然,对我来说,一个非常简单的做法就是直接在这个基础上面,弄一个我们自己的 assembly 了,当然还是需要有一些变动的。
这里我就不多介绍 gopacket 的使用,大家自行 Google,反正也是这玩意也是 Google 弄的。
HTTP/2
通过第一步,我们可以得到一个 TCP stream,那么下一步自然就是将这个 TCP stream 解析成 HTTP/2 了。这里我们先简单的说说 HTTP/2,不同于 HTTP/1,HTTP2 的消息是基于 frame 的,大家可以详细的去参考 RFC7540。所以对我们来说,解析 HTTP/2,其实就是解析 frame。
这里,我们使用的是 Go 的另一个库 http2 。但这个库,Google 其实并没有想让外面直接使用,所以除了 API,几乎没任何的例子。幸运的是,Go 的 gRPC 使用的是这个库,自然,我们就能知道如何用了,主要在 http_util.go 这个文件里面。将 frame 的使用放到之前的 HTTP assembly 里面,类似如下:
func (h *httpStream) run() {
buf := bufio.NewReader(&h.r)
framer := http2.NewFramer(ioutil.Discard, buf)
framer.MaxHeaderListSize = uint32(16 << 20)
framer.ReadMetaHeaders = hpack.NewDecoder(4096, nil)
因为我们同样需要关注 HTTP/2 的 header,所以这里需要用 HPACK 的 encoder,关于 HPACK,大家可以参考 RFC7541。
创建了 framer,下一步自然就是 for 循环调用 framer.ReadFrame()
了。但这里其实是有一个问题的,对于 HTTP/2 的第一次握手来说,其实并不是走的 frame,所以如果直接调用 ReadFrame
,会发现程序直接出错了,这也是我最开始犯的错误。看了看 HTTP/2 的 starting 这章节,我才知道了出现问题。
幸运的是,我们整个集群因为全部使用的 gRPC 协议,所以使用的是 Connection Preface 这种方式,我们只需要先让 buf peek 几个字符,判断下是不是 PRI * HTTP/2.0
这样的就可以了,然后我们就可以直接使用 ReadFrame
来处理剩下的数据。
这里还需要注意,对于 PD 来说,同一个端口可以支持 gRPC,也同时提供 HTTP/1 的访问,所以我们除了要判断是否是 HTTP/2 的开始握手消息,也需要判断是否是 HTTP/1 的消息,这个就比较简单了,对于 HTTP/1 来说,request 最开始无非就是 GET,POST,PUT 这些,而 response 则是以 HTTP 开头的。
gRPC
当我们拿到了 HTTP/2 的 frame 之后,我们自然就能映射成对应的 gRPC 协议了。大家可以详细的看看 gRPC over HTTP2 这篇文档。对于我们来说,最开始关注的 frame 当然是 MetaHeadersFrame 以及 DataFrame。
对于 MetaHeadersFrame,如果它里面包含 :path
,那么它其实可以认为是一个 request,也就是 client 发给 server 的,如果里面包含 :status
,则可以认为是一个 response,也就是 server 回复给 client 的。对我们来说,拿到 :path
非常的重要,这样我们就能知道实际定义的 gRPC service 以及对应的 method 到底是哪一个,然后才能用对应的 protobuf 解析出来。譬如 :path
是 /pdpb.PD/StoreHeartbeat
,那么我们就知道,这个是一个定义在 pdpb 文件里面的 PD 的 service,而函数是 StoreHeartbeat,然后我们就能够知道它的 request 和 response 对应的具体的 protobuf,这样在后面的 DataFrame 里面我们就能够将数据解析出来了。
通常,如果我们碰到了 :path
,我们需要将这个 frame 的 stream ID 跟 :path
绑定起来,这样 response 的时候,我们才能根据 stream ID 找到这个 :path
了。这里,我再次犯了一个错误,之前我一直以为 stream ID 是全局唯一的,所以我用了一个全局的 map 来保存上面的映射关系,但后来发现貌似有问题,才突然明白,只有对于单个连接,这个 stream ID 是唯一的。
而对于 DataFrame,数据的第一个字符表明是否压缩,如果是 1,则压缩算法是在前面 header 里面的 grpc-encoding
字段定义,这里我还没考虑,多数时候我们也没开压缩,后面再说吧。
Protocol Buffer
当做完上面一步,其实我们大部分工作已经完成了,但实际我们还是会遇到一些问题,主要就是如果这个 assembly 是中途开始监控网络传输,那么极大概率是拿不到 :path
的,如果没有 :path
,我们就没法解出来对应的 protobuf message。
之所以会出现这样的问题,主要就是因为 HTTP/2 的 HPACK,HPACK 会将一些重复的 header 压缩省去。对于 gRPC 来说,因为 client 可能在一个 stream 上面多次调用相同的 method,而这个 :path
都是一样的,自然会被 HPACK 给去掉了。
因为我们没办法知道实际的 protobuf,所以唯一的办法就是将这个数据按照 protobuf 的编码格式给打印出来,详细的编码可以参考 encoding。对于 Go 来说,我们可以直接使用 DebugPrint。
但 DebugPrint 这个函数输出来的东西我不怎么喜欢,于是还是重新自己撸了一个,毕竟很简单,首先 DecodeVarint,然后得到这个 field 实际的 tag number 以及 Wire Type,然后在根据对应的 wire type 打印出值。但这里,因为我们拿不到实际的 protobuf,所以并不清楚这些值的具体含义。譬如对于 wire type 为 0 的数据,我们并不清楚它到底是 int32,还是 int64,或者是 sint32。
而对于 wire type 为 2 的数据,我们也不知道它到底是 string,还是 embedded struct,这里我稍微做了一点优化,会尝试去先按照 embedded struct 解码,如果能正常解开,表明是一个 embedded struct,否则就不是。但对于 Repeated Fields,我暂时还没啥太好的想法。
后面大概的输出类似这样:
tag=1 struct
tag=1 varint=2037210783374497686
tag=2 varint=13195394291058371180
tag=3 varint=244
tag=4 varint=2
tag=2 varint=1
tag=3 struct
tag=2 struct
tag=1 struct
tag=3 varint=244
虽然多数时候还是看不出来啥,但聊胜于无吧。不过如果我们大概能猜到是什么样的 protobuf,还是能直接匹配上去的。
Demo
最后大概的效果类似如下,启动程序,监控 TiKV 的 20160 端口:
go run assembly.go -f "port 20160" -i lo0
2018/12/29 20:17:17 Starting capture on interface "lo0"
2018/12/29 20:17:17 reading in packets
2018/12/29 20:17:26 127.0.0.1:64989 -> 127.0.0.1:20160 /tikvpb.Tikv/KvPrewrite context:<region_id:2 region_epoch:<conf_ver:1 version:1 > peer:<id:3 store_id:1 > > mutations:<key:"usertable:a" value:"\010\000\002\0020" > primary_lock:"usertable:a" start_version:405297128206237697 lock_ttl:3000
2018/12/29 20:17:26 127.0.0.1:20160 -> 127.0.0.1:64989 /tikvpb.Tikv/KvPrewrite
2018/12/29 20:17:26 127.0.0.1:64995 -> 127.0.0.1:20160 /tikvpb.Tikv/KvCommit context:<region_id:2 region_epoch:<conf_ver:1 version:1 > peer:<id:3 store_id:1 > > start_version:405297128206237697 keys:"usertable:a" commit_version:405297128206237698
2018/12/29 20:17:26 127.0.0.1:20160 -> 127.0.0.1:64995 /tikvpb.Tikv/KvCommit
2018/12/29 20:17:29 127.0.0.1:64999 -> 127.0.0.1:20160 /tikvpb.Tikv/KvGet context:<region_id:2 region_epoch:<conf_ver:1 version:1 > peer:<id:3 store_id:1 > > key:"usertable:a" version:405297128901443585
2018/12/29 20:17:29 127.0.0.1:20160 -> 127.0.0.1:64999 /tikvpb.Tikv/KvGet value:"\010\000\002\0020"
通过这个工具,我们还是能非常方便的看出来各个组件之间到底在干啥,对查问题还是有帮助的。当然,这只是一个小 demo,如果哪位有兴趣,欢迎联系我,一起加入把这个东西给做完善,tl@pingcap.com。