天天看點

詳解 Flink 容器化環境下的 OOM Killed

作者:林小鉑

在生産環境中,Flink 通常會部署在 YARN 或 k8s 等資源管理系統之上,程序會以容器化(YARN 容器或 docker 等容器)的方式運作,其資源會受到資源管理系統的嚴格限制。另一方面,Flink 運作在 JVM 之上,而 JVM 與容器化環境并不是特别适配,尤其 JVM 複雜且可控性較弱的記憶體模型,容易導緻程序因使用資源超标而被 kill 掉,造成 Flink 應用的不穩定甚至不可用。

針對這個問題,Flink 在 1.10 版本對記憶體管理子產品進行了重構,設計了全新的記憶體參數。在大多數場景下 Flink 的記憶體模型和預設已經足夠好用,可以幫使用者屏蔽程序背後的複雜記憶體結構,然而一旦出現記憶體問題,問題的排查和修複都需要比較多的領域知識,通常令普通使用者望而卻步。

為此,本文将解析 JVM 和 Flink 的記憶體模型,并總結在工作中遇到和在社群交流中了解到的造成 Flink 記憶體使用超出容器限制的常見原因。由于 Flink 記憶體使用與使用者代碼、部署環境、各種依賴版本等因素都有緊密關系,本文主要讨論 on YARN 部署、Oracle JDK/OpenJDK 8、Flink 1.10+ 的情況。此外,特别感謝 @宋辛童(Flink 1.10+ 新記憶體架構的主要作者)和 @唐雲(RocksDB StateBackend 專家)在社群的答疑,令筆者受益匪淺。

JVM 記憶體分區

對于大多數 Java 使用者而言,日常開發中與 JVM Heap 打交道的頻率遠大于其他 JVM 記憶體分區,是以常把其他記憶體分區統稱為 Off-Heap 記憶體。而對于 Flink 來說,記憶體超标問題通常來自 Off-Heap 記憶體,是以對 JVM 記憶體模型有更深入的了解是十分必要的。

根據 JVM 8 Spec[1],JVM 管理的記憶體分區如下圖:

詳解 Flink 容器化環境下的 OOM Killed

img1. JVM 8 記憶體模型

除了上述 Spec 規定的标準分區,在具體實作上 JVM 常常還會加入一些額外的分區供進階功能子產品使用。以 HotSopt JVM 為例,根據 Oracle NMT[5] 的标準,我們可以将 JVM 記憶體細分為如下區域:

● Heap: 各線程共享的記憶體區域,主要存放 new 操作符建立的對象,記憶體的釋放由 GC 管理,可被使用者代碼或 JVM 本身使用。

● Class: 類的中繼資料,對應 Spec 中的 Method Area (不含 Constant Pool),Java 8 中的 Metaspace。

● Thread: 線程級别的記憶體區,對應 Spec 中的 PC Register、Stack 和 Natvive Stack 三者的總和。

● Compiler: JIT (Just-In-Time) 編譯器使用的記憶體。

● Code Cache: 用于存儲 JIT 編譯器生成的代碼的緩存。

● GC: 垃圾回收器使用的記憶體。

● Symbol: 存儲 Symbol (比如字段名、方法簽名、Interned String) 的記憶體,對應 Spec 中的 Constant Pool。

● Arena Chunk: JVM 申請作業系統記憶體的臨時緩存區。

● NMT: NMT 自己使用的記憶體。

● Internal: 其他不符合上述分類的記憶體,包括使用者代碼申請的 Native/Direct 記憶體。

● Unknown: 無法分類的記憶體。

理想情況下,我們可以嚴格控制各分區記憶體的上限,來保證程序總體記憶體在容器限額之内。但是過于嚴格的管理會帶來會有額外使用成本且缺乏靈活度,是以在實際中為了 JVM 隻對其中幾個暴露給使用者使用的分區提供了硬性的上限,而其他分區則可以作為整體被視為 JVM 本身的記憶體消耗。

具體可以用于限制分區記憶體的 JVM 參數如下表所示(值得注意的是,業界對于 JVM Native 記憶體并沒有準确的定義,本文的 Native 記憶體指的是 Off-Heap 記憶體中非 Direct 的部分,與 Native Non-Direct 可以互換)。

詳解 Flink 容器化環境下的 OOM Killed

