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

簡介: 本文將解析 JVM 和 Flink 的內存模型,並總結在工做中遇到和在社區交流中瞭解到的形成 Flink 內存使用超出容器限制的常見緣由。因爲 Flink 內存使用與用戶代碼、部署環境、各類依賴版本等因素都有緊密關係,本文主要討論 on YARN 部署、Oracle JDK/OpenJDK 八、Flink 1.10+ 的狀況。

在生產環境中,Flink 一般會部署在 YARN 或 k8s 等資源管理系統之上,進程會以容器化(YARN 容器或 docker 等容器)的方式運行,其資源會受到資源管理系統的嚴格限制。另外一方面,Flink 運行在 JVM 之上,而 JVM 與容器化環境並非特別適配,尤爲 JVM 複雜且可控性較弱的內存模型,容易致使進程因使用資源超標而被 kill 掉,形成 Flink 應用的不穩定甚至不可用。java

針對這個問題,Flink 在 1.10 版本對內存管理模塊進行了重構,設計了全新的內存參數。在大多數場景下 Flink 的內存模型和默認已經足夠好用,能夠幫用戶屏蔽進程背後的複雜內存結構,然而一旦出現內存問題,問題的排查和修復都須要比較多的領域知識,一般令普通用戶望而卻步。node

爲此,本文將解析 JVM 和 Flink 的內存模型,並總結在工做中遇到和在社區交流中瞭解到的形成 Flink 內存使用超出容器限制的常見緣由。因爲 Flink 內存使用與用戶代碼、部署環境、各類依賴版本等因素都有緊密關係,本文主要討論 on YARN 部署、Oracle JDK/OpenJDK 八、Flink 1.10+ 的狀況。此外,特別感謝 @宋辛童(Flink 1.10+ 新內存架構的主要做者)和 @唐雲(RocksDB StateBackend 專家)在社區的答疑,令筆者受益不淺。算法

JVM 內存分區

對於大多數 Java 用戶而言,平常開發中與 JVM Heap 打交道的頻率遠大於其餘 JVM 內存分區,所以常把其餘內存分區統稱爲 Off-Heap 內存。而對於 Flink 來講,內存超標問題一般來自 Off-Heap 內存,所以對 JVM 內存模型有更深刻的理解是十分必要的。docker

根據 JVM 8 Spec[1],JVM 管理的內存分區以下圖:緩存

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 自己的內存消耗。app

具體能夠用於限制分區內存的 JVM 參數以下表所示(值得注意的是,業界對於 JVM Native 內存並無準確的定義,本文的 Native 內存指的是 Off-Heap 內存中非 Direct 的部分,與 Native Non-Direct 能夠互換)。框架

從表中能夠看到,使用 Heap、Metaspace 和 Direct 內存都是比較安全的,但非 Direct 的 Native 內存狀況則比較複雜,多是 JVM 自己的一些內部使用(好比下文會提到的 MemberNameTable),也多是用戶代碼引入的 JNI 依賴,還有多是用戶代碼自身經過 sun.misc.Unsafe 申請的 Native 內存。理論上講,用戶代碼或第三方 lib 申請的 Native 內存須要用戶來規劃內存用量,而 Internal 的其他部分能夠併入 JVM 自己的內存消耗。而實際上 Flink 的內存模型也遵循了相似的原則。

Flink TaskManager 內存模型

首先回顧下 Flink 1.10+ 的 TaskManager 內存模型。

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 內存分區的內存溢出會致使何種後果,判斷邏輯以下:

一、如果 Flink 有硬限制的分區,Flink 會報該分區內存不足。不然進入下一步。
二、若該分區屬於 JVM 管理的分區,在其實際值增加致使 JVM 分區也內存耗盡時,JVM 會報其所屬的 JVM 分區的 OOM (好比 java.lang.OutOfMemoryError: Jave heap space)。不然進入下一步。
三、該分區內存持續溢出,最終致使進程整體內存超出容器內存限制。在開啓嚴格資源控制的環境下,資源管理器(YARN/k8s 等)會 kill 掉該進程。

爲直觀地展現 Flink 各內存分區與 JVM 內存分區間的關係,筆者整理了以下的內存分區映射表:

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 的一個 bug8,它會致使 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。

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。

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 內存計算的不許確。因爲筆者水平有限,不能保證所有內容均正確無誤,若讀者有不一樣意見,很是歡迎留言指教一塊兒探討。

做者:林小鉑
原文連接本文爲阿里雲原創內容,未經容許不得轉載

相關文章
相關標籤/搜索