Kubernetes 中網站無法訪問,深入排查實戰

開篇點題, 這其實是一次深入探索問題本質的一次排查故事,之所以想寫這個,是因爲這個問題的現象和最後分析出來的原因看起來有點千差萬別。因爲感覺排查過程可以抽象成一個通用的排查思維邏輯, 所以各位看完後可以這個抽象是否做成功了

起(問題發生)

故事的起因和大多數排查故事一樣, 並沒有什麼特別的.就是普通的一天早上,正帶着愉快心情上班時,突然被拉了一個會議,然後老闆在會議中特別着急的表達了問題以及嚴重性,於是我也特別着急的開始了排查。

問題也是很普通,外部大客戶發現一個容器裏的應用無法響應請求了,特別着急的找到了我們這邊。

從會議中聽到的內容總結了一下, 大致是容器裏的一個server進程沒法響應http請求,我包括其他同學理所當然的以爲容器網絡可能出問題了,然後我登錄到宿主機上, 按套路查看容器網絡聯通性,路由等,發現網絡正常沒有任何問題,折騰完了之後完全一臉懵,不知道到底是啥情況

承(開始排查,居然不是網絡問題)

按正常套路排查沒有任何結果後,我又諮詢了上層應用同學關於服務的信息, 希望從用戶的部署服務類型看出一點信息,上層應用同學從k8s集羣中查看了pod的信息,發現是一個普通的java應用,參數也沒有奇怪的地方.

這裏簡直沒有頭緒,我又問了一下問題出現前線上有沒有做變更,結果果然有做,昨天晚上剛更新了容器引擎的版本,也就是說容器引擎被重啓了.正是因爲我對容器太瞭解了,理所當然的覺得容器引擎重啓不會對已運行的容器有任何影響,所以暫時對這個線索不是很上心.

到了這,線上的排查基本結束,線上的問題只能先重新創建pod來解決.當時對k8s還不熟,我們請上層同學先嚐試復現問題,然後再進行線下的排查.也多虧了一位同學線下復現出了問題,排查才又有了進展

復現時用kubectl查看pod日誌時偶然發現當應用請求卡住的時候,容器的標準輸出也斷了.結合線上用戶的bug案例,作出了一個簡單的分析,應該是應用進程在響應用戶請求時需要打印一些內容,當這個步驟卡住時,就無法繼續響應請求,表面上看就是用戶的請求卡住了.排查進入到這裏,距離發現最終bug的根因就比較近了

轉(定位根因)

問題的觸發的條件

  1. 進程要向容器標準輸出打印日誌
  2. 容器引擎重啓

問題觸發是因爲容器引擎重啓觸發的,重啓後發現容器的標準輸出就斷了,容器裏的進程也無法響應請求了,並且通過debug發現容器收到了SIGPIPE的信號.再結合容器是如何轉發stdio到容器引擎的原理,基本上定位了原因,原來是容器用來轉發stdio的fifo(linux 命名管道)斷了.去看了線上shim打開的fifo fd已經被關閉也確認了這點

原因定位了,但是代碼bug還沒有找出來,雖然我當時對容器非常熟,但是我對fifo的工作原理可非常不熟,如果當時我對fifo的原理了解的話,可能下午就定位出了問題,不至於用了一天的時間(這個問題後續又發生了一次,也是fifo的問題,但是是另外一個bug,第二次等我自己線下復現之後,下午就定位出來了).

下面先介紹一下和問題相關的fifo部分的工作原理

不打開fifo讀端或多次重新打開讀端, 只寫方式打開fifo寫端, 若寫入fifo裏的數據超過緩衝區,fifo寫端報EPIPE(Broken pipe)錯誤退出, 發出SIGPIPE的信號.如果讀寫方式打開fifo寫端,就不會有這個問題

