李樂
問題引入
2020-12-25日晚,突然接收到少量錯誤日誌報警『failed to dial server: dial tcp xxxx:yy: i/o timeout』。原來是微服務客戶端請求服務端,連線失敗。
簡單介紹下服務現狀:我們的服務部署在k8s環境,微服務框架我們使用的是smallnest/rpcx,註冊中心基於zookeeper,鏈路如下圖所示:
- 第一步:這些連線超時的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。
可以看到,每一個請求(響應),頭部都有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 ...............
……
整個過程的互動流程如下圖所示:
可以看到,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資料更新邏輯了。
如圖所示,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列表。這時候的流程應該是如下圖所示:
檢視檔案中的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併發問題確實是很容易忽視,卻又很容易產生,平時開發還需多注意多思考。
公眾號
該系列文章會同步到以下公眾號