Golang之微服務為什麼發現不了

LNMPR原始碼研究發表於2021-01-03

李樂

問題引入

  2020-12-25日晚,突然接收到少量錯誤日誌報警『failed to dial server: dial tcp xxxx:yy: i/o timeout』。原來是微服務客戶端請求服務端,連線失敗。

  簡單介紹下服務現狀:我們的服務部署在k8s環境,微服務框架我們使用的是smallnest/rpcx,註冊中心基於zookeeper,鏈路如下圖所示:
image

  • 第一步:這些連線超時的pod(一個pod相當於一臺虛擬機器)是有什麼異常嗎?根據連線超時的IP地址查詢對應的pod,發現沒有一個pod是這個IP地址;那這個IP地址是從哪來的呢?難道是在某個隱祕的角落啟動的?
  • 第二步:連線註冊中心,檢視該服務註冊的IP列表,發現也不存在上面超時的IP地址。
  • 進一步:這個異常IP地址,k8s環境歷史確實分配過;猜測服務端重啟後,IP地址變了,但是客戶端卻沒有更新IP列表,導致還請求老的IP地址。

  另外,錯誤日誌只集中在一個pod,即只有一個pod沒有更新服務端的IP列表。初步猜測可能有兩種原因:1)這個客戶端與zookeeper之間連線存在異常,所以zookeeper無法通知資料變更;2)服務發現框架存在程式碼異常,且只在某些場景觸發,導致無法更新本地IP列表,或者是沒有watch資料變更。

  針對第一種猜測,很簡單登入到該異常pod,檢視與zookeeper之間的連線即可:

# netstat -anp | grep 2181
tcp        0      0 xxxx:51970     yyyy:2181        ESTABLISHED 9/xxxx
tcp        0      0 xxxx:40510     yyyy:2181        ESTABLISHED 9/xxxx

  可以看到存在兩條正常的TCP連線,為什麼是兩條呢?因為該程式不止作為客戶端訪問其他服務,還作為服務端供其他客戶端呼叫,其中一條連線是用來註冊服務,另外一條連線用來發現服務。tcpdump抓包看看這兩條連線的資料互動:

23:01:58.363413 IP xxxx.51970 > yyyy.2181: Flag [P.], seq 2951753839:2951753851, ack 453590484, win 356, length 12
23:01:58.363780 IP yyyy.2181 > xxxx.51970: Flags [P.], seq 453590484:453590504, ack 2951753851, win 57, length 20
23:01:58.363814 IP xxxx.51970 > yyyy.2181: Flags [.], ack 453590504, win 356, length 0

……

  上面省略了抓包的內容部分。注意zookeeper點採用二進位制協議,不太方便識別但是基本可以確信,這是ping-pong心跳包(定時互動,且每次資料一致)。並且,兩條連線都有正常的心跳包頻繁互動。

  pod與zookeeper之間連線正常,那麼很可能就是服務發現框架的程式碼問題了。

模擬驗證

  通過上面的分析,可能的原因是:服務發現框架存在程式碼異常,且只在某些場景觸發,導致無法更新本地IP列表,或者是沒有watch資料變更。

  客戶端有沒有watch資料變更,這一點非常容易驗證;只需要重啟一臺服務,客戶端tcpdump抓包就行。只不過zookeeper點採用二進位制協議,不好分析資料包內容。

  簡單介紹下zookeeper通訊協議;如下圖所示,圖中4B表示該欄位長度為4Byte。
image
  可以看到,每一個請求(響應),頭部都有4位元組標識該請求體的長度;另外,請求頭部Opcode標識該請求型別,比如獲取節點資料,建立節點等。watch事件通知是沒有請求,只有響應,其中Type標識事件型別,Path為發生事件的節點路徑。

  從zookeeper SDK可以找到所有請求型別,以及事件型別的定義。

const (
    opNotify       = 0
    opCreate       = 1
    opDelete       = 2
    opExists       = 3
    opGetData      = 4   //獲取節點資料,這是我們需要關注的
    opSetData      = 5
    opGetAcl       = 6
    opSetAcl       = 7
    opGetChildren  = 8
    opSync         = 9
    opPing         = 11
    opGetChildren2 = 12  //獲取子節點列表,這是我們需要關注的
    opCheck        = 13
    opMulti        = 14
    opClose        = -11
    opSetAuth      = 100
    opSetWatches   = 101
)

