一行超長日誌引發的 “血案” - Containerd 頻繁 OOM 背後的真相

米开朗基杨發表於2024-06-19

案發現場:混沌初現

2024年6月10日,本應是平靜的一天。但從上午 9 點開始,Sealos 公有云的運維監控告警就開始不停地響。北京可用區伺服器節點突然出現大量 “not ready” 告警,緊接著,系統自動觸發 004 節點重啟,讓服務暫時恢復了正常。

就在我以為這只是個小插曲的時候,7分鐘後,廣州可用區伺服器也淪陷了!001 節點不得不重啟以求自保。事情似乎並沒有那麼簡單。

“發生什麼事了?!” 運維同學們迅速登入伺服器排查。原本穩定執行在 30%左右的記憶體使用率,在幾分鐘內飆升到 100%。“看起來像是有新應用大量佔用記憶體?”

問題排查:真相難明

“難道是底層機器的記憶體不足以支撐業務的增長?” 抱著姑且一試的態度,我們緊急升級了北京可用區伺服器的配置,將記憶體容量直接翻倍。觀察一段時間後,服務基本恢復穩定。“看來還是資源預留不足,3 倍底線仍然扛不住啊。” 大家暫時鬆了口氣。

廣州可用區的擴容也在40分鐘後完成。

正當大家鬆了一口氣,準備撤離 “戰場” 時,北京可用區又開始捲土重來:16:22,接連不斷的 not ready 告警又回來了!003 和 004 節點在資源耗盡的邊緣反覆橫跳。人工重啟維持一會兒還好,可 10 幾分鐘後,崩潰又捲土重來,記憶體曲線堅定地向右上方奔去...

更讓人無語的是,計劃趕不上變化,17:46,廣州可用區也開始出問題,兩個叢集再次陷入節點輪番崩潰的怪圈

揭示真相:記憶體炸彈,原來是你!

局勢愈發緊張,我們只能透過不斷重啟節點,維持業務的運轉。但記憶體增長的勢頭依然強勁,系統每隔 15-30分鐘就會崩潰

記憶體洩露?

面對詭異的記憶體曲線,有人提出會不會是記憶體洩露?大家決定從系統核心和容器兩個方向深挖。

首先懷疑是不是有容器在瘋狂列印日誌。統計對比發現整體叢集的 Pod 數量上升並不異常,排除了 Pod 數量暴增導致的資源問題。但在統計各節點記憶體使用時,發現所有 Pod 的實際記憶體使用總和,遠小於宿主機的記憶體佔用。

進一步確認是 Containerd 程序的記憶體佔用在飆升,最高達到了恐怖的 1G/s 速率!而與此同時,Pod 本身的資源使用一直很平穩。

定位罪魁禍首

進一步懷疑可能是 Containerd 的 Bug 導致記憶體洩露。我們決定先將整個叢集的 Containerd 升級到最新的 1.7.18 版本再繼續觀察。升級命令如下:

#!/usr/bin/env bashset -euxo pipefail
wget https://github.com/containerd/containerd/releases/download/v1.7.18/containerd-1.7.18-linux-amd64.tar.gz
tar xfz containerd-1.7.18-linux-amd64.tar.gz -C /usr
systemctl restart containerd && sleep 3
systemctl is-active containerd

結果負載降低後,Containerd 的記憶體佔用依然在緩慢上漲。也就是說,更新後的 Containerd 還是在快速洩露記憶體

大家開始把注意力放到 Containerd 身上,從多個角度展開排查:

1。透過 pprof 取樣分析 containerd 記憶體分配情況

首先修改 containerd 配置,使其輸出 debug 日誌資訊:

[debug]
  address = "/run/containerd/containerd-debug.sock"
  uid = 0
  gid = 0
  level = "debug"
  format = "json"

然後開始取樣,輸出 pprof 檔案:

ctr pprof --debug-socket /run/containerd/containerd-debug.sock heap >> containerd.pprof

檢視 pprof 檔案內容,懷疑與容器日誌有關。

於是開始透過節點儲存的容器日誌查詢是否有 pod 輸出了大量日誌。

2。觀察叢集節點互相重啟的情況,嘗試透過黑盒方式定位出問題的容器/映象

