【JVM故障問題排查心得】「記憶體診斷系列」JVM記憶體與Kubernetes中pod的記憶體、容器的記憶體不一致所引發的OOMKilled問題總結(上)

洛神灬殤 發表於 2022-11-29
JVM Kubernetes

背景介紹

在我們日常的工作當中,通常應用都會採用Kubernetes進行容器化部署,但是總是會出現一些問題,例如,JVM堆小於Docker容器中設定的記憶體大小和Kubernetes的記憶體大小,但是還是會被OOMKilled。在此我們介紹一下K8s的OOMKilled的Exit Code編碼。

Exit Code 137

  • 表明容器收到了 SIGKILL 訊號,程式被殺掉,對應kill -9,引發SIGKILL的是docker kill。這可以由使用者或由docker守護程式來發起,手動執行:docker kill
  • 137比較常見,如果 pod 中的limit 資源設定較小,會執行記憶體不足導致 OOMKilled,此時state 中的 ”OOMKilled” 值為true,你可以在系統的dmesg -T 中看到OOM日誌。

為什麼我設定的大小關係沒有錯,還會OOMKilled?

因為我的heap大小肯定是小於Docker容器以及Pod的大小的,為啥還是會出現OOMKilled?

原因分析

這種問題常發生在JDK8u131或者JDK9版本之後所出現在容器中執行JVM的問題:在大多數情況下,JVM將一般預設會採用宿主機Node節點的記憶體為Native VM空間(其中包含了堆空間、直接記憶體空間以及棧空間),而並非是是容器的空間為標準。

例如在我的機器

docker run -m 100MB openjdk:8u121 java -XshowSettings:vm -version
VM settings:
    Max. Heap Size (Estimated): 444.50M
    Ergonomics Machine Class: server
    Using VM: OpenJDK 64-Bit Server VM

以上的資訊出現了矛盾,我們在執行的時候將容器記憶體設定為100MB,而-XshowSettings:vm列印出的JVM將最大堆大小為444M,如果按照這個記憶體進行分配記憶體的話很可能會導致節點主機在某個時候殺死我的JVM。

如何解決此問題

JVM 感知 cgroup 限制

一種方法解決 JVM 記憶體超限的問題,這種方法可以讓JVM自動感知 docker 容器的 cgroup 限制,從而動態的調整堆記憶體大小。JDK8u131在JDK9中有一個很好的特性,即JVM能夠檢測在Docker容器中執行時有多少記憶體可用。為了使jvm保留根據容器規範的記憶體,必須設定標誌-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap。

注意:如果將這兩個標誌與Xms和Xmx標誌一起設定,那麼jvm的行為將是什麼?-Xmx標誌將覆蓋-XX:+ UseCGroupMemoryLimitForHeap標誌。

總結一下
  • 標誌-XX:+ UseCGroupMemoryLimitForHeap使JVM可以檢測容器中的最大堆大小。

  • -Xmx標誌將最大堆大小設定為固定大小。

  • 除了JVM的堆空間,還會對於非堆和jvm的東西,還會有一些額外的記憶體使用情況。

使用JDK9的容器感知機制嘗試

$ docker run -m 100MB openjdk:8u131 java \
  -XX:+UnlockExperimentalVMOptions \
  -XX:+UseCGroupMemoryLimitForHeap \
  -XshowSettings:vm -version
VM settings:
    Max. Heap Size (Estimated): 44.50M
    Ergonomics Machine Class: server
    Using VM: OpenJDK 64-Bit Server VM

可以看出來透過記憶體感知之後,JVM能夠檢測到容器只有100MB,並將最大堆設定為44M。我們調整一下記憶體大小看看是否可以實現動態化調整和感知記憶體分配,如下所示。

docker run -m 1GB openjdk:8u131 java \
  -XX:+UnlockExperimentalVMOptions \
  -XX:+UseCGroupMemoryLimitForHeap \
  -XshowSettings:vm -version
VM settings:
    Max. Heap Size (Estimated): 228.00M
    Ergonomics Machine Class: server
    Using VM: OpenJDK 64-Bit Server VM

我們設定了容器有1GB記憶體分配,而JVM使用228M作為最大堆。因為容器中除了JVM之外沒有其他程式在執行,所以我們還可以進一步擴大一下對於Heap堆的分配?

$ docker run -m 1GB openjdk:8u131 java \
  -XX:+UnlockExperimentalVMOptions \
  -XX:+UseCGroupMemoryLimitForHeap \
  -XX:MaxRAMFraction=1 -XshowSettings:vm -version
VM settings:
    Max. Heap Size (Estimated): 910.50M
    Ergonomics Machine Class: server
    Using VM: OpenJDK 64-Bit Server VM

在較低的版本的時候可以使用-XX:MaxRAMFraction引數,它告訴JVM使用可用記憶體/MaxRAMFract作為最大堆。使用-XX:MaxRAMFraction=1,我們將幾乎所有可用記憶體用作最大堆。從上面的結果可以看出來記憶體分配已經可以達到了910.50M。

問題分析
  1. 最大堆佔用總記憶體是否仍然會導致你的程式因為記憶體的其他部分(如“元空間”)而被殺死?
  • 答案:MaxRAMFraction=1仍將為其他非堆記憶體留出一些空間。

但如果容器使用堆外記憶體,這可能會有風險,因為幾乎所有的容器記憶體都分配給了堆。您必須將-XX:MaxRAMFraction=2設定為堆只使用50%的容器記憶體,或者使用Xmx

容器內部感知CGroup資源限制

Docker1.7開始將容器cgroup資訊掛載到容器中,所以應用可以從 /sys/fs/cgroup/memory/memory.limit_in_bytes 等檔案獲取記憶體、 CPU等設定,在容器的應用啟動命令中根據Cgroup配置正確的資源設定 -Xmx, -XX:ParallelGCThreads等引數

在Java10中,改進了容器整合。
  • Java10+廢除了-XX:MaxRAM引數,因為JVM將正確檢測該值。在Java10中,改進了容器整合。無需新增額外的標誌,JVM將使用1/4的容器記憶體用於堆。

  • java10+確實正確地識別了記憶體的docker限制,但您可以使用新的標誌MaxRAMPercentage(例如:-XX:MaxRAMPercentage=75)而不是舊的MaxRAMFraction,以便更精確地調整堆的大小,而不是其餘的(堆疊、本機…)

  • java10+上的UseContainerSupport選項,而且是預設啟用的,不用設定。同時 UseCGroupMemoryLimitForHeap 這個就棄用了,不建議繼續使用,同時還可以透過 -XX:InitialRAMPercentage、-XX:MaxRAMPercentage、-XX:MinRAMPercentage 這些引數更加細膩的控制 JVM 使用的記憶體比率。

Java 程式在執行時會呼叫外部程式、申請 Native Memory 等,所以即使是在容器中執行 Java 程式,也得預留一些記憶體給系統的。所以 -XX:MaxRAMPercentage 不能配置得太大。當然仍然可以使用-XX:MaxRAMFraction=1選項來壓縮容器中的所有記憶體。

參考資料