const (
    EventNodeCreated         EventType = 1
    EventNodeDeleted         EventType = 2
    EventNodeDataChanged     EventType = 3
    EventNodeChildrenChanged EventType = 4 //子節點列表變化,這是我們需要關注的
)

  下面可以開始操作了,客戶端tcpdump開啟抓包,服務端殺死一個pod,分析抓包資料如下:

//zookeeper資料變更事件通知
23:02:02.717505 IP xxxx.2181 > xxxx.51970: Flags [P.], seq 453590524:453590585, ack 2951753863, win 57, length 61
                  0000 0039 ffff ffff ffff ffff ffff  .....9..........
    0x0050:  ffff 0000 0000 0000 0004 0000 0003 0000  ................
    0x0060:  001d xxxx xxxx    xxxx xxxx xxxx xxxx    xxxx  ../xxxxxxxxxxxxx
    0x0070:  xxxx xxxx xxxx xxxx xxxx xxxx xxxx xx    xxxxxxxxxxxxxxx
23:02:02.717540 IP xxxx.51970 > xxxx.2181: Flags [.], ack 453590585, win 356, length 0

//客戶端發起請求,獲取子節點列表
23:02:02.717752 IP xxxx.51970 > xxxx.2181: Flags [P.], seq 2951753863:2951753909, ack 453590585, win 356, length 46
                  0000 002a 0000 4b2f 0000 000c 0000  .....*..K/......
    0x0050:  001d xxxx xxxx    xxxx xxxx xxxx xxxx    xxxx  ../xxxxxxxxxxxxx
    0x0060:  xxxx xxxx xxxx xxxx xxxx xxxx xxxx xx00  xxxxxxxxxxxxxxx.
//zookeeper響應,包含服務端所有節點(IP)
23:02:02.718500 IP xxxx.2181 > xxxx.51970: Flags [P.], seq 453590585:453591858, ack 2951753909, win 57, length 1273

//遍歷所有節點(IP),獲取資料(metadata)
23:02:02.718654 IP xxxx.51970 > xxxx.2181: Flags [P.], seq 2951753909:2951753978, ack 453591858, win 356, length 69
                  0000 0041 0000 4b30 0000 0004 0000  .....A..K0......
    0x0050:  0034 xxxx xxxx xxxx xxxx xxxx xxxx xxxx  .4/xxxxxxxxxxxxx
    0x0060:  xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx  xxxxxxxxxxxxxxxx
    0x0070:  xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx  xxxxxxxxxxxxxxxx
    0x0080:  xxxx xxxx xxxx 00                        xxxxxxx
23:02:02.720273 IP xxxx.2181 > xxxx.51970: Flags [P.], seq 453591858:453591967, ack 2951753978, win 57, length 109
                  0000 0069 0000 4b30 0000 0003 0ab3  .....i..K0......
    0x0050:  ad90 0000 0000 0000 0011 6772 6f75 703d  ..........group=
    0x0060:  6f6e 6c69 6e65 2674 7073 3d00 0000 030a  online&tps=.....
    0x0070:  b2ff ed00 0000 030a b3ad 5800 0001 76ae  ..........X...v.
    0x0080:  d003 dd00 0001 76af 051d 6d00 0000 3a00  ......v...m...:.
    0x0090:  0000 0000 0000 0001 703f 90a3 f679 ce00  ........p?...y..
    0x00a0:  0000 1100 0000 0000 0000 030a b2ff ed    ...............
    
……

  整個過程的互動流程如下圖所示:
image
  可以看到,zookeeper在資料變更時通知客戶端了,而客戶端也拉取最新節點列表了,而且獲取到的節點IP列表都是正確的。這就奇怪了,都已經獲取到最新的IP列表了,為什麼還請求老的IP地址?是沒有更新記憶體中的資料嗎?這就review程式碼了。

程式碼Review

  我們的微服務框架使用的是rpcx,監聽zookeeper資料變更的邏輯,如下所示:

