一、發現問題
在一次系統上線后,我們發現某幾個節點在長時間運行后會出現內存持續飆升的問題,導致的結果就是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