從表中可以看到,使用 Heap、Metaspace 和 Direct 記憶體都是比較安全的,但非 Direct 的 Native 記憶體情況則比較複雜,可能是 JVM 本身的一些内部使用(比如下文會提到的 MemberNameTable),也可能是使用者代碼引入的 JNI 依賴,還有可能是使用者代碼自身通過 sun.misc.Unsafe 申請的 Native 記憶體。理論上講,使用者代碼或第三方 lib 申請的 Native 記憶體需要使用者來規劃記憶體用量,而 Internal 的其餘部分可以并入 JVM 本身的記憶體消耗。而實際上 Flink 的記憶體模型也遵循了類似的原則。

Flink TaskManager 記憶體模型

首先回顧下 Flink 1.10+ 的 TaskManager 記憶體模型。

詳解 Flink 容器化環境下的 OOM Killed

img2. Flink TaskManager 記憶體模型

顯然,Flink 架構本身不僅會包含 JVM 管理的 Heap 記憶體,也會申請自己管理 Off-Heap 的 Native 和 Direct 記憶體。在筆者看來,Flink 對于 Off-Heap 記憶體的管理政策可以分為三種:

● 硬限制(Hard Limit): 硬限制的記憶體分區是 Self-Contained 的,Flink 會保證其用量不會超過設定的門檻值(若記憶體不夠則抛出類似 OOM 的異常),

● 軟限制(Soft Limit): 軟限制意味着記憶體使用長期會在門檻值以下,但可能短暫地超過配置的門檻值。

● 預留(Reserved): 預留意味着 Flink 不會限制分區記憶體的使用,隻是在規劃記憶體時預留一部分空間,但不能保證明際使用會不會超額。

結合 JVM 的記憶體管理來看,一個 Flink 記憶體分區的記憶體溢出會導緻何種後果,判斷邏輯如下:

1、若是 Flink 有硬限制的分區,Flink 會報該分區記憶體不足。否則進入下一步。

2、若該分區屬于 JVM 管理的分區,在其實際值增長導緻 JVM 分區也記憶體耗盡時,JVM 會報其所屬的 JVM 分區的 OOM (比如 java.lang.OutOfMemoryError: Jave heap space)。否則進入下一步。

3、該分區記憶體持續溢出,最終導緻程序總體記憶體超出容器記憶體限制。在開啟嚴格資源控制的環境下,資料總管(YARN/k8s 等)會 kill 掉該程序。

為直覺地展示 Flink 各記憶體分區與 JVM 記憶體分區間的關系,筆者整理了如下的記憶體分區映射表:

詳解 Flink 容器化環境下的 OOM Killed

img3. Flink 分區及 JVM 分區記憶體限制關系

根據之前的邏輯,在所有的 Flink 記憶體分區中,隻有不是 Self-Contained 且所屬 JVM 分區也沒有記憶體硬限制參數的 JVM Overhead 是有可能導緻程序被 OOM kill 掉的。作為一個預留給各種不同用途的記憶體的大雜燴,JVM Overhead 的确容易出問題,但同時它也可以作為一個兜底的隔離緩沖區,來緩解來自其他區域的記憶體問題。

舉個例子,Flink 記憶體模型在計算 Native Non-Direct 記憶體時有一個 trick:

Although, native non-direct memory usage can be accounted for as a part of the framework off-heap memory or task off-heap memory, it will result in a higher JVM’s direct memory limit in this case.

雖然 Task/Framework 的 Off-Heap 分區中可能含有 Native Non-Direct 記憶體,而這部分記憶體嚴格來說屬于 JVM Overhead,不會被 JVM -XX:MaxDirectMemorySize 參數所限制,但 Flink 還是将它算入 MaxDirectMemorySize 中。這部分預留的 Direct 記憶體配額不會被實際使用,是以可以留給沒有上限 JVM Overhead 占用,達到為 Native Non-Direct 記憶體預留白間的效果。

OOM Killed 常見原因

與上文分析一緻,實踐中導緻 OOM Killed 的常見原因基本源于 Native 記憶體的洩漏或者過度使用。因為虛拟記憶體的 OOM Killed 通過資料總管的配置很容易避免且通常不會有太大問題,是以下文隻讨論實體記憶體的 OOM Killed。

RocksDB Native 記憶體的不确定性

衆所周知,RocksDB 通過 JNI 直接申請 Native 記憶體,并不受 Flink 的管控,是以實際上 Flink 通過設定 RocksDB 的記憶體參數間接影響其記憶體使用。然而,目前 Flink 是通過估算得出這些參數,并不是非常精确的值,其中有以下的幾個原因。

