天天看點

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

作者:shengdong

排查完全陌生的問題,完全不熟悉的系統元件,是售後工程師的一大工作樂趣,當然也是挑戰。今天借這篇文章,跟大家分析一例這樣的問題。排查過程中,需要了解一些自己完全陌生的元件,比如systemd和dbus。但是排查問題的思路和方法基本上還是可以複用了,希望對大家有所幫助。

問題一直在發生

I'm NotReady

阿裡雲有自己的Kubernetes容器叢集産品。随着Kubernetes叢集出貨量的劇增,線上使用者零星的發現,叢集會非常低機率地出現節點NotReady情況。據我們觀察,這個問題差不多每個月,就會有一兩個客戶遇到。在節點NotReady之後,叢集Master沒有辦法對這個節點做任何控制,比如下發新的Pod,再比如抓取節點上正在運作Pod的實時資訊。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

需要知道的Kubernetes知識

這裡我稍微補充一點Kubernetes叢集的基本知識。Kubernetes叢集的“硬體基礎”,是以單機形态存在的叢集節點。這些節點可以是實體機,也可以是虛拟機。叢集節點分為Master和Worker節點。Master節點主要用來負載叢集管控元件,比如排程器和控制器。而Worker節點主要用來跑業務。Kubelet是跑在各個節點上的代理,它負責與管控元件溝通,并按照管控元件的訓示,直接管理Worker節點。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

當叢集節點進入NotReady狀态的時候,我們需要做的第一件事情,肯定是檢查運作在節點上的kubelet是否正常。在這個問題出現的時候,使用systemctl指令檢視kubelet狀态,發現它作為systemd管理的一個daemon,是運作正常的。當我們用journalctl檢視kubelet日志的時候,發現下邊的錯誤。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

什麼是PLEG

這個報錯很清楚的告訴我們,容器runtime是不工作的,且PLEG是不健康的。這裡容器runtime指的就是docker daemon。Kubelet通過直接操作docker daemon來控制容器的生命周期。而這裡的PLEG,指的是pod lifecycle event generator。PLEG是kubelet用來檢查容器runtime的健康檢查機制。這件事情本來可以由kubelet使用polling的方式來做。但是polling有其成本上的缺陷,是以PLEG應用而生。PLEG嘗試以一種“中斷”的形式,來實作對容器runtime的健康檢查,雖然實際上,它同時用了polling和”中斷”兩種機制。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

基本上看到上邊的報錯,我們可以确認,容器runtime出了問題。在有問題的節點上,通過docker指令嘗試運作新的容器,指令會沒有響應。這說明上邊的報錯是準确的.

容器runtime

Docker Daemon調用棧分析

Docker作為阿裡雲Kubernetes叢集使用的容器runtime,在1.11之後,被拆分成了多個元件以适應OCI标準。拆分之後,其包括docker daemon,containerd,containerd-shim以及runC。元件containerd負責叢集節點上容器的生命周期管理,并向上為docker daemon提供gRPC接口。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

在這個問題中,既然PLEG認為容器運作是出了問題,我們需要先從docker daemon程序看起。我們可以使用kill -USR1 指令發送USR1信号給docker daemon,而docker daemon收到信号之後,會把其所有線程調用棧輸出到檔案/var/run/docker檔案夾裡。

Docker daemon程序的調用棧相對是比較容易分析的。稍微留意,我們會發現大多數的調用棧都類似下圖中的樣子。通過觀察棧上每個函數的名字,以及函數所在的檔案(子產品)名稱,我們可以看到,這個調用棧下半部分,是程序接到http請求,做請求路由的過程;而上半部分則進入實際的處理函數。最終處理函數進入等待狀态,等待的是一個mutex執行個體。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

到這裡,我們需要稍微看一下ContainerInspectCurrent這個函數的實作,而最重要的是,我們能搞明白,這個函數的第一個參數,就是mutex的指針。使用這個指針搜尋整個調用棧檔案,我們會找出,所有等在這個mutex上邊的線程。同時,我們可以看到下邊這個線程。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

這個線程上,函數ContainerExecStart也是在處理具體請求的時候,收到了這個mutex這個參數。但不同的是,ContainerExecStart并沒有在等待mutex,而是已經拿到了mutex的所有權,并把執行邏輯轉向了containerd調用。關于這一點,我們可以使用代碼來驗證。前邊我們提到過,containerd向上通過gRPC對docker daemon提供接口。此調用棧上半部分内容,正是docker daemon在通過gRPC請求來呼叫containerd。