首先透過命令獲取節點的所有正在執行的 Pod,獲取其中的所有 image 資訊,減去其他正常節點的所有 image 資訊,得到只在出問題節點執行的 image 映象列表。

透過北京可用區節點和廣州可用區節點的上述操作求交集,進一步減少獨立出現的映象列表,得到如下的列表:

aibotk/wechat-assistant
aibotk/wechat-assistant:v4.6.28
calciumion/new-api:latest
cloudreve/cloudreve
cloudtogouser/pageplug-ce
csjiangyj/filesys
docker.io/semitechnologies/weaviate:1.19.6
ghcr.io/songquanpeng/one-api:latest
harbor.service.xiaoyangedu.net/zadig/confluence-crack:7.13.20
infracreate-registry.cn-zhangjiakou.cr.aliyuncs.com/apecloud/csi-attacher:v3.4.0
jupyter/scipy-notebook
prom/alertmanager:v0.25.0
quay.io/cilium/hubble-ui-backend:v0.13.0@sha256:1e7657d997c5a48253bb8dc91ecee75b63018d16ff5e5797e5af367336bc8803
quay.io/cilium/hubble-ui:v0.13.0@sha256:7d663dc16538dd6e29061abd1047013a645e6e69c115e008bee9ea9fef9a6666
quay.io/cilium/operator-generic:v1.15.5@sha256:f5d3d19754074ca052be6aac5d1ffb1de1eb5f2d947222b5f10f6d97ad4383e8
registry.cn-chengdu.aliyuncs.com/wyxz_test/wyxz:go-man-2024-05-22-15-04-30-2
registry.cn-hangzhou.aliyuncs.com/fastgpt_docker/m3e-large-api:latest
registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt:latest
registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-sandbox:v4.8.3
registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt:v4.7
registry.cn-hangzhou.aliyuncs.com/gxtatu/guet-server:latest
registry.cn-hangzhou.aliyuncs.com/pandak/ssh:latest
registry.cn-hongkong.aliyuncs.com/manyun2024/subconverter:clashv2
registry.k8s.io/coredns/coredns:v1.10.1

排除掉最常見的叢集內部元件的映象,也還是有十多個應用映象,於是我們嘗試逐個進入應用終端內排查,可是每個容器資源佔用都很正常。

排查過程中,出現問題的節點仍然因為 OOM 不得不反覆重啟 Containerd。

繼續研究 containerd 的記憶體使用和日誌處理流程,終於在 NewCRILogger 函式的 redirectLogs 中,發現端倪:

redirectLogs 會根據傳入的 maxLen,也就是 max_container_log_line_size 引數,來決定記憶體緩衝區的大小。如果 maxLen 是-1 (無限制),那麼任何超長的日誌,都會一次性載入記憶體,直到把記憶體吃光!

順藤摸瓜,發現罪魁禍首正是 Sealos 叢集預設配置裡的 max_container_log_line_size=-1。而 Containerd 官方預設值應該是 16384

為了驗證,我們設定廣州可用區的 node004 節點的 Containerd 將 maxLen 改為 16384,重啟後案情峰迴路轉,記憶體終於不再失控了!

為了確保這就是根本原因,我們編寫了一個測試程式,不斷列印超長日誌。在有問題的預設配置下,成功穩定復現了節點 Containerd 記憶體飆升的現象。而限制了長度的新配置,即使列印再多日誌也不會有問題。

究其根本,只是之前沒遇到列印特別長日誌的應用,一直 “藏而不發” 罷了。

問題復現

最終我們可以確定,在出問題的預設配置的 Containerd 環境上,如果有容器在一行列印大量日誌,必定會出現大量佔用記憶體的情形。

可以參考我們 mock 程式原始碼地址,其中的核心就一個 fmt.Printf 函式。

私有化部署的 Sealos 環境可以直接在 Launchpad 上部署我們打包好的映象,可以穩定復現。測試映象地址:docker.io/lingdie/containerd-log-debug:dev

刨根問底

Sealos 的 runtime 將 Containerd 的 max_container_log_line_size 引數設定為預設值 -1,這塊程式碼在建立時就沒有再動過,而排查 Containerd 的原始碼發現其最新版的預設值是 16384