對比了問題代碼,打開fifo的方式正是O_WRONLY的方式,之前沒有出問題居然是因爲從來沒有更新過容器引擎,昨天晚上第一次更新直接觸發了這個問題.困擾大家一天的問題竟然只需要改一個單詞,把O_WRONLY -> O_RDWR就可以了

也可以用下面這段簡單的代碼來自行驗證一下

好了,問題分析完了,下面我要開始寫容器引擎接管stdio的原理了,對容器部分原理沒有興趣的同學可以直接跳到"合"的章節了

原理解析(容器創建原理及接管stdio)

稍微提一下,出問題的不是runc容器,是kata安全容器, runc容器畢竟用的多bug也比較少了

以下原理解析我都以pouch(https://github.com/alibaba/pouch)+ containerd(https://github.com/containerd/containerd)+ runc(https://github.com/opencontainers/runc.git)的方式來做分析

從低向上容器1號進程IO的流轉

進程的stdio指向pipe一端 -> shim進程打開的pipe另一端 ->shim進程打開的fifo寫端 -> pouch打開的fifo寫端 -> pouch指定的IO輸出地址,默認是json文件

容器IO的創建和是否需要terminal,是否有stdin有關係,爲了簡單起見,我們下面的流程介紹都是後臺運行一個容器爲例來講解,即只會創建容器的stdout和stderr,用pouch命令來表述,就是執行下面的命令後,如何從pouch logs看到進程的日誌輸出
pouch run -d nigix
pouch創建容器與初始化IO

簡單介紹一下pouch創建容器的流程,pouch和dokcer一樣,也是基於containerd去管理容器的,即pouch啓動會拉起一個containerd進程,pouch發起各種容器相關的請求時,通過grpc和containerd通信,containerd收到請求後,調用對應的runtime接口操作容器,這裏的runtime可以有很多類型,大家最常用的就是runc了,當然也可以是上方案例中的kata安全容器,你也可以按照oci標準自己實現一個自己runtime,這是題外話了.

pouch調用containerd的NewTask接口發起一個創建容器命令,這個函數的第二個參數是初始化IO的函數指針,看一下碼,https://github.com/alibaba/pouch/blob/master/ctrd/container.go#L677-L685

看一下圖中的代碼,cio包裏代碼是隻讀阻塞的模式(雖然flags傳了NONBLOCK,但是在fifo包裏會被去掉)打開stdout和stderr2個fifo的,pouch打開2個fifo後,會開始拷貝2個容器IO流,io.Copy的讀端是fifo的輸入,寫端是可以自定義的,寫端可以是json文件,syslog或其他.換個說法,這裏的寫端就是容器引擎配置的log-driver

這裏打開fifo的部分要注意一下,containerd fifo包封裝了整個流程,和直接調用是不一樣的,最直接看出不同的地方就是打開文件的個數,重新放一張上面案例中發過的示例圖,代碼裏對stdout和stderr2個fifo文件只打開了一次,但是這個fd顯示文件被打開了2次,這是因爲fifo包裏對fifo的處理加了一層,打開了2次,第一次打開的是fifo文件,即下面的路徑,第二次按參數指定的flag打開了第一次打開的fd文件, 即/proc/self/fd/22.

之所以打開2次是爲了fifo文件在物理上被刪除後,內存中打開的fd也可以被關閉

containerd創建容器與初始化IO

還是先介紹一下containerd創建容器的大致原理,其實這裏還有一個shim進程,準確來說,shim是實際管理容器進程,也就是說shim是容器1號進程的父進程,containerd和shim之間通過ttrpc交互(ttrpc是containerd社區實現的低內存佔用的grpc版本),containerd收到創建容器請求時,會創建一個shim進程,然後通過ttrpc發送後續的相關請求.

shim創建容器的同時會初始化容器IO,相關代碼可以看一下這幾個文件,https://github.com/containerd/containerd/tree/master/pkg/process
shim先創建os.Pipe,因爲這個容器只需要stdout和stderr,所以這裏只會創建stdout和stderr的2個pipe,作用是其中一端用來作爲容器1號進程的輸入和輸出,另一端輸出到pouch創建的fifo裏, 這樣pouch就讀到了容器進程的標準輸出

看一下下面這張圖,cmd封裝了shim調用runccreate, cmd的stdio就是容器進程的stdio, 這裏的原因在第3步runc創建容器裏細講

調用runc create返回後,shim開始拷貝容器IO到pouch創建的fifo裏,代碼在這裏,https://github.com/containerd/containerd/blob/master/pkg/process/io.go#L135-L232

下面這張圖是拷貝stdout的IO流的邏輯, 拷貝stderr也類似,rio.Stdout() 是上面shim創建的pipe的另外一端

看一下shim進程打開的fd,發現stdout和stderr fifo都打開了2次,這是因爲不打開fifo讀端或多次重新打開讀端, 只寫方式打開fifo寫端, 若寫入fifo裏的數據超過緩衝區,fifo寫端報EPIPE (Broken pipe)錯誤退出,所以這裏分別用讀寫方式打開了2次fifo

runc 創建容器與初始化IO

這裏是最後一個創建容器的步驟, containerd調用實際的容器運行時創建容器,我以大家最常用的runc來做介紹

這裏插一句,案例裏的kata安全容器也是一種OCI標準的運行時,簡單來說安全容器就是有自己的內核,不和宿主機共享內核,這樣纔是安全可靠的.kata是基於qemu來做, 可以理解他有2層,第一層在宿主機上,和qemu以及qemu裏的進程交互,第二層在qemu裏,接收第一層發來的請求,實際完成的代碼就是封裝了runc的libcontainer.所以kata的stdio相比於runc多轉發了一次

同樣我先簡單概括一下runc創建容器的流程,shim創建容器需要調用2次runc,第一次是runccreate,這個命令完成後,容器的用戶進程還沒有被拉起,runc 啓動了一個init進程,這個init進程把容器啓動的所有準備都做完, 包括切換ns,cgroup隔離,掛載鏡像rootfs, volume等,runc init進程最後會向一個fifo(和pouch fifo沒有關係, runc自己用的一個fifo文件)寫0,在0被讀取出來之前runc init會一直hang着

shim的第二次調用是runcstart,runc start做的工作很簡單,從fifo中讀出數據,這時hang住的runc init會往下執行,調用execve加載用戶進程, 這時容器的用戶進程纔開始運行

在介紹runc創建容器IO之前,我們先看一下容器進程的stdio的fd指向吧,因爲沒有標準輸入,所以進程0號fd是指向/dev/null的,1號和2號fd分別指向了一個pipe,這個pipe就是第二步裏shim創建的pipe

可以打開shim進程的proc文件確認一下, 13和15號fd打開的fd號是和容器進程打開的2個pipe是一樣的, 說明2個進程打開的是同樣的pipe

上面說到runc啓動的第一個進程是runc init, 啓動進程的流程同樣也是封裝了一個cmd命令,cmd的stdio是指向process的stdio

所以當真正的容器進程啓動的時候自然也繼承了runc init的stdio

合(後記)

看起來是個網絡問題,最後發現是一個fifo的問題,但是循序漸進的分析下來,感覺一切都是合情合理的

類似排查網絡問題的套路一樣,問題排查一樣也有套路(抽象方法)可循,也看過這方面的總結,但還是寫下自己的理解,當套路被壓縮到極致之後,就變成了高大上的邏輯思維方式

  1. 詳細分析問題出現的現象,問題進程的大致工作流程,問題觸發的條件
  2. 不要憑經驗判斷哪些組件不會出問題,詳細分析組件日誌和代碼,尤其不要對任何代碼有敬畏之心(不敬畏,但是尊重所有代碼),尤其不要認爲內核,系統庫都是基本穩定的
  3. 問題鏈路上涉及的原理最好都去學習熟悉

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