for {
        _, _, eventCh, err := s.client.ChildrenW(s.normalize(directory))
        
        select {
        case e := <-eventCh:
            if e.Type == zk.EventNodeChildrenChanged {
                keys, stat, err := s.client.Children(s.normalize(directory))
            
                for _, key := range keys {
                    pair, err := s.Get(strings.TrimSuffix(directory, "/") + s.normalize(key))
                }
            }
        }
}

  注意獲取子節點列表的兩個方法,ChildrenW以及Children;這兩是有區別的:

func (c *Conn) Children(path string) ([]string, *Stat, error) {
    _, err := c.request(opGetChildren2, &getChildren2Request{Path: path, Watch: false}, res, nil)
    return res.Children, &res.Stat, err
}

func (c *Conn) ChildrenW(path string) ([]string, *Stat, <-chan Event, error) {
    _, err := c.request(opGetChildren2, &getChildren2Request{Path: path, Watch: true}, res, func(req *request, res *responseHeader, err error) {
    return res.Children, &res.Stat, ech, err
}

  原來,方法的字尾『W』代表著是否設定監聽器。這裡讀者需要知道,zookeeper的監聽器是一次性的。即客戶端設定監聽器後,資料變更時候,zookeeper查詢監聽器通知客戶端,同時會刪除該監聽器。這就導致下次資料變更時候不會通知客戶端了。

  這有什麼問題嗎?也許會有問題。客戶端接收到資料變更後主要有三步邏輯:1)獲取子節點列表,注意這時候並沒有設定監聽器;2)遍歷所有節點獲取資料;3)獲取子節點列表,設定監聽器,等待zookeeper事件通知。注意從第一步到第三步,是有耗時的,特別是服務端節點數目過多時候,多次請求耗時必然更高,那麼在這之間的資料變更客戶端是感知不到的。再結合程式碼升級流程,是滾動升級,即啟動若干新版本pod(目前配置25%數目),如果這些pod正常啟動,則殺掉部分老版本pod;以此類推。

  如果從第一部分新版本pod啟動,到最後一部分新版本pod啟動以及殺掉最後一些老版本pod,之間間隔非常短呢?小於上面第一步到第三步耗時呢?這樣就會導致,客戶端即存在老的IP列表,也存在新的IP列表。(上一小節模擬驗證時候,只是殺死一個pod驗證資料監聽機制,並沒有模擬大量pod重啟過程並分析資料互動,因此無法確定這類場景是否存在問題)

  繼續分析日誌,發現客戶端請求所有服務端的IP地址都是錯誤的,即客戶端並不存在新的IP地址,不符合該場景。另外,再回首分析下模擬驗證時候抓的資料包,第一步到第三步的時間間隔是非常非常小的,畢竟服務端機器數目不多,內網訪問zookeeper也比較快,而滾動釋出過程一般很慢,遠大於該間隔。

//第一步獲取子節點列表,沒有設定監聽器;注意最後一個位元組為0x00,即watch=false
23:02:02.717752 IP xxxx.51970 > xxxx.2181: Flags [P.], seq 2951753863:2951753909, ack 453590585, win 356, length 46
                  0000 002a 0000 4b2f 0000 000c 0000  .....*..K/......
    0x0050:  001d xxxx xxxx    xxxx xxxx xxxx xxxx    xxxx  ../xxxxxxxxxxxxx
    0x0060:  xxxx xxxx xxxx xxxx xxxx xxxx xxxx xx00  xxxxxxxxxxxxxxx.
    
//第一步獲取子節點列表,並設定監聽器;注意最後一個位元組為0x01,即watch=true
23:02:02.768422 IP xxxx.51970 > xxxx.2181: Flags [P.], seq 2951757025:2951757071, ack 453596850, win 356, length 46
                  0000 002a 0000 4b5d 0000 000c 0000  .....*..K]......
    0x0050:  001d xxxx xxxx    xxxx xxxx xxxx xxxx    xxxx  ../xxxxxxxxxxxxx
    0x0060:  xxxx xxxx xxxx xxxx xxxx xxxx xxxx xx01  xxxxxxxxxxxxxxx.
    
