High Memory Usage in Kubernetes (1)
Addressing Challenges During Large File Uploads
背景
最近接手公司四五年的舊專案踩到一個 K8S 的雷想記錄一下,先描述一下背景,我們的服務整套服務架在客戶的 K8S 內,服務內有隻 API 會進行上傳加密壓縮檔並做後續處理。
整個 API 的流程大概如下,由於該 API 是以非同步進行,所以在第一次解壓縮後的流程都是以非同步 Job 的方式進行。
Service 收到 File 利用 Golang package 進行解密解壓縮 ->
解壓縮檔裡面還會有一份壓縮檔,會利用 7Zip 進行第二次的解壓縮 ->
最後會取得一份大檔,在透過 Linux 的 Split 指令切分為小檔案 ->
依序對切分完的檔案做後續的處理
壓縮檔的大小約 3GB,進行完兩次解壓縮後的做後大小約 10 GB
在客戶環境發現 API 使用時 Pod 的 WSS 會飆到約 10 GB
測試環境與工具
由於給地端的服務測試環境沒有真正的 K8S 所以開一台 EC2 利用 Kind 建立 K8S 進行測試
檔案:壓縮檔約 700MB 解開後約 3.xGB
fluentBit:利用 node exporter 抓取 instance metrics
prometheus:蒐集 node exporter & go process metrics
pprof:分析與定位資源消耗位置
由於 pprof 只能抓到觀測當下的指標,因此比較難評估抓取的時間點,所以採用程式碼埋點的方式進行,總共埋了四個位置。
- Controller 收到 Request 的時間點
- Controller 準備進行 response 時
- 非同步 Job 未進行任何動作時
- 非同步 Job 結束時
尚未修改
go process metrics
可以觀察到服務在高峰時本身使用了約 2GB 的記憶體
pprof
pprof 觀測結果
- middleware request_log 使用到了 io.ReadAll 佔用大量記憶體
- heap2 與 heap3 結果相同
- heap4 並無觀察到有大量記憶體佔用的情況
- 根據上述兩點推斷,在非同步 Job 執行時並無明顯的記憶體資源佔用
檢查後發現先前在記錄 request body 時因為沒有過濾掉檔案上傳的部分,因此會在記憶體複製一份一模一樣的 body,導致 middleware 佔用與壓縮檔相同大小的記憶體。
修改 Middleware 後
go process metrics
高峰下降到了 1.37 GB 左右
pprof
pprof 觀測結果
- request_log 佔用大量記憶體情況改善
- heap2 與 heap3 在 ZipCryptoDecryptor 有佔用大量記憶體情況
- heap4 並無觀察到有大量記憶體佔用的情況
ZipCryptoDecryptor
由於 Golang 官方的 Zip Package 本身沒有支援解密的功能,因此使用了 https://github.com/yeka/zip 的套件進行解密解壓縮
爬了這個 source code 後發現 ZipCryptoDecryptor 會建立一個與檔案大小一樣的 slice 當作暫存,因此會消耗大量的記憶體空間
func ZipCryptoDecryptor(r *io.SectionReader, password []byte) (*io.SectionReader, error) {
z := NewZipCrypto(password)
b := make([]byte, r.Size())
r.Read(b)
m := z.Decrypt(b)
return io.NewSectionReader(bytes.NewReader(m), 12, int64(len(m))), nil
}
修改後改用 chunk 的方式進行解密,並將結果透過 io.Pipe 的方式回傳,不使用 memory 當作暫存,詳細實作方法可參考我的 github
type OffsetReader struct {
r io.Reader
offset int64
read int64
}
func NewOffsetReader(r io.Reader, offset int64) *OffsetReader {
return &OffsetReader{
r: r,
offset: offset,
}
}
func (o *OffsetReader) Read(p []byte) (n int, err error) {
if o.read < o.offset {
skip := o.offset - o.read
var discarded int64
for skip > 0 {
discarded, err = io.CopyN(io.Discard, o.r, skip)
if err != nil {
return 0, err
}
o.read += discarded
skip -= discarded
}
}
n, err = o.r.Read(p)
if err != nil {
o.read += int64(n)
}
return n, err
}
func ZipCryptoDecryptor(r *io.SectionReader, password []byte) (io.Reader, error) {
z := NewZipCrypto(password)
pr, pw := io.Pipe()
go func() {
defer pw.Close()
err := z.DecryptStream(pw, r)
if err != nil {
pw.CloseWithError(err)
}
}()
or := NewOffsetReader(pr, 12)
return or, nil
}
func (z *ZipCrypto) DecryptStream(w io.Writer, r io.Reader) error {
buffer := make([]byte, 4096) // 4KB Buffer size
for {
n, err := r.Read(buffer)
if n > 0 {
//Decrypt the chunk
decryptedChunk := z.Decrypt(buffer[:n])
_, writerErr := w.Write(decryptedChunk)
if writerErr != nil {
return writerErr
}
}
if err == io.EOF {
break
} else if err != nil {
return err
}
}
return nil
}
go process metrics
修正後記憶體使用量只有小幅度的變化
pprof 觀測結果
- ZipDcryptDecryptor 佔用大量記憶體情況改善
- heap2 ~ heap4 並無觀察到有大量記憶體佔用的情況
EC2 測試
由於上面的測試都在本機開發測試的,因此上到 EC2 進行完整修改後的測試
使用資料
- 壓縮檔約 700MB 解開後約 3 GB
Node Metrics
Golang Metrics
2. 壓縮檔約 3GB 解開後約 10 GB
Node Metrics
Golang Metrics
調整過後不論是3 GB 的資料或是 10 GB 的資料,測試過後觀察 EC2 機器本身的 RAM 使用量都維持在 1 GB 左右並無持續增長或是暴衝的情況,服務本身的記憶體使用量也無劇烈波動!!!
似乎服務本身的問題已經解決了,但實際交付給客戶後又是另一件事了…接續下一篇再繼續分享