如何在生產環境排查 Rust 內存佔用過高問題

📄

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

本文 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 構建高性能、低成本並具備實時分析能力的新一代時序數據庫。

歡迎加入或者推薦

請聯繫:[email protected]!

*本週推薦閱讀*

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

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

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

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

img

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章