一、發現問題
在一次系統上線後,我們發現某幾個節點在長時間執行後會出現記憶體持續飆升的問題,導致的結果就是Kubernetes叢集的這個節點會把所在的Pod進行驅逐OOM;如果排程到同樣問題的節點上,也會出現Pod一直起不來的問題。我們嘗試了殺死Pod後手動排程的辦法(label),當然也可以排除排程節點。但是在一段時間後還會復現,我們通過監控系統也排查了這段時間的流量情況,但應該和記憶體持續佔用沒有關聯,這時我們意識到這可能是程式的問題。
二、現象-記憶體居高不下
發現個別業務服務記憶體佔用觸發告警,通過 Grafana 檢視在沒有什麼流量的情況下,記憶體佔用量依然拉平,沒有打算下降的樣子:
並且觀測的這些服務,早年還只是 100MB。現在隨著業務迭代和上升,目前已經穩步 4GB,容器限額 Limits 紛紛給它開道,但我想總不能是無休止的增加資源吧,這是一個很大的問題。
三、Pod頻繁重啟
有的業務服務,業務量小,自然也就沒有調整容器限額,因此得不到記憶體資源,又超過額度,就會進入瘋狂的重啟怪圈:
重啟將近 200 次,告警通知已經爆炸!
四、排查
猜想一:頻繁申請重複物件
出現問題服務的業務特點,那就是基本為圖片處理類的功能,例如:圖片解壓縮、批量生成二維碼、PDF 生成等,因此就懷疑是否在量大時頻繁申請重複物件,而程式本身又沒有及時釋放記憶體,因此導致持續佔用。
記憶體池
想解決頻繁申請重複物件,可以用最常見的 sync.Pool
當多個 goroutine 都需要建立同⼀個物件的時候,如果 goroutine 數過多,導致物件的建立數⽬劇增,進⽽導致 GC 壓⼒增大。形成 “併發⼤-佔⽤記憶體⼤-GC 緩慢-處理併發能⼒降低-併發更⼤”這樣的惡性迴圈。
場景驗證
在描述中關注到幾個關鍵字,分別是併發大,Goroutine 數過多,GC 壓力增大,GC 緩慢。也就是需要滿足上述幾個硬性條件,才可以認為是符合猜想的。
通過拉取 PProf goroutine,可得知 Goroutine 數並不高:
沒有什麼流量的情況下,也不符合併發大,Goroutine 數過多的情況,若要更進一步確認,可通過 Grafana 落實其量的高低。
從結論上來講,我認為與其沒有特別直接的關係,但猜想其所對應的業務功能到導致的間接關係應當存在。
猜想二:未知的記憶體洩露
記憶體居高不下,其中一個反應就是猜測是否存在洩露,而我們的容器中目前只跑著一個程式:
顯然其提示的記憶體使用不高,也不像程式記憶體洩露的問題,因此也將其排除。
猜想三:容器環境的機制
既然不是業務程式碼影響,也不是GC影響,那是否與環境本身有關呢,我們可以得知容器 OOM 的判別標準是 container_memory_working_set_bytes(當前工作集)。
而 container_memory_working_set_bytes 是由 cadvisor 提供的,對應下述指標:
從結論上來講,Memory 換算過來是 4GB+,石錘。接下來的問題就是 Memory 是怎麼計算出來的呢,顯然和 RSS 不對標。
原因
從 cadvisor/issues/638 可得知 container_memory_working_set_bytes 指標的組成實際上是 RSS + Cache。而 Cache 高的情況,常見於程式有大量檔案 IO,佔用 Cache 可能就會比較高,猜測也與 Go 版本、Linux 核心版本的 Cache 釋放、回收方式有較大關係。
出問題的常見功能,如:
- 批量圖片解壓縮。
- 批量二維碼生成。
- 批量上傳渲染後圖片。
解決方案
在本場景中 cadvisor 所提供的判別標準 container_memory_working_set_bytes 是不可變更的,也就是無法把判別標準改為 RSS,因此我們只能考慮掌握主動權。
開發角度
使用類 sync.Pool 做多級記憶體池管理,防止申請到 “不合適”的記憶體空間,常見的例子: ioutil.ReadAll:
func (b *Buffer) ReadFrom(r io.Reader) (n int64, err error) {
…
for {
if free := cap(b.buf) - len(b.buf); free < MinRead {
newBuf := b.buf
if b.off+free < MinRead {
newBuf = makeSlice(2*cap(b.buf) + MinRead) // 擴充雙倍空間
copy(newBuf, b.buf[b.off:])
}
}
}
}
核心是做好做多級記憶體池管理,因為使用多級記憶體池,就會預先定義多個 Pool,比如大小 100,200,300的 Pool 池,當你要 150 的時候,分配200,就可以避免部分的記憶體碎片和記憶體碎塊。
但從另外一個角度來看這存在著一定的難度,因為你怎麼知道什麼時候在哪個叢集上會突然出現這型別的服務,何況開發人員的預期情況參差不齊,寫多級記憶體池寫出 BUG 也是有可能的。
讓業務服務無限重啟,也是不現實的,被動重啟,沒有控制,且告警,存在風險。
運維角度
可以使用定期重啟的常用套路。可以在部署環境可以配合指令碼做 HPA,當容器記憶體指標超過約定限制後,起一個新的容器替換,再將原先的容器給釋放掉,就可以在預期內替換且業務穩定了。
總結
根據上述排查和分析結果,原因如下:
- 應用程式行為:檔案處理型服務,導致 Cache 佔用高。
- Linux 核心版本:版本比較低(BUG?),不同 Cache 回收機制。
- 記憶體分配機制:在達到 cgroup limits 前會嘗試釋放,但可能記憶體碎片化,也可能是一次性索要太多,無法分配到足夠的連續記憶體,最終導致 cgroup oom。
從根本上來講,應用程式需要去優化其記憶體使用和分配策略,又或是將其抽離為獨立的特殊服務去處理。並不能以目前這樣簡單未經多級記憶體池控制的方式去使用,否則會導致記憶體使用量越來越大。
而從服務提供的角度來講,我們並不知道這類服務會在什麼地方出現又何時會成長起來,因此我們需要主動去控制容器的 OOM,讓其實現優雅退出,保證業務穩定和可控。
最後
最近在寫基於Golang的工具和框架,還請多多Star.
YoyoGo 是一個用 Go 編寫的簡單,輕便,快速的 微服務框架,目前已實現了Web框架的能力,但是底層設計已支援多種服務架構。
Github
https://github.com/yoyofx/yoyogo
https://github.com/yoyofxteam