如果要排查網(wǎng)絡(luò)問(wèn)題泻红,tcpdump 或者 Wireshark 是非常好的工具负敏,但有時(shí)候我們想要更多。譬如前一段時(shí)間我們?cè)谂挪橐粋€(gè)奇怪的問(wèn)題城菊,TiKV 在處理一個(gè)請(qǐng)求的時(shí)候報(bào)了 key not in range 的 bug备燃,直觀的說(shuō),就是我們想在某段 range 里面處理一個(gè) key凌唬,但實(shí)際這個(gè) key 根本不是這個(gè) range 里面的并齐。通常出現(xiàn)這種錯(cuò)誤,無(wú)非就是兩種情況,要不是 TiKV 自己內(nèi)部有個(gè) bug况褪,導(dǎo)致了 key 錯(cuò)亂撕贞,要不就上層的 TiDB 給 TiKV 的請(qǐng)求錯(cuò)了,發(fā)過(guò)來(lái)一個(gè)錯(cuò)誤的 key窝剖。
然后 TiDB 這邊麻掸,也可能有幾種情況,要不就是自己內(nèi)部有個(gè) bug赐纱,導(dǎo)致了 key 錯(cuò)亂(可能給 PD 或者 TiKV 發(fā)送了錯(cuò)誤的 key)脊奋,要不就是從 PD 獲取這個(gè) key 的路由信息的時(shí)候錯(cuò)了。
為了確定到底是哪個(gè)組件出了問(wèn)題疙描,我們需要登錄到三個(gè)組件诚隙,詳細(xì)的排查日志,但多數(shù)時(shí)候起胰,請(qǐng)求是沒(méi)有日志的久又,因?yàn)樗麄兌际钦_的請(qǐng)求,只有最終出問(wèn)題那里才有效五。于是另一個(gè)做法就是將日志的級(jí)別調(diào)成 debug地消,這樣是能看到很多請(qǐng)求了,但也會(huì)面臨日志太多畏妖,不容易排查的問(wèn)題脉执。或者干脆重新定制一個(gè)加了特殊 log 的版本戒劫,但這樣多數(shù)時(shí)候在用戶那邊是不可能的半夷。
所以這時(shí)候,如果我們能直接監(jiān)控三個(gè)組件的數(shù)據(jù)交互迅细,那么就能快速的確定到底是哪個(gè)組件出了問(wèn)題巫橄。具體到我們這邊,各個(gè)組件是使用 gRPC 交互的茵典,所以我們只需要將 gRPC 的協(xié)議給解析出來(lái)湘换,最好能解碼成我們實(shí)際定義的 protobuf,這事情就成了统阿。但比較悲催的是彩倚,并沒(méi)有這樣的工具。不過(guò)對(duì)于程序員來(lái)說(shuō)砂吞,沒(méi)有就擼一個(gè)唄署恍,于是就有了 gRPC assembly(后面簡(jiǎn)稱 assembly)。
要實(shí)現(xiàn)一個(gè) assembly蜻直,其實(shí)比較簡(jiǎn)單盯质,無(wú)非幾步:
- 捕獲各個(gè)模塊之間的 TCP 數(shù)據(jù)流
- 將這個(gè) TCP 數(shù)據(jù)通過(guò) HTTP/2 協(xié)議解析
- 通過(guò) HTTP/2 協(xié)議映射到對(duì)應(yīng)的 gRPC service
- 通過(guò)我們定義的 protobuf 格式解析出實(shí)際的數(shù)據(jù)
gopacket
對(duì)于網(wǎng)絡(luò)包的捕獲袁串,Go 里面已經(jīng)有一個(gè)非常方便的庫(kù) - gopacket,這個(gè)庫(kù)方便到你通過(guò)這個(gè)庫(kù)能很快速的自己擼一個(gè) tcpdump 出來(lái)呼巷。
在 gopacket 里面囱修,有一個(gè) httpassembly,我們可以直接解析 HTTP/1 的協(xié)議王悍。自然破镰,對(duì)我來(lái)說(shuō),一個(gè)非常簡(jiǎn)單的做法就是直接在這個(gè)基礎(chǔ)上面压储,弄一個(gè)我們自己的 assembly 了鲜漩,當(dāng)然還是需要有一些變動(dòng)的。
這里我就不多介紹 gopacket 的使用集惋,大家自行 Google孕似,反正也是這玩意也是 Google 弄的。
HTTP/2
通過(guò)第一步刮刑,我們可以得到一個(gè) TCP stream喉祭,那么下一步自然就是將這個(gè) TCP stream 解析成 HTTP/2 了。這里我們先簡(jiǎn)單的說(shuō)說(shuō) HTTP/2雷绢,不同于 HTTP/1泛烙,HTTP2 的消息是基于 frame 的,大家可以詳細(xì)的去參考 RFC7540翘紊。所以對(duì)我們來(lái)說(shuō)蔽氨,解析 HTTP/2,其實(shí)就是解析 frame霞溪。
這里孵滞,我們使用的是 Go 的另一個(gè)庫(kù) http2 中捆。但這個(gè)庫(kù)鸯匹,Google 其實(shí)并沒(méi)有想讓外面直接使用,所以除了 API泄伪,幾乎沒(méi)任何的例子殴蓬。幸運(yùn)的是,Go 的 gRPC 使用的是這個(gè)庫(kù)蟋滴,自然染厅,我們就能知道如何用了,主要在 http_util.go 這個(gè)文件里面津函。將 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)
因?yàn)槲覀兺瑯有枰P(guān)注 HTTP/2 的 header,所以這里需要用 HPACK 的 encoder尔苦,關(guān)于 HPACK涩馆,大家可以參考 RFC7541行施。
創(chuàng)建了 framer,下一步自然就是 for 循環(huán)調(diào)用 framer.ReadFrame()
了魂那。但這里其實(shí)是有一個(gè)問(wèn)題的蛾号,對(duì)于 HTTP/2 的第一次握手來(lái)說(shuō),其實(shí)并不是走的 frame涯雅,所以如果直接調(diào)用 ReadFrame
鲜结,會(huì)發(fā)現(xiàn)程序直接出錯(cuò)了,這也是我最開(kāi)始犯的錯(cuò)誤活逆【ⅲ看了看 HTTP/2 的 starting 這章節(jié),我才知道了出現(xiàn)問(wèn)題蔗候。
幸運(yùn)的是贬养,我們整個(gè)集群因?yàn)槿渴褂玫?gRPC 協(xié)議,所以使用的是 Connection Preface 這種方式琴庵,我們只需要先讓 buf peek 幾個(gè)字符误算,判斷下是不是 PRI * HTTP/2.0
這樣的就可以了,然后我們就可以直接使用 ReadFrame
來(lái)處理剩下的數(shù)據(jù)迷殿。
這里還需要注意儿礼,對(duì)于 PD 來(lái)說(shuō),同一個(gè)端口可以支持 gRPC庆寺,也同時(shí)提供 HTTP/1 的訪問(wèn)蚊夫,所以我們除了要判斷是否是 HTTP/2 的開(kāi)始握手消息,也需要判斷是否是 HTTP/1 的消息懦尝,這個(gè)就比較簡(jiǎn)單了知纷,對(duì)于 HTTP/1 來(lái)說(shuō),request 最開(kāi)始無(wú)非就是 GET陵霉,POST琅轧,PUT 這些,而 response 則是以 HTTP 開(kāi)頭的踊挠。
gRPC
當(dāng)我們拿到了 HTTP/2 的 frame 之后乍桂,我們自然就能映射成對(duì)應(yīng)的 gRPC 協(xié)議了。大家可以詳細(xì)的看看 gRPC over HTTP2 這篇文檔效床。對(duì)于我們來(lái)說(shuō)睹酌,最開(kāi)始關(guān)注的 frame 當(dāng)然是 MetaHeadersFrame 以及 DataFrame。
對(duì)于 MetaHeadersFrame剩檀,如果它里面包含 :path
憋沿,那么它其實(shí)可以認(rèn)為是一個(gè) request,也就是 client 發(fā)給 server 的沪猴,如果里面包含 :status
辐啄,則可以認(rèn)為是一個(gè) response甥绿,也就是 server 回復(fù)給 client 的。對(duì)我們來(lái)說(shuō)则披,拿到 :path
非常的重要共缕,這樣我們就能知道實(shí)際定義的 gRPC service 以及對(duì)應(yīng)的 method 到底是哪一個(gè),然后才能用對(duì)應(yīng)的 protobuf 解析出來(lái)士复。譬如 :path
是 /pdpb.PD/StoreHeartbeat
图谷,那么我們就知道,這個(gè)是一個(gè)定義在 pdpb 文件里面的 PD 的 service阱洪,而函數(shù)是 StoreHeartbeat便贵,然后我們就能夠知道它的 request 和 response 對(duì)應(yīng)的具體的 protobuf,這樣在后面的 DataFrame 里面我們就能夠?qū)?shù)據(jù)解析出來(lái)了冗荸。
通常承璃,如果我們碰到了 :path
,我們需要將這個(gè) frame 的 stream ID 跟 :path
綁定起來(lái)蚌本,這樣 response 的時(shí)候盔粹,我們才能根據(jù) stream ID 找到這個(gè) :path
了。這里程癌,我再次犯了一個(gè)錯(cuò)誤舷嗡,之前我一直以為 stream ID 是全局唯一的,所以我用了一個(gè)全局的 map 來(lái)保存上面的映射關(guān)系嵌莉,但后來(lái)發(fā)現(xiàn)貌似有問(wèn)題进萄,才突然明白,只有對(duì)于單個(gè)連接锐峭,這個(gè) stream ID 是唯一的中鼠。
而對(duì)于 DataFrame,數(shù)據(jù)的第一個(gè)字符表明是否壓縮沿癞,如果是 1援雇,則壓縮算法是在前面 header 里面的 grpc-encoding
字段定義,這里我還沒(méi)考慮抛寝,多數(shù)時(shí)候我們也沒(méi)開(kāi)壓縮熊杨,后面再說(shuō)吧曙旭。
Protocol Buffer
當(dāng)做完上面一步盗舰,其實(shí)我們大部分工作已經(jīng)完成了,但實(shí)際我們還是會(huì)遇到一些問(wèn)題桂躏,主要就是如果這個(gè) assembly 是中途開(kāi)始監(jiān)控網(wǎng)絡(luò)傳輸钻趋,那么極大概率是拿不到 :path
的,如果沒(méi)有 :path
剂习,我們就沒(méi)法解出來(lái)對(duì)應(yīng)的 protobuf message蛮位。
之所以會(huì)出現(xiàn)這樣的問(wèn)題较沪,主要就是因?yàn)?HTTP/2 的 HPACK,HPACK 會(huì)將一些重復(fù)的 header 壓縮省去失仁。對(duì)于 gRPC 來(lái)說(shuō)尸曼,因?yàn)?client 可能在一個(gè) stream 上面多次調(diào)用相同的 method,而這個(gè) :path
都是一樣的萄焦,自然會(huì)被 HPACK 給去掉了控轿。
因?yàn)槲覀儧](méi)辦法知道實(shí)際的 protobuf,所以唯一的辦法就是將這個(gè)數(shù)據(jù)按照 protobuf 的編碼格式給打印出來(lái)拂封,詳細(xì)的編碼可以參考 encoding茬射。對(duì)于 Go 來(lái)說(shuō),我們可以直接使用 DebugPrint冒签。
但 DebugPrint 這個(gè)函數(shù)輸出來(lái)的東西我不怎么喜歡在抛,于是還是重新自己擼了一個(gè),畢竟很簡(jiǎn)單萧恕,首先 DecodeVarint刚梭,然后得到這個(gè) field 實(shí)際的 tag number 以及 Wire Type,然后在根據(jù)對(duì)應(yīng)的 wire type 打印出值票唆。但這里望浩,因?yàn)槲覀兡貌坏綄?shí)際的 protobuf,所以并不清楚這些值的具體含義惰说。譬如對(duì)于 wire type 為 0 的數(shù)據(jù)磨德,我們并不清楚它到底是 int32,還是 int64吆视,或者是 sint32禁荸。
而對(duì)于 wire type 為 2 的數(shù)據(jù)棚愤,我們也不知道它到底是 string,還是 embedded struct,這里我稍微做了一點(diǎn)優(yōu)化蟋座,會(huì)嘗試去先按照 embedded struct 解碼,如果能正常解開(kāi)假勿,表明是一個(gè) embedded struct这吻,否則就不是。但對(duì)于 Repeated Fields般堆,我暫時(shí)還沒(méi)啥太好的想法在孝。
后面大概的輸出類似這樣:
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
雖然多數(shù)時(shí)候還是看不出來(lái)啥,但聊勝于無(wú)吧淮摔。不過(guò)如果我們大概能猜到是什么樣的 protobuf私沮,還是能直接匹配上去的。
Demo
最后大概的效果類似如下和橙,啟動(dòng)程序仔燕,監(jiān)控 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"
通過(guò)這個(gè)工具造垛,我們還是能非常方便的看出來(lái)各個(gè)組件之間到底在干啥,對(duì)查問(wèn)題還是有幫助的晰搀。當(dāng)然五辽,這只是一個(gè)小 demo,如果哪位有興趣外恕,歡迎聯(lián)系我奔脐,一起加入把這個(gè)東西給做完善,tl@pingcap.com吁讨。