//間隔50ms左右

  不過,zookeeper監聽器一次性機制還是需要關注,以防出現資料變更無法同步問題。

柳暗花明

  還能有什麼原因呢?只能繼續扒程式碼了,既然rpcx獲取到了最新的IP列表,為什麼沒有更新呢?這就需要重點分析rpcx資料更新邏輯了。
image
  如圖所示,ZookeeperDiscovery監聽到服務端IP變更時候,將最新的IP列表寫入chan,rpcxClient通過chan可獲取最新的IP列表,並更新selector(selector提供負載均衡能力)。這個邏輯可以說是非常簡單了,沒有理由會出現異常。但是事實證明,異常大概率就在這塊邏輯。難道是rpcxClient讀取chan資料的協程有異常了?看看協程棧幀,也並沒有問題。

//客戶端畢竟不止是隻訪問一個微服務,所以會存在多個rpcxClient;
//這至少說明ZookeeperDiscovery監聽協程與rpcxClient讀chan協程數目是一致的。

 5   runtime.gopark
     runtime.goparkunlock
     runtime.chanrecv
     runtime.chanrecv2
     github.com/smallnest/rpcx/client.(*xClient).watch
-----------+-------------------------------------------------------
5   runtime.gopark
    runtime.selectgo
    github.com/smallnest/rpcx/client.(*ZookeeperDiscovery).watch

  只能繼續探索。。。

  聯想到之前還新增了服務發現災備邏輯(防止zookeeper出現異常或者客戶端到zookeeper之間鏈路異常),在監聽到zookeeper資料變化時,還會將該資料寫入本地檔案。服務啟動時,如果zookeeper無法連線,可以從本地檔案讀取服務端IP列表。這時候的流程應該是如下圖所示:
image
  檢視檔案中的IP列表以及檔案更新時間,發現都沒有任何問題:

# stat /xxxx
  File: /xxxx
Access: 2020-12-24 22:06:16.000000000
Modify: 2020-12-29 23:02:14.000000000
Change: 2020-12-29 23:02:14.000000000

  這就不可思議了,檔案中都是正確的IP列表,rpcxClient卻不是?而rpcxClient更新資料的邏輯簡單的不能再簡單了,基本沒有出錯的可能性啊。難道是基於chan的資料通訊有問題?再研究研究chan相關邏輯。

  rpcxClient與LocalWrapClient是通過WatchService方法獲取通訊的chan。可以看到,這裡新建chan,並append到d.chans切片中。那如果兩個協程同時WatchService呢?就可能出現併發問題,切片中最終可能只會有一個chan!這就解釋了為什麼本地檔案可以正常更新,rpcxClient始終無法更新。

func (d *ZookeeperDiscovery) WatchService() chan []*KVPair {
    ch := make(chan []*KVPair, 10)
    d.chans = append(d.chans, ch)
    return ch
}

  我們再寫個小例子模擬一下這種case,驗證併發append問題:

package main

import (
    "fmt"
    "sync"
)

func main() {
    ok := true
    for i := 0; i <1000; i ++ {
        var arr []int
        wg := sync.WaitGroup{}

        for j := 0; j <2; j ++ {
            wg.Add(1)

            go func() {
                defer wg.Done()
                arr = append(arr, i)
            }()
        }
        wg.Wait()

        if len(arr) < 2 {
            fmt.Printf("error:%d \n", i)
            ok = false
            break
        }
    }

    if ok {
        fmt.Println("ok")
    }
}

//error:261 

  至此,問題基本明瞭。解決方案也很簡單,去掉服務發現災備邏輯即可。

總結

  初次遇到這問題時候,覺得匪夷所思。基於現狀,冷靜分析問題產生情況,一個一個去排查或者排除,切記急躁。

  抓包驗證,二進位制協議又不方便分析,只能去研究zookeeper通訊協議了。最終還是需要一遍一遍Review程式碼,尋找蛛絲馬跡,不要忽視任何可能產生的異常。

  最後,Golang併發問題確實是很容易忽視,卻又很容易產生,平時開發還需多注意多思考。

公眾號

  該系列文章會同步到以下公眾號

image

相關文章