出問題的原始碼在這裡:https://github.com/labring-actions/runtime/blob/443cc6f4625a6a505586dba5e90a71adec275639/containerd/etc/config.toml.tmpl#L28

透過深入原始碼發現,這裡的引數會影響系統申請的 buf 的實現,參考:https://github.com/containerd/containerd/blob/main/internal/cri/io/logger.go

這個 max_container_log_line_size 會作為 NewCRILoggermaxLen 引數傳遞進來:

// 這裡的 maxLen,sealos 預設值是-1, containerd 最新版預設值是 16384
func NewCRILogger(path string, w io.Writer, stream StreamType, maxLen int) (io.WriteCloser, <-chan struct{}) {
    log.L.Debugf("Start writing stream %q to log file %q", stream, path)
    prc, pwc := io.Pipe()
    stop := make(chan struct{})
    go func() {
        redirectLogs(path, prc, w, stream, maxLen)
        close(stop)
    }()
    return pwc, stop
}

這裡會啟動一個 channel 去重定向日誌,而其中最關鍵的 redirectLogs 函式里面,會根據 maxLen 決定 buf 的大小:

const (
    // defaultBufSize is the default size of the read buffer in bytes.
    defaultBufSize = 4096
)
...
func redirectLogs(path string, rc io.ReadCloser, w io.Writer, s StreamType, maxLen int) {
    var (
...
        bufSize   = defaultBufSize
...
    )
...
    // Make sure bufSize <= maxLen
    if maxLen > 0 && maxLen < bufSize {
            bufSize = maxLen
    }
    r := bufio.NewReaderSize(rc, bufSize)
...
    for {            
        newLine, isPrefix, err := readLine(r)
...
        if maxLen > 0 && length > maxLen {
            exceedLen := length - maxLen
            last := buf[len(buf)-1]
            if exceedLen > len(last) {
                    // exceedLen must <= len(last), or else the buffer
                    // should have be written in the previous iteration.
                    panic("exceed length should <= last buffer size")
            }
            buf[len(buf)-1] = last[:len(last)-exceedLen]
            writeLineBuffer(partial, buf)
            splitEntries.Inc()
            buf = [][]byte{last[len(last)-exceedLen:]}
            length = exceedLen
...
        }
    }
}

由於 Containerd 將日誌寫入檔案的方式以行為單位,因此若某程式產生一行超長日誌,且 max_container_log_line_size 不限制,那麼將造成其日誌永久佔用記憶體資源,也無法在節點上查詢這段日誌 (比如 kubectl logs 無法返回日誌)。

解決方案

明白病灶所在,治療就輕而易舉了。我們為所有叢集的 containerd 配置都加上了合理的 maxLen 限制,觀察一段時間後,問題不再出現。

同時修復了 Sealos 原始碼裡的預設配置,重新打包了映象。還對有問題的應用日誌長度做了切分限制。

相關 PR:https://github.com/labring-actions/runtime/pull/15

相關 Issue:https://github.com/labring-actions/runtime/issues/14

私有化部署修復方案

sealos exec 'sed -i "s/max_container_log_line_size = -1/max_container_log_line_size = 16384/" /etc/containerd/config.toml'
sealos exec 'systemctl restart containerd'

經驗總結

回顧這次事件,雖然過程一波三折,但慶幸我們沒有被 “溫水煮青蛙”,及時止損。感謝團隊的通力協作,不眠不休地排除故障,力保業務平穩。這次的慘痛教訓也讓我們更加深刻地認識到,在複雜的容器環境中,Kernel 的每一個細微引數都可能帶來致命影響。有幾點經驗在這裡總結一下:

  1. 預設配置一定要設定合理,不能存有僥倖。無限制的記憶體很可能就是定時炸彈;
  2. 要警惕列印超長日誌的情況,在應用側做好長度截斷。但底層程式也要有自我保護;
  3. 監控警報、資源曲線、節點指標,是異常的第一發現者。再結合運維同學的經驗直覺,能快速圈定排查方向;
  4. 原始碼面前,了無秘密。當反覆試錯無果時,要學會深入原始碼一探究竟。這需要一定的技術積累,平時多讀優秀開源專案的程式碼就很有幫助;
  5. 覆盤總結很重要,要舉一反三,從根本上解決類似問題,並形成規範和知識庫,提升整個團隊的效率。

相關文章