gRPC Assembly

如果要排查網絡問題,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,其實比較簡單,無非幾步:

  1. 捕獲各個模塊之間的 TCP 數據流
  2. 將這個 TCP 數據通過 HTTP/2 協議解析
  3. 通過 HTTP/2 協議映射到對應的 gRPC service
  4. 通過我們定義的 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,如果哪位有興趣,歡迎聯繫我,一起加入把這個東西給做完善,[email protected]

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章