首先是部分記憶體難以準确計算的問題。RocksDB 的記憶體占用有 4 個部分[6]:

● Block Cache: OS PageCache 之上的一層緩存,緩存未壓縮的資料 Block。

● Indexes and filter blocks: 索引及布隆過濾器,用于優化讀性能。

● Memtable: 類似寫緩存。

● Blocks pinned by Iterator: 觸發 RocksDB 周遊操作(比如周遊 RocksDBMapState 的所有 key)時,Iterator 在其生命周期内會阻止其引用到的 Block 和 Memtable 被釋放,導緻額外的記憶體占用[10]。

前三個區域的記憶體都是可配置的,但 Iterator 鎖定的資源則要取決于應用業務使用模式,且沒有提供一個硬限制,是以 Flink 在計算 RocksDB StateBackend 記憶體時沒有将這部分納入考慮。

其次是 RocksDB Block Cache 的一個 bug[8][9],它會導緻 Cache 大小無法嚴格控制,有可能短時間内超出設定的記憶體容量,相當于軟限制。

對于這個問題,通常我們隻要調大 JVM Overhead 的門檻值,讓 Flink 預留更多記憶體即可,因為 RocksDB 的記憶體超額使用隻是暫時的。

glibc Thread Arena 問題

另外一個常見的問題就是 glibc 著名的 64 MB 問題,它可能會導緻 JVM 程序的記憶體使用大幅增長,最終被 YARN kill 掉。

具體來說,JVM 通過 glibc 申請記憶體,而為了提高記憶體配置設定效率和減少記憶體碎片,glibc 會維護稱為 Arena 的記憶體池,包括一個共享的 Main Arena 和線程級别的 Thread Arena。當一個線程需要申請記憶體但 Main Arena 已經被其他線程加鎖時,glibc 會配置設定一個大約 64 MB (64 位機器)的 Thread Arena 供線程使用。這些 Thread Arena 對于 JVM 是透明的,但會被算進程序的總體虛拟記憶體(VIRT)和實體記憶體(RSS)裡。

預設情況下,Arena 的最大數目是 cpu 核數 * 8,對于一台普通的 32 核伺服器來說最多占用 16 GB,不可謂不可觀。為了控制總體消耗記憶體的總量,glibc 提供了環境變量 MALLOC_ARENA_MAX 來限制 Arena 的總量,比如 Hadoop 就預設将這個值設定為 4。然而,這個參數隻是一個軟限制,所有 Arena 都被加鎖時,glibc 仍會建立 Thread Arena 來配置設定記憶體[11],造成意外的記憶體使用。

通常來說,這個問題會出現在需要頻繁建立線程的應用裡,比如 HDFS Client 會為每個正在寫入的檔案建立一個 DataStreamer 線程,是以比較容易遇到 Thread Arena 的問題。如果懷疑你的 Flink 應用遇到這個問題,比較簡單的驗證方法就是看程序的 pmap 是否存在很多大小為 64MB 倍數的連續 anon 段,比如下圖中藍色幾個的 65536 KB 的段就很有可能是 Arena。

詳解 Flink 容器化環境下的 OOM Killed

img4. pmap 64 MB arena

這個問題的修複辦法比較簡單,将 MALLOC_ARENA_MAX 設定為 1 即可,也就是禁用 Thread Arena 隻使用 Main Arena。當然,這樣的代價就是線程配置設定記憶體效率會降低。不過值得一提的是,使用 Flink 的程序環境變量參數(比如 containerized.taskmanager.env.MALLOC_ARENA_MAX=1)來覆寫預設的 MALLOC_ARENA_MAX 參數可能是不可行的,原因是在非白名單變量(yarn.nodemanager.env-whitelist)沖突的情況下, NodeManager 會以合并 URL 的方式來合并原有的值和追加的值,最終造成 MALLOC_ARENA_MAX="4:1" 這樣的結果。

最後,還有一個更徹底的可選解決方案,就是将 glibc 替換為 Google 家的 tcmalloc 或 Facebook 家的 jemalloc [12]。除了不會有 Thread Arena 問題,記憶體配置設定性能更好,碎片更少。在實際上,Flink 1.12 的官方鏡像也将預設的記憶體配置設定器從 glibc 改為 jemelloc [17]。

JDK8 Native 記憶體洩漏

Oracle Jdk8u152 之前的版本存在一個 Native 記憶體洩漏的 bug[13],會造成 JVM 的 Internal 記憶體分區一直增長。