Containerd調用棧分析

與輸出docker daemon的調用棧類似,我們可以通過kill -SIGUSR1 指令來輸出containerd的調用棧。不同的是,這次調用棧會直接輸出到messages日志。

Containerd作為一個gRPC的伺服器,它會在接到docker daemon的遠端請求之後,建立一個線程去處理這次請求。關于gRPC的細節,我們這裡其實不用關注太多。在這次請求的用戶端調用棧上,可以看到這次調用的核心函數是Start一個程序。我們在containerd的調用棧裡搜尋Start,Process以及process.go等字段,很容易發現下邊這個線程。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

這個線程的核心任務,就是依靠runC去建立容器程序。而在容器啟動之後,runC程序會退出。是以下一步,我們自然而然會想到,runC是不是有順利完成自己的任務。檢視程序清單,我們會發現,系統中有個别runC程序,還在執行,這不是預期内的行為。容器的啟動,跟程序的啟動,耗時應該是差不對的,系統裡有正在運作的runC程序,則說明runC不能正常啟動容器。

什麼是Dbus

RunC請求Dbus

容器runtime的runC指令,是libcontainer的一個簡單的封裝。這個工具可以用來管理單個容器,比如容器建立,或者容器删除。在上節的最後,我們發現runC不能完成建立容器的任務。我們可以把對應的程序殺掉,然後在指令行用同樣的指令嘗試啟動容器,同時用strace追蹤整個過程。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

分析發現,runC停在了向帶有org.free字段的dbus寫資料的地方。那什麼是dbus呢?在Linux上,dbus是一種程序間進行消息通信的機制。

原因并不在Dbus

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

我們可以使用busctl指令列出系統現有的所有bus。如下圖,在問題發生的時候,我看到客戶叢集節點Name的編号非常大。是以我傾向于認為,dbus某些相關的資料結構,比如Name,耗盡了引起了這個問題。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

Dbus機制的實作,依賴于一個元件叫做dbus-daemon。如果真的是dbus相關資料結構耗盡,那麼重新開機這個daemon,應該是可以解決這個問題。但不幸的是,問題并沒有這麼直接。重新開機dbus-daemon之後,問題依然存在。

在上邊用strace追蹤runC的截圖中,我提到了,runC卡在向帶有org.free字段的bus寫資料的地方。在busctl輸出的bus清單裡,顯然帶有這個字段的bus,都在被systemd使用。這時,我們用systemctl daemon-reexec來重新開機systemd,問題消失了。是以基本上我們可以判斷一個方向:問題可能跟systemd有關系。

Systemd是硬骨頭

Systemd是相當複雜的一個元件,尤其對沒有做過相關開發工作的同學來說,比如我自己。基本上,排查systemd的問題,我用到了四個方法,(調試級别)日志,core dump,代碼分析,以及live debugging。其中第一個,第三個和第四個結合起來使用,讓我在經過幾天的鏖戰之後,找到了問題的原因。但是這裡我們先從“沒用”的core dump說起。

沒用的Core Dump

因為重新開機systemd解決了問題,而這個問題本身,是runC在使用dbus和systemd通信的時候沒有了響應,是以我們需要驗證的第一件事情,就是systemd不是有關鍵線程被鎖住了。檢視core dump裡所有線程,隻有以下一個線程,此線程并沒有被鎖住,它在等待dbus事件,以便做出響應。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

零散的資訊

因為無計可施,是以隻能做各種測試、嘗試。使用busctl tree指令,可以輸出所有bus上對外暴露的接口。從輸出結果看來,org.freedesktop.systemd1這個bus是不能響應接口查詢請求的。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

使用下邊的指令,觀察org.freedesktop.systemd1上接受到的是以請求,可以看到,在正常系統裡,有大量Unit建立删除的消息,但是有問題的系統裡,這個bus上完全沒有任何消息。

gdbus monitor --system --dest org.freedesktop.systemd1 --object-path /org/freedesktop/systemd1
           
K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

分析問題發生前後的系統日志,runC在重複的跑一個libcontainer_%d_systemd_test_default.slice測試,這個測試非常頻繁,但是當問題發生的時候,這個測試就停止了。是以直覺告訴我,這個問題,可能和這個測試,有很大的關系。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

另外,我使用systemd-analyze指令,打開了systemd的調試日志,發現systemd有Operation not supported的報錯。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

