天天看點

如何在生産環境排查 Rust 記憶體占用過高問題"

如何在生産環境排查 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_active

dump_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

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

如何在生産環境排查 Rust 記憶體占用過高問題"

這個 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 完成了它的鍊路可視化之旅
如何在生産環境排查 Rust 記憶體占用過高問題"