具體而言,JVM 會緩存字元串符号(Symbol)到方法(Method)、成員變量(Field)的映射對來加快查找,每對映射稱為 MemberName,整個映射關系稱為 MemeberNameTable,由 java.lang.invoke.MethodHandles 這個類負責。在 Jdk8u152 之前,MemberNameTable 是使用 Native 記憶體的,是以一些過時的 MemberName 不會被 GC 自動清理,造成記憶體洩漏。

要确認這個問題,需要通過 NMT 來檢視 JVM 記憶體情況,比如筆者就遇到過線上一個 TaskManager 的超過 400 MB 的 MemeberNameTable。

詳解 Flink 容器化環境下的 OOM Killed

img5. JDK8 MemberNameTable Native 記憶體洩漏

在 JDK-8013267[14] 以後,MemeberNameTable 從 Native 記憶體被移到 Java Heap 當中,修複了這個問題。然而,JVM 的 Native 記憶體洩漏問題不止一個,比如 C2 編譯器的記憶體洩漏問題[15],是以對于跟筆者一樣沒有專門 JVM 團隊的使用者來說,更新到最新版本的 JDK 是修複問題的最好辦法。

YARN mmap 記憶體算法

衆所周知,YARN 會根據 /proc/${pid} 下的程序資訊來計算整個 container 程序樹的總體記憶體,但這裡面有一個比較特殊的點是 mmap 的共享記憶體。mmap 記憶體會全部被算進程序的 VIRT,這點應該沒有疑問,但關于 RSS 的計算則有不同标準。

依據 YARN 和 Linux smaps 的計算規則,記憶體頁(Pages)按兩種标準劃分:

● Private Pages: 隻有目前程序映射(mapped)的 Pages

● Shared Pages: 與其他程序共享的 Pages

● Clean Pages: 自從被映射後沒有被修改過的 Pages

● Dirty Pages: 自從被映射後已經被修改過的 Pages

在預設的實作裡,YARN 根據 /proc/${pid}/status 來計算總記憶體,所有的 Shared Pages 都會被算入程序的 RSS,即便這些 Pages 同時被多個程序映射[16],這會導緻和實際作業系統實體記憶體的偏差,有可能導緻 Flink 程序被誤殺(當然,前提是使用者代碼使用 mmap 且沒有預留足夠空間)。

為此,YARN 提供 yarn.nodemanager.container-monitor.procfs-tree.smaps-based-rss.enabled 配置選項,将其設定為 true 後,YARN 将根據更準确的 /proc/${pid}/smap 來計算記憶體占用,其中很關鍵的一個概念是 PSS。簡單來說,PSS 的不同點在于計算記憶體時會将 Shared Pages 均分給所有使用這個 Pages 的程序,比如一個程序持有 1000 個 Private Pages 和 1000 個會分享給另外一個程序的 Shared Pages,那麼該程序的總 Page 數就是 1500。

回到 YARN 的記憶體計算上,程序 RSS 等于其映射的所有 Pages RSS 的總和。在預設情況下,YARN 計算一個 Page RSS 公式為:

```

Page RSS = Private_Clean + Private_Dirty + Shared_Clean + Shared_Dirty

```

因為一個 Page 要麼是 Private,要麼是 Shared,且要麼是 Clean 要麼是 Dirty,是以其實上述公示右邊有至少三項為 0 。而在開啟 smaps 選項後,公式變為:

Page RSS = Min(Shared_Dirty, PSS) + Private_Clean + Private_Dirty

簡單來說,新公式的結果就是去除了 Shared_Clean 部分被重複計算的影響。

雖然開啟基于 smaps 計算的選項會讓計算更加準确,但會引入周遊 Pages 計算記憶體總和的開銷,不如 直接取 /proc/${pid}/status 的統計資料快,是以如果遇到 mmap 的問題,還是推薦通過提高 Flink 的 JVM Overhead 分區容量來解決。

總結

本文首先介紹 JVM 記憶體模型和 Flink TaskManager 記憶體模型,然後據此分析得出程序 OOM Killed 通常源于 Native 記憶體洩漏,最後列舉幾個常見的 Native 記憶體洩漏原因以及處理辦法,包括 RocksDB 記憶體占用的不确定性、glibc 的 64MB 問題、JDK8 MemberNameTable 洩露和 YARN 對 mmap 記憶體計算的不準确。由于筆者水準有限,不能保證全部内容均正确無誤,若讀者有不同意見,非常歡迎留言指教一起探讨。

詳解 Flink 容器化環境下的 OOM Killed