Kubernetes master 節(jié)點快炸了!!!
線上 master 的 apiserver 組件內(nèi)存報警,內(nèi)存使用量持續(xù)增長,監(jiān)控如下:

排查過程
從監(jiān)控上看和另外一個程序(管理員平臺)的內(nèi)存使用情況吻合,使用率降下來是因為重啟了 apiserver 和管理員平臺,且問題只出現(xiàn)在最近兩天的晚上,管理員平臺中有一段邏輯是定時全量拉取集群數(shù)據(jù)(設(shè)計不合理,后續(xù)需要改),管理員平臺的日志里顯示拉取數(shù)據(jù)超時,基本猜測和管理員平臺調(diào)用 k8s api不 合理有關(guān),且 k8s apiserver 應(yīng)該也有 bug,導(dǎo)致內(nèi)存泄露或者 goroutine 泄露。但是最近代碼都沒動過,為啥之前沒事呢,后負責(zé)管理員平臺的同事說近兩天美東專線有問題,延遲是之前的3倍,而且出現(xiàn)問題的時間正好匹配,那接下來就查一下具體原因。

apiserver 錯誤日志里有大量的上述日志,可以看到是 apiserver 因為響應(yīng)超時觸發(fā)的,里面也有詳細的函數(shù)調(diào)用堆棧信息,也有ip的信息,正好對應(yīng)了 master 和管理員平臺的地址,通過 pprof 也可以看到此時的 goroutine 使用量一直在增加,已45000+,確認是產(chǎn)生了 goroutine 泄露。下圖為 pprof tree 看到的部分內(nèi)容,里面顯示了占用量最多的地方

同時在瀏覽器中訪問 http://ip:port/debug/pprof/goroutine 可以看到具體 goroutine 數(shù)量和執(zhí)行函數(shù)的行號,此處忘記截圖了,不過和上面的信息吻合,且更信息因為攜帶了行號的信息,可以看到是如下代碼出的問題(代碼版本1.12.4)
// k8s.io/kubernetes/vendor/k8s.io/apiserver/pkg/server/filters/timeout.go
func (t *timeoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r, after, postTimeoutFn, err := t.timeout(r)
if after == nil {
t.handler.ServeHTTP(w, r)
return
}
errCh := make(chan interface{})
tw := newTimeoutWriter(w)
go func() {
defer func() {
err := recover()
if err != nil {
const size = 64 << 10
buf := make([]byte, size)
buf = buf[:runtime.Stack(buf, false)]
err = fmt.Sprintf("%v\n%s", err, buf)
}
errCh <- err
}()
t.handler.ServeHTTP(tw, r)
}()
select {
case err := <-errCh:
if err != nil {
panic(err)
}
return
case <-after:
postTimeoutFn()
tw.timeout(err)
}
}
泄露的 goroutine 就是第11行處的,簡單解釋一下上面的邏輯:生成一個 timeout 的 handler,起一個新的 goroutine 進行后續(xù) handler 的處理,當前 goroutine 中使用 select 進行等待,分為兩種 case,分別對應(yīng)新 goroutine 中 panic 的情況和整個函數(shù)超時的情況,分別看兩個 case 的內(nèi)容:
第25行:從 errCh 讀取數(shù)據(jù),其中 errCh 中的數(shù)據(jù)是在新的 goroutine 中產(chǎn)生的,對應(yīng)到實際情況就是22行的代碼出發(fā)生了 panic,在13行捕獲到了,最后在20行把 err 寫入到 errCh 中,但是這里需要注意一下這個 errCh 是個無緩存的。
第30行:after 是調(diào)用 time.After 后產(chǎn)生的一個 chan,在超時后可以從這個 chan 中獲取到數(shù)據(jù),然后在32行處會調(diào)用 tw.timeout 函數(shù),里面會觸發(fā) panic。
那為什么 goroutine 泄露了呢?
問題就出現(xiàn)在了剛才提到的無緩沖的 errCh 上,因為觸發(fā)了 timeout,代碼邏輯沒有執(zhí)行到25行,直接去了30行,然后整個函數(shù) panic,導(dǎo)致20行執(zhí)行的時候卡住了,從而阻止了11行出的新的 goroutine 的退出,每有一個 timeout 的請求,這里就會泄露一個 goroutine,從而導(dǎo)致內(nèi)存隨之泄露,cpu 的話其實不受什么影響,因為泄露的 goroutine 已經(jīng)執(zhí)行過 gopark,不是 runnable 狀態(tài)的。
解決方案
印象中記得之前看 k8s 版本升級的 release-note 時有提到過修復(fù) apiserver leak 字樣的信息,然后就去官方項目中查,結(jié)果沒找到,然后直接去看了對應(yīng)文件的最新版本代碼,看 history,終于找到了相關(guān)的修復(fù)的 commit,合入1.17。
case <-after:
defer func() {
// resultCh needs to have a reader, since the function doing
// the work needs to send to it. This is defer'd to ensure it runs
// ever if the post timeout work itself panics.
go func() {
res := <-resultCh
if res != nil {
switch t := res.(type) {
case error:
utilruntime.HandleError(t)
default:
utilruntime.HandleError(fmt.Errorf("%v", res))
}
}
}()
}()
postTimeoutFn()
tw.timeout(err)
可以看到其思想就是在外層 panic 后,新加一個 defer func 用來從之前的 errCh(新版改名為 resultCh)接收數(shù)據(jù),從而避免之前的問題。
總結(jié)
通過如上修改,確實可以解決 goroutine 泄露的問題,但是也存在一個隱患:第6行新加的 goroutine 會從 resultCh 讀數(shù)據(jù),因為在上一段代碼處有個處理,無論是否 panic,都會往 errCh(resultCh)寫入 err,從而可以避免同時泄露兩個 goroutine 的情況,但是如果短時間內(nèi)大量請求到來且處理時間都比較慢直至超時,雖然 goroutine 不會泄露,但是會產(chǎn)生兩倍于之前的 goroutine ,可能會在短時間內(nèi)造成內(nèi)存暴漲,也算是一個穩(wěn)定性風(fēng)險,需要合理設(shè)置限流來降低風(fēng)險。
K8S 進階訓(xùn)練營
點擊屏末 | 閱讀原文 | 即刻學(xué)習(xí)