根據以上零散的知識,隻能做出一個大概的結論:org.freedesktop.systemd1這個bus在經過大量Unit建立删除之後,沒有了響應。而這些頻繁的Unit建立删除測試,是runC某一個checkin改寫了UseSystemd這個函數,而這個函數被用來測試,systemd的某些功能是否可用。UseSystemd這個函數在很多地方被調用,比如建立容器,或者檢視容器性能等操作。

代碼分析

這個問題線上上所有Kubernetes叢集中,發生的頻率大概是一個月兩例。問題一直在發生,且隻能在問題發生之後,通過重新開機systemd來處理,這風險極大。

我們分别給systemd和runC社群送出了bug,但是一個很現實的問題是,他們并沒有像阿裡雲這樣的線上環境,他們重制這個問題的機率幾乎是零,是以這個問題沒有辦法指望社群來解決。硬骨頭還得我們自己啃。

在上一節最後,我們看到了,問題出現的時候,systemd會輸出一些Operation not supported報錯。這個報錯看起來和問題本身風馬牛不相及,但是直覺告訴我,這,或許是離問題最近的一個地方,是以我決定,先搞清楚這個報錯因何而來。

Systemd代碼量比較大,而報這個錯誤的地方也比較多。通過大量的代碼分析(這裡略去一千字),我發現有幾處比較可疑地方,有了這些可疑的地方,接下來需要做的事情,就是等待。在等待了三周以後,終于有線上叢集,再次重制了這個問題。

Live Debugging

在征求客戶同意之後,下載下傳systemd調試符号,挂載gdb到systemd上,在可疑的函數下斷點,continue繼續執行。經過多次驗證,發現systemd最終踩到了sd_bus_message_seal這個函數裡的EOPNOTSUPP報錯。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

這個報錯背後的道理是,systemd使用了一個變量cookie,來追蹤自己處理的所有dbus message。每次在在加封一個新的消息的時候,systemd都會先把cookie這個值加一,然後再把這個cookie值複制給這個新的message。

我們使用gdb列印出dbus->cookie這個值,可以很清楚看到,這個值超過了0xffffffff。是以看起來,這個問題是systemd在加封過大量message之後,cookie這個值32位溢出,新的消息不能被加封導緻的。

K8S從懵圈到熟練:讀懂這一篇,叢集節點不下線問題一直在發生什麼是Dbus

另外,在一個正常的系統上,使用gdb把bus->cookie這個值改到接近0xffffffff,然後觀察到,問題在cookie溢出的時候立刻出現,則證明了我們的結論。

怎麼判斷叢集節點NotReady是這個問題導緻的

首先我們需要在有問題的節點上安裝gdb和systemd debuginfo,然後用指令gdb /usr/lib/systemd/systemd 1把gdb attach到systemd,在函數sd_bus_send設定斷點,然後繼續執行。等systemd踩到斷點之後,用p /x bus->cookie檢視對應的cookie值,如果此值超過了0xffffffff,那麼cookie就溢出了,則必然導緻節點NotReady的問題。确認完之後,可以使用quit來detach調試器。

問題修複

這個問題的修複,并沒有那麼直截了當。原因之一,是systemd使用了同一個cookie變量,來相容dbus1和dbus2。對于dbus1來說,cookie是32位的,這個值在經過systemd三五個月頻繁建立删除Unit之後,是肯定會溢出的;而dbus2的cookie是64位的,可能到了時間的盡頭,它也不會溢出。

另外一個原因是,我們并不能簡單的讓cookie折返,來解決溢出問題。因為這有可能導緻systemd使用同一個cookie來加封不同的消息,這樣的結果将是災難性的。

最終的修複方法是,使用32位cookie來同樣處理dbus1和dbus2兩種情形。同時在cookie達到0xfffffff的之後下一個cookie傳回0x80000000,用最高位來标記cookie已經處于溢出狀态。檢查到cookie處于這種狀态時,我們需要檢查是否下一個cookie正在被其他message使用,來避免cookie沖突。

後記

這個問題根本原因肯定在systemd,但是runC的函數UseSystemd使用不那麼美麗的方法,去測試systemd的功能,而這個函數在整個容器生命周期管理過程中,被頻繁的觸發,讓這個低機率問題的發生成為了可能。systemd的修複已經被紅帽接受,預期不久的将來,我們可以通過更新systemd,從根本上解決這個問題。

https://github.com/lnykryn/systemd-rhel/pull/322

繼續閱讀