如何在生產環境排查 Rust 記憶體佔用過高問題

SOFAStack發表於2021-11-03


?

文|魏熙凱(螞蟻集團技術專家)

本文 6320 字 閱讀 10 分鐘

記憶體安全的 Rust,雖然基本不會出現記憶體洩漏,但如何合理分配記憶體,是每個複雜應用都要面臨的問題。往往隨著業務的不同,相同的程式碼可能會產生不同的記憶體佔用。因此,有不小的概率會出現記憶體使用過多、記憶體逐漸增長不釋放的問題。

本文我想分享一下,我們在實踐過程中遇到的關於記憶體佔用過高的問題。對於這些記憶體問題,在本文中會做出簡單的分類,以及提供我們在生產環境下進行排查定位的方法給大家參考。

本文最先發表於 RustMagazine 中文月刊

(https://rustmagazine.github.io/rust_magazine_2021/chapter_5/rust-memory-troubleshootting.html

記憶體分配器

首先在生產環境中,我們往往不會選擇預設的記憶體分配器(malloc),而是會選擇 jemalloc,可以提供更好的多核效能以及更好的避免記憶體碎片(詳細原因可以參考[1])。Rust 的生態中,對於 jemalloc 的封裝有很多優秀的庫,這裡我們就不糾結於哪一個庫更好,我們更關心如何使用 jemalloc 提供的分析能力,幫助我們診斷記憶體問題。

閱讀 jemalloc 的使用文件,可以知道其提供了基於取樣方式的記憶體 profile 能力,而且可以通過 mallctl 可以設定 prof.active 和 prof.dump 這兩個選項,來達到動態控制記憶體 profile 的開關和輸出記憶體 profile 資訊的效果。

記憶體快速增長直至 oom

這樣的情況一般是相同的程式碼在面對不同的業務場景時會出現,因為某種特定的輸入(往往是大量的資料)引起程式的記憶體快速增長。

不過有了上面提到的 memory profiling 功能,快速的記憶體增長其實一個非常容易解決的情況,為我們可以在快速增長的過程中開啟 profile 開關,一段時間後,輸出 profile 結果,通過相應的工具進行視覺化,就可以清楚地瞭解到哪些函式被呼叫,進行了哪些結構的記憶體分配。

不過這裡分為兩種情況:可以復現以及難以復現,對於兩種情況的處理手段是不一樣的,下面對於這兩種情況分別給出可操作的方案。

可以復現

可以復現的場景其實是最容易的解決的問題,因為我們可以在復現期間採用動態開啟 profile,在短時間內的獲得大量的記憶體分配資訊即可。

下面給出一個完整的 demo,展示一下在 Rust 應用中如何進行動態的記憶體 profile。

本文章,我會採用 jemalloc-sys jemallocator jemalloc-ctl 這三個 Rust 庫來進行記憶體的 profile,這三個庫的功能主要是:

jemalloc-sys: 封裝 jemalloc。

jemallocator: 實現了 Rust 的 GlobalAlloc,用來替換預設的記憶體分配器。

jemalloc-ctl: 提供了對於 mallctl 的封裝,可以用來進行 tuning、動態配置分配器的配置、以及獲取分配器的統計資訊等。

下面是 demo 工程的依賴:

[dependencies]
jemallocator = "0.3.2"
jemalloc-ctl = "0.3.2"
[dependencies.jemalloc-sys]
version = "0.3.2"
features = ["stats", "profiling", "unprefixed_malloc_on_supported_platforms"]
[profile.release]
debug = true

其中比較關鍵的是 jemalloc-sys 的幾個 features 需要開啟,否則後續的 profile 會遇到失敗的情況,另外需要強調的是 demo 的執行環境是在 Linux 環境下執行的。

然後 demo 的 src/main.rs 的程式碼如下:

use jemallocator;
use jemalloc_ctl::{AsName, Access};
use std::collections::HashMap;
#[global_allocator]
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
const PROF_ACTIVE: &'static [u8] = b"prof.active\0";
const PROF_DUMP: &'static [u8] = b"prof.dump\0";
const PROFILE_OUTPUT: &'static [u8] = b"profile.out\0";
fn set_prof_active(active: bool) {
    let name = PROF_ACTIVE.name();
    name.write(active).expect("Should succeed to set prof");
}
fn dump_profile() {
    let name = PROF_DUMP.name();
    name.write(PROFILE_OUTPUT).expect("Should succeed to dump profile")
}
fn main() {
    set_prof_active(true);
    let mut buffers: Vec<HashMap<i32, i32>> = Vec::new();
    for _ in 0..100 {
        buffers.push(HashMap::with_capacity(1024));
    }
    set_prof_active(false);
    dump_profile();
}

demo 已經是非常簡化的測試用例了,主要做如下的說明:

set_prof_activedump_profile 都是通過 jemalloc-ctl 來呼叫 jemalloc 提供的 mallctl 函式,通過給相應的 key 設定 value 即可,比如這裡就是給 prof.active 設定布林值,給 profile.dump 設定 dump 的檔案路徑。

編譯完成之後,直接執行程式是不行的,需要設定好環境變數(開啟記憶體 profile 功能):

export MALLOC_CONF=prof:true

然後再執行程式,就會輸出一份 memory profile 檔案,demo 中檔名字已經寫死 —— profile.out,這個是一份文字檔案,不利於直接觀察(沒有直觀的 symbol)。

通過 jeprof 等工具,可以直接將其轉化成視覺化的圖形:

jeprof --show_bytes --pdf <path_to_binary> ./profile.out > ./profile.pdf

這樣就可以將其視覺化,從下圖中,我們可以清晰地看到所有的記憶體來源:

img

這個 demo 的整體流程就完成了,距離應用到生產的話,只差一些 trivial 的工作,下面是我們在生產的實踐:

  • 將其封裝成 HTTP 服務,可以通過 curl 命令直接觸發,將結果通過 HTTP response 返回。
  • 支援設定 profile 時長。
  • 處理併發觸發 profile 的情況。

說到這裡,這個方案其實有一個好處一直沒有提到,就是它的動態性。因為開啟記憶體 profile 功能,勢必是會對效能產生一定的影響(雖然這裡開啟的影響並不是特別大),我們自然是希望在沒有問題的時候,避免開啟這個 profile 功能,因此這個動態開關還是非常實用的。

難以復現

事實上,可以穩定復現的問題都不是問題。生產上,最麻煩的問題是難以復現的問題,難以復現的問題就像是一個定時炸彈,復現條件很苛刻導致難以精準定位問題,但是問題又會冷不丁地出現,很是讓人頭疼。

一般對於難以復現的問題,主要的思路是提前準備好保留現場,在問題發生的時候,雖然服務出了問題,但是我們儲存了出問題的現場。比如這裡的記憶體佔用過多的問題,有一個很不錯的思路就是:在 oom 的時候,產生 coredump。

不過我們在生產的實踐並沒有採用 coredump 這個方法,主要原因是生產環境的伺服器節點記憶體往往較大,產生的 coredump 也非常大,光是產生 coredump 就需要花費不少時間,會影響立刻重啟的速度,此外分析、傳輸、儲存都不太方便。

這裡介紹一下我們在生產環境下采用的方案,實際上也是非常簡單的方法,通過 jemalloc 提供的功能,可以很簡單的進行間接性地輸出記憶體 profile 結果。

在啟動使用了 jemalloc 的、準備長期執行的程式,使用環境變數設定 jemalloc 引數:

export MALLOC_CONF=prof:true,lg_prof_interval:30

這裡的引數增加了一個 lg_prof_interval:30,其含義是記憶體每增加 1GB(2^30,可以根據需要修改,這裡只是一個例子),就輸出一份記憶體 profile。這樣隨著時間的推移,如果發生了記憶體的突然增長(超過設定的閾值),那麼相應的 profile 一定會產生,那麼我們就可以在發生問題的時候,根據檔案的建立日期,定位到出問題的時刻,記憶體究竟發生了什麼樣的分配。

記憶體緩慢增長不釋放

不同於記憶體的急速增長,記憶體整體的使用處於一個穩定的狀態,但是隨著時間的推移,記憶體又在穩定地、緩慢地增長。通過上面所說的方法,很難發現記憶體究竟在哪裡使用了。

這個問題也是我們在生產碰到的非常棘手的問題之一,相較於此前的劇烈變化,我們不再關心發生了那些分配事件,我們更關心的是當前的記憶體分佈情況,但是在沒有 GC 的 Rust 中,觀察當前程式的記憶體分佈情況,並不是一件很簡單的事情(尤其是在不影響生產執行的情況下)。

針對這個情況,我們在生產環境中的實踐是這樣的:

手動釋放部分結構(往往是快取)記憶體
然後觀察前後的記憶體變化(釋放了多少記憶體),確定各個模組的記憶體大小

而藉助 jemalloc 的統計功能,可以獲取到當前的記憶體使用量,我們完全可以重複進行 釋放制定模組的記憶體+計算釋放大小,來確定記憶體的分佈情況。

這個方案的缺陷也是很明顯的,就是參與記憶體佔用檢測的模組是先驗的(你無法發現你認知以外的記憶體佔用模組),不過這個缺陷還是可以接受的,因為一個程式中可能佔用記憶體過大的地方,我們往往都是知道的。

下面給出一個 demo 工程,可以根據這個 demo 工程,應用到生產。

下面是 demo 工程的依賴:

[dependencies]
jemallocator = "0.3.2"
jemalloc-ctl = "0.3.2"
[dependencies.jemalloc-sys]
version = "0.3.2"
features = ["stats", "profiling", "unprefixed_malloc_on_supported_platforms"]
[profile.release]
debug = true

demo 的 src/main.rs 的程式碼:

use jemallocator;
use jemalloc_ctl::{epoch, stats};
#[global_allocator]
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
fn alloc_cache() -> Vec<i8> {
    let mut v = Vec::with_capacity(1024 * 1024);
    v.push(0i8);
    v
}
fn main() {
    let cache_0 = alloc_cache();
    let cache_1 = alloc_cache();
    let e = epoch::mib().unwrap();
    let allocated_stats = stats::allocated::mib().unwrap();
    let mut heap_size = allocated_stats.read().unwrap();
    drop(cache_0);
    e.advance().unwrap();
    let new_heap_size = allocated_stats.read().unwrap();
    println!("cache_0 size:{}B", heap_size - new_heap_size);
    heap_size = new_heap_size;
    drop(cache_1);
    e.advance().unwrap();
    let new_heap_size = allocated_stats.read().unwrap();
    println!("cache_1 size:{}B", heap_size - new_heap_size);
    heap_size = new_heap_size;
    println!("current heap size:{}B", heap_size);
}

比起上一個 demo 長了一點,但是思路非常簡單,只要簡單說明一下 jemalloc-ctl 的一個使用注意點即可,在獲取新的統計資訊之前,必須先呼叫一下 epoch.advance()

下面是我的編譯後執行的輸出資訊:

cache_0 size:1048576B
cache_1 size:1038336B
current heap size:80488B

這裡可以發現,cache_1 的 size 並不是嚴格的 1MB,這個可以說是正常的,一般來說(不針對這個 demo)主要有兩個原因:

在進行記憶體統計的時候,還有其他的記憶體變化在發生。
jemalloc 提供的 stats 資料不一定是完全準確的,因為他為了更好的多核效能,不可能使用全域性的統計,因此實際上是為了效能,放棄了統計資訊的一致性。

不過這個資訊的不精確,並不會給定位記憶體佔用過高的問題帶來阻礙,因為釋放的記憶體往往是巨大的,微小的擾動並不會影響到最終的結果。

另外,其實還有更簡單的方案,就是通過釋放快取,直接觀察機器的記憶體變化,不過需要知道的是記憶體不一定是立即還給 OS 的,而且靠眼睛觀察也比較累,更好的方案還是將這樣的記憶體分佈檢查功能整合到自己的 Rust 應用之中。

其他通用方案

metrics

另外還有一個非常有效、我們一直都在使用的方案,就是在產生大量記憶體分配的時候,將分配的記憶體大小記錄成指標,供後續採集、觀察。

整體的方案如下:

  • 使用 Prometheus Client 記錄分配的記憶體(應用層統計)
  • 暴露出 metrics 介面
  • 配置 Promethues server,進行 metrics 拉取
  • 配置 Grafana,連線 Prometheus server,進行視覺化展示

記憶體排查工具

在記憶體佔用過高的排查過程中,也嘗試過其他的強大工具,比如 heaptrack、valgrind 等工具,但是這些工具有一個巨大的弊端,就是會帶來非常大的 overhead。

一般來說,使用這類工具的話,基本上應用程式是不可能在生產執行的。

也正因如此,在生產的環境下,我們很少使用這類工具排查記憶體的問題。

總結

雖然 Rust 已經幫我們避免掉了記憶體洩漏的問題,但是記憶體佔用過高的問題,我想不少在生產長期執行的程式還是會有非常大的概率出現的。本文主要分享了我們在生產環境中遇到的幾種記憶體佔用過高的問題場景,以及目前我們在不影響生產正常服務的情況下,一些常用的、快速定位問題的排查方案,希望能給大家帶來一些啟發和幫助。

當然可以肯定的是,還有其他我們沒有遇到過的記憶體問題,也還有更好的、更方便的方案去做記憶體問題的定位和排查,希望知道的同學可以一起多多交流。

參考

[1] Experimental Study of Memory Allocation forHigh-Performance Query Processing
[2] jemalloc 使用文件
[3] jemallocator

關於我們

我們是螞蟻智慧監控技術中臺的時序儲存團隊,我們正在使用 Rust 構建高效能、低成本並具備實時分析能力的新一代時序資料庫。

歡迎加入或者推薦

請聯絡:jiachun.fjc@antgroup.com

*本週推薦閱讀*

新一代日誌型系統在 SOFAJRaft 中的應用

下一個 Kubernetes 前沿:多叢集管理

基於 RAFT 的生產級高效能 Java 實現 - SOFAJRaft 系列內容合輯

終於!SOFATracer 完成了它的鏈路視覺化之旅

img

相關文章