天天看點

Java 應用程式在 Kubernetes 上棘手的記憶體管理

作者:網際網路進階架構師

如何結合使用 JVM Heap 堆和 Kubernetes 記憶體的 requests 和 limits 并遠離麻煩。

在容器環境中運作 Java 應用程式需要了解兩者 —— JVM 記憶體機制和 Kubernetes 記憶體管理。這兩個環境一起工作會産生一個穩定的應用程式,但是,錯誤配置最多可能導緻基礎設施超支,最壞情況下可能會導緻應用程式不穩定或崩潰。我們将首先仔細研究 JVM 記憶體的工作原理,然後我們将轉向 Kubernetes,最後,我們将把這兩個概念放在一起。

JVM 記憶體模型簡介

JVM 記憶體管理是一種高度複雜的機制,多年來通過連續釋出不斷改進,是 JVM 平台的優勢之一。對于本文,我們将隻介紹對本主題有用的基礎知識。在較高的層次上,JVM 記憶體由兩個空間組成 —— Heap 和 Metaspace。

Java 應用程式在 Kubernetes 上棘手的記憶體管理

非 Heap 記憶體

JVM 使用許多記憶體區域。最值得注意的是 Metaspace。Metaspace 有幾個功能。它主要用作方法區,其中存儲應用程式的類結構和方法定義,包括标準庫。記憶體池和常量池用于不可變對象,例如字元串,以及類常量。堆棧區域是用于線程執行的後進先出結構,存儲原語和對傳遞給函數的對象的引用。根據 JVM 實作和版本,此空間用途的一些細節可能會有所不同。

我喜歡将 Metaspace 空間視為一個管理區域。這個空間的大小可以從幾 MB 到幾百 MB 不等,具體取決于代碼庫及其依賴項的大小,并且在應用程式的整個生命周期中幾乎保持不變。預設情況下,此空間未綁定并會根據應用程式需要進行擴充。

Metaspace 是在 Java 8 中引入的,取代了 Permanent Generation,後者存在垃圾回收問題。

其他一些值得一提的非堆記憶體區域是代碼緩存、線程、垃圾回收。更多關于非堆記憶體參考這裡。

Heap 堆記憶體

如果 Metaspace 是管理空間,那麼 Heap 就是操作空間。這裡存放着所有的執行個體對象,并且垃圾回收機制在這裡最為活躍。該記憶體的大小因應用程式而異,取決于工作負載的大小 —— 應用程式需要滿足單個請求和流量特征所需的記憶體。大型應用程式通常具有以GB為機關的堆大小。

我們将使用一個示例應用程式用于探索記憶體機制。源代碼在此處。

這個示範應用程式模拟了一個真實世界的場景,在該場景中,為傳入請求提供服務的系統會在堆上累積對象,并在請求完成後成為垃圾回收的候選對象。該程式的核心是一個無限循環,通過将大型對象添加到清單并定期清除清單來建立堆上的大型對象。

val list = mutableListOf<ByteArray>()

generateSequence(0) { it + 1 }.forEach {
    if (it % (HEAP_TO_FILL / INCREMENTS_IN_MB) == 0) list.clear()
    list.add(ByteArray(INCREMENTS_IN_MB * BYTES_TO_MB))
}           

以下是應用程式的輸出。在預設間隔(本例中為350MB堆大小)内,狀态會被清除。重要的是要了解,清除狀态并不會清空堆 - 這是垃圾收集器内部實作的決定何時将對象從記憶體中驅逐出去。讓我們使用幾個堆設定來運作此應用程式,以檢視它們對JVM行為的影響。

首先,我們将使用 4 GB 的最大堆大小(由 -Xmx 标志控制)。

~ java -jar -Xmx4G app/build/libs/app.jar

INFO           Used          Free            Total
INFO       14.00 MB      36.00 MB       50.00 MB
INFO       66.00 MB      16.00 MB       82.00 MB
INFO      118.00 MB     436.00 MB      554.00 MB
INFO      171.00 MB     383.00 MB      554.00 MB
INFO      223.00 MB     331.00 MB      554.00 MB
INFO      274.00 MB     280.00 MB      554.00 MB
INFO      326.00 MB     228.00 MB      554.00 MB
INFO  State cleared at ~ 350 MB.
INFO           Used          Free            Total
INFO      378.00 MB     176.00 MB      554.00 MB
INFO      430.00 MB     208.00 MB      638.00 MB
INFO      482.00 MB     156.00 MB      638.00 MB
INFO      534.00 MB     104.00 MB      638.00 MB
INFO      586.00 MB      52.00 MB      638.00 MB
INFO      638.00 MB      16.00 MB      654.00 MB
INFO      690.00 MB      16.00 MB      706.00 MB
INFO  State cleared at ~ 350 MB.
INFO           Used          Free            Total
INFO      742.00 MB      16.00 MB      758.00 MB
INFO      794.00 MB      16.00 MB      810.00 MB
INFO      846.00 MB      16.00 MB      862.00 MB
INFO      899.00 MB      15.00 MB      914.00 MB
INFO      951.00 MB      15.00 MB      966.00 MB
INFO     1003.00 MB      15.00 MB     1018.00 MB
INFO     1055.00 MB      15.00 MB     1070.00 MB
...
...           

有趣的是,盡管狀态已被清除并準備好進行垃圾回收,但可以看到使用的記憶體(第一列)仍在增長。為什麼會這樣呢?由于堆有足夠的空間可以擴充,JVM 延遲了通常需要大量 CPU 資源的垃圾回收,并優化為服務主線程。讓我們看看不同堆大小如何影響此行為。

~ java -jar -Xmx380M app/build/libs/app.jar

INFO           Used          Free            Total
INFO       19.00 MB     357.00 MB      376.00 MB
INFO       70.00 MB     306.00 MB      376.00 MB
INFO      121.00 MB     255.00 MB      376.00 MB
INFO      172.00 MB     204.00 MB      376.00 MB
INFO      208.00 MB     168.00 MB      376.00 MB
INFO      259.00 MB     117.00 MB      376.00 MB
INFO      310.00 MB      66.00 MB      376.00 MB
INFO  State cleared at ~ 350 MB.
INFO           Used          Free            Total
INFO       55.00 MB     321.00 MB      376.00 MB
INFO      106.00 MB     270.00 MB      376.00 MB
INFO      157.00 MB     219.00 MB      376.00 MB
INFO      208.00 MB     168.00 MB      376.00 MB
INFO      259.00 MB     117.00 MB      376.00 MB
INFO      310.00 MB      66.00 MB      376.00 MB
INFO      361.00 MB      15.00 MB      376.00 MB
INFO  State cleared at ~ 350 MB.
INFO           Used          Free            Total
INFO       55.00 MB     321.00 MB      376.00 MB
INFO      106.00 MB     270.00 MB      376.00 MB
INFO      157.00 MB     219.00 MB      376.00 MB
INFO      208.00 MB     168.00 MB      376.00 MB
INFO      259.00 MB     117.00 MB      376.00 MB
INFO      310.00 MB      66.00 MB      376.00 MB
INFO      361.00 MB      15.00 MB      376.00 MB
INFO  State cleared at ~ 350 MB.
INFO           Used          Free            Total
INFO       55.00 MB     321.00 MB      376.00 MB
INFO      106.00 MB     270.00 MB      376.00 MB
INFO      157.00 MB     219.00 MB      376.00 MB
INFO      208.00 MB     168.00 MB      376.00 MB
...
...           

在這種情況下,我們配置設定了剛好足夠的堆大小(380 MB)來處理請求。我們可以看到,在這些限制條件下,GC立即啟動以避免可怕的記憶體不足錯誤。這是 JVM 的承諾 - 它将始終在由于記憶體不足而失敗之前嘗試進行垃圾回收。為了完整起見,讓我們看一下它的實際效果:

~ java -jar -Xmx150M app/build/libs/app.jar

INFO           Used          Free            Total
INFO       19.00 MB     133.00 MB      152.00 MB
INFO       70.00 MB      82.00 MB      152.00 MB
INFO      106.00 MB      46.00 MB      152.00 MB
Exception in thread "main"
...
...
Caused by: java.lang.OutOfMemoryError: Java heap space
 at com.dansiwiec.HeapDestroyerKt.blowHeap(HeapDestroyer.kt:28)
 at com.dansiwiec.HeapDestroyerKt.main(HeapDestroyer.kt:18)
 ... 8 more           

對于 150 MB 的最大堆大小,程序無法處理 350MB 的工作負載,并且在堆被填滿時失敗,但在垃圾收集器嘗試挽救這種情況之前不會失敗。

Java 應用程式在 Kubernetes 上棘手的記憶體管理

我們也來看看 Metaspace 的大小。為此,我們将使用 jstat(為簡潔起見省略了輸出)

~ jstat -gc 35118

MU
4731.0           

輸出表明 Metaspace 使用率約為 5 MB。記住 Metaspace 負責存儲類定義,作為實驗,讓我們将流行的 Spring Boot 架構添加到我們的應用程式中。

~ jstat -gc 34643

MU
28198.6           

Metaspace 躍升至近 30 MB,因為類加載器占用的空間要大得多。對于較大的應用程式,此空間占用超過 100 MB 的情況并不罕見。接下來讓我們進入 Kubernetes 領域。

Kubernetes 記憶體管理

Kubernetes 記憶體控制在作業系統級别運作,與管理配置設定給它的記憶體的 JVM 形成對比。K8s 記憶體管理機制的目标是確定工作負載被排程到資源充足的節點上,并将它們保持在一定的限制範圍内。

Java 應用程式在 Kubernetes 上棘手的記憶體管理

在定義工作負載時,使用者有兩個參數可以操作 — requests 和 limits。這些是在容器級别定義的,但是,為了簡單起見,我們将根據 pod 參數來考慮它,這些參數隻是容器設定的總和。

當請求 pod 時,kube-scheduler(控制平面的一個元件)檢視資源請求并選擇一個具有足夠資源的節點來容納 pod。一旦排程,允許 pod 超過其記憶體requests(隻要節點有空閑記憶體)但禁止超過其limits。

Kubelet(節點上的容器運作時)監視 pod 的記憶體使用率,如果超過記憶體限制,它将重新啟動 pod 或在節點資源不足時将其完全從節點中逐出(有關更多詳細資訊,請參閱有關此主題的官方文檔。這會導緻臭名昭著的 OOMKilled(記憶體不足)的 pod 狀态。

當 pod 保持在其限制範圍内,但超出了節點的可用記憶體時,會出現一個有趣的場景。這是可能的,因為排程程式會檢視 pod 的請求(而不是限制)以将其排程到節點上。在這種情況下,kubelet 會執行一個稱為節點壓力驅逐的過程。簡而言之,這意味着 pod 正在終止,以便回收節點上的資源。根據節點上的資源狀況有多糟糕,驅逐可能是軟的(允許 pod 優雅地終止)或硬的。此場景如下圖所示。

Java 應用程式在 Kubernetes 上棘手的記憶體管理

關于驅逐的内部運作,肯定還有很多東西需要了解。有關此複雜過程的更多資訊,請點選此處。對于這個故事,我們就此打住,現在看看這兩種機制 —— JVM 記憶體管理和 Kubernetes 是如何協同工作的。

JVM 和 Kubernetes

Java 10 引入了一個新的 JVM 标志 —— -XX:+UseContainerSupport(預設設定為 true),如果 JVM 在資源有限的容器環境中運作,它允許 JVM 檢測可用記憶體和 CPU。該标志與 -XX:MaxRAMPercentage 一起使用,讓我們根據總可用記憶體的百分比設定最大堆大小。在 Kubernetes 的情況下,容器上的 limits 設定被用作此計算的基礎。例如 —— 如果 pod 具有 2GB 的限制,并且将 MaxRAMPercentage 标志設定為 75%,則結果将是 1500MB 的最大堆大小。

這需要一些技巧,因為正如我們之前看到的,Java 應用程式的總體記憶體占用量高于堆(還有 Metaspace 、線程、垃圾回收、APM 代理等)。這意味着,需要在最大堆空間、非堆記憶體使用量和 pod 限制之間取得平衡。具體來說,前兩個的總和不能超過最後一個,因為它會導緻 OOMKilled(參見上一節)。

為了觀察這兩種機制的作用,我們将使用相同的示例項目,但這次我們将把它部署在(本地)Kubernetes 叢集上。為了在 Kubernetes 上部署應用程式,我們将其打包為一個 Pod:

apiVersion: v1
kind: Pod
metadata:
  name: heapkiller
spec:
  containers:
    - name: heapkiller
      image: heapkiller
      imagePullPolicy: Never
      resources:
        requests:
          memory: "500Mi"
          cpu: "500m"
        limits:
          memory: "500Mi"
          cpu: "500m"
      env:
        - name: JAVA_TOOL_OPTIONS
          value: '-XX:MaxRAMPercentage=70.0'           

快速複習第一部分 —— 我們确定應用程式需要至少 380MB的堆記憶體才能正常運作。

場景 1 — Java Out Of Memory 錯誤

讓我們首先了解我們可以操作的參數。它們是 — pod 記憶體的 requests 和 limits,以及 Java 的最大堆大小,在我們的例子中由 MaxRAMPercentage 标志控制。

在第一種情況下,我們将總記憶體的 70% 配置設定給堆。pod 請求和限制都設定為 500MB,這導緻最大堆為 350MB(500MB 的 70%)。

我們執行 kubectl apply -f pod.yaml 部署 pod ,然後用 kubectl get logs -f pod/heapkiller 觀察日志。應用程式啟動後不久,我們會看到以下輸出:

INFO  Started HeapDestroyerKt in 5.599 seconds (JVM running for 6.912)
INFO           Used          Free            Total
INFO       17.00 MB       5.00 MB       22.00 MB
...
INFO      260.00 MB      78.00 MB      338.00 MB
...
Exception in thread "main" java.lang.reflect.InvocationTargetException
Caused by: java.lang.OutOfMemoryError: Java heap space           

如果我們執行 kubectl describe pod/heapkiller 拉出 pod 詳細資訊,我們将找到以下資訊:

Containers:
  heapkiller:
    ....
    State:          Waiting
      Reason:       CrashLoopBackOff
    Last State:     Terminated
      Reason:       Error
      Exit Code:    1
...
Events:
  Type     Reason     Age                From               Message
  ----     ------     ----               ----               -------
...
  Warning  BackOff    7s (x7 over 89s)   kubelet            Back-off restarting failed container           

簡而言之,這意味着 pod 以狀态碼 1 退出(Java Out Of Memory 的退出碼),Kubernetes 将繼續使用标準退避政策重新啟動它(以指數方式增加重新啟動之間的暫停時間)。下圖描述了這種情況。

Java 應用程式在 Kubernetes 上棘手的記憶體管理

這種情況下的關鍵要點是 —— 如果 Java 因 OutOfMemory 錯誤而失敗,您将在 pod 日志中看到它。

場景 2 — Pod 超出記憶體 limit 限制

為了實作這個場景,我們的 Java 應用程式需要更多記憶體。我們将 MaxRAMPercentage 從 70% 增加到 90%,看看會發生什麼。我們按照與之前相同的步驟并檢視日志。該應用程式運作良好了一段時間:

...
...
INFO      323.00 MB      83.00 MB      406.00 MB
INFO      333.00 MB      73.00 MB      406.00 MB           

然後 …… 噗。沒有更多的日志。我們運作與之前相同的 describe 指令以擷取有關 pod 狀态的詳細資訊。

Containers:
  heapkiller:
    State:          Waiting
      Reason:       CrashLoopBackOff
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
Events:
  Type     Reason     Age                  From              Message
 ----     ------     ----                 ----               ------
...
...
 Warning  BackOff    6s (x7 over 107s)    kubelet            Back-off restarting failed container           

乍看之下,這與之前的場景類似 —— pod crash,現在處于 CrashLoopBackOff(Kubernetes 一直在重新開機),但實際上卻大不相同。之前,pod 中的程序退出(JVM 因記憶體不足錯誤而崩潰),在這種情況下,是 Kubernetes 殺死了 pod。該 OOMKill 狀态表示 Kubernetes 已停止 pod,因為它已超出其配置設定的記憶體限制。這怎麼可能?

通過将 90% 的可用記憶體配置設定給堆,我們假設其他所有内容都适合剩餘的 10% (50MB),而對于我們的應用程式,情況并非如此,這導緻記憶體占用超過 500MB 限制。下圖展示了超出 pod 記憶體限制的場景。

Java 應用程式在 Kubernetes 上棘手的記憶體管理

要點 —— OOMKilled 在 pod 的狀态中查找。

場景 3 — Pod 超出節點的可用記憶體

最後一種不太常見的故障情況是 pod 驅逐。在這種情況下 — 記憶體request和limit是不同的。Kubernetes 根據request參數而不是limit參數在節點上排程 pod。如果一個節點滿足請求,kube-scheduler将選擇它,而不管節點滿足限制的能力如何。在我們将 pod 排程到節點上之前,讓我們先看一下該節點的一些詳細資訊:

~ kubectl describe node/docker-desktop

Allocatable:
  cpu:                4
  memory:             1933496Ki
Allocated resources:
  (Total limits may be over 100 percent, i.e., overcommitted.)
  Resource           Requests     Limits
  --------           --------     ------
  cpu                850m (21%)   0 (0%)
  memory             240Mi (12%)  340Mi (18%)           

我們可以看到該節點有大約 2GB 的可配置設定記憶體,并且已經占用了大約 240MB(由kube-system pod,例如etcd和coredns)。

對于這種情況,我們調整了 pod 的參數 —— request: 500Mi(未更改),limit: 2500Mi 我們重新配置應用程式以将堆填充到 2500MB(之前為 350MB)。當 pod 被排程到節點上時,我們可以在節點描述中看到這種配置設定:

Allocated resources:
  (Total limits may be over 100 percent, i.e., overcommitted.)
  Resource           Requests     Limits
  --------           --------     ------
  cpu                1350m (33%)  500m (12%)
  memory             740Mi (39%)  2840Mi (150%)           

當 pod 到達節點的可用記憶體時,它會被殺死,我們會在 pod 的描述中看到以下詳細資訊:

~ kubectl describe pod/heapkiller

Status:           Failed
Reason:           Evicted
Message:          The node was low on resource: memory.
Containers:
  heapkiller:
    State:          Terminated
      Reason:       ContainerStatusUnknown
      Message:      The container could not be located when the pod was terminated
      Exit Code:    137
      Reason:       OOMKilled           

這表明由于節點記憶體不足,pod 被逐出。我們可以在節點描述中看到更多細節:

~ kubectl describe node/docker-desktop

Events:
  Type     Reason                   Age                 From     Message
  ----     ------                   ----                ----     -------
  Warning  SystemOOM                1s                  kubelet  System OOM encountered, victim process: java, pid: 67144           

此時,CrashBackoffLoop 開始,pod 不斷重新開機。下圖描述了這種情況。

Java 應用程式在 Kubernetes 上棘手的記憶體管理

關鍵要點 —— 在 pod 的狀态中查找 Evicted 以及通知節點記憶體不足的事件。

場景 4 — 參數配置良好,應用程式運作良好

最後一個場景顯示應用程式在正确調整的參數下正常運作。為此,我們将pod 的request和 limit 都設定為 500MB,将 -XX:MaxRAMPercentage 設定為 80%。

Java 應用程式在 Kubernetes 上棘手的記憶體管理

讓我們收集一些統計資料,以了解節點級别和更深層次的 Pod 中正在發生的情況。

~ kubectl describe node/docker-desktop

Allocated resources:
  (Total limits may be over 100 percent, i.e., overcommitted.)
  Resource           Requests     Limits
  --------           --------     ------
  cpu                1350m (33%)  500m (12%)
  memory             740Mi (39%)  840Mi (44%)           

節點看起來很健康,有空閑資源。讓我們看看 pod 的内部。

# Run from within the container
~ cat /sys/fs/cgroup/memory.current

523747328           

這顯示了容器的目前記憶體使用情況。那是 499MB,就在邊緣。讓我們看看是什麼占用了這段記憶體:

# Run from within the container
~ ps -o pid,rss,command ax

  PID   RSS   COMMAND
    1 501652  java -XX:NativeMemoryTracking=summary -jar /app.jar
   36   472   /bin/sh
   55  1348   ps -o pid,rss,command ax           

RSS,*Resident Set Size,*是對正在占用的記憶體程序的一個很好的估計。上面顯示 490MB(501652 bytes)被 Java 程序占用。讓我們再剝離一層,看看 JVM 的記憶體配置設定。我們傳遞給 Java 程序的标志 -XX:NativeMemoryTracking 允許我們收集有關 Java 記憶體空間的詳細運作時統計資訊。

~ jcmd 1 VM.native_memory summary

Total: reserved=1824336KB, committed=480300KB
-                 Java Heap (reserved=409600KB, committed=409600KB)
                            (mmap: reserved=409600KB, committed=409600KB)

-                     Class (reserved=1049289KB, committed=4297KB)
                            (classes #6760)
                            (  instance classes #6258, array classes #502)
                            (malloc=713KB #15321)
                            (mmap: reserved=1048576KB, committed=3584KB)
                            (  Metadata:   )
                            (    reserved=32768KB, committed=24896KB)
                            (    used=24681KB)
                            (    waste=215KB =0.86%)
                            (  Class space:)
                            (    reserved=1048576KB, committed=3584KB)
                            (    used=3457KB)
                            (    waste=127KB =3.55%)

-                    Thread (reserved=59475KB, committed=2571KB)
                            (thread #29)
                            (stack: reserved=59392KB, committed=2488KB)
                            (malloc=51KB #178)
                            (arena=32KB #56)

-                      Code (reserved=248531KB, committed=14327KB)
                            (malloc=800KB #4785)
                            (mmap: reserved=247688KB, committed=13484KB)
                            (arena=43KB #45)

-                        GC (reserved=1365KB, committed=1365KB)
                            (malloc=25KB #83)
                            (mmap: reserved=1340KB, committed=1340KB)

-                  Compiler (reserved=204KB, committed=204KB)
                            (malloc=39KB #316)
                            (arena=165KB #5)

-                  Internal (reserved=283KB, committed=283KB)
                            (malloc=247KB #5209)
                            (mmap: reserved=36KB, committed=36KB)

-                     Other (reserved=26KB, committed=26KB)
                            (malloc=26KB #3)

-                    Symbol (reserved=6918KB, committed=6918KB)
                            (malloc=6206KB #163986)
                            (arena=712KB #1)

-    Native Memory Tracking (reserved=3018KB, committed=3018KB)
                            (malloc=6KB #92)
                            (tracking overhead=3012KB)

-        Shared class space (reserved=12288KB, committed=12224KB)
                            (mmap: reserved=12288KB, committed=12224KB)

-               Arena Chunk (reserved=176KB, committed=176KB)
                            (malloc=176KB)

-                   Logging (reserved=5KB, committed=5KB)
                            (malloc=5KB #219)

-                 Arguments (reserved=1KB, committed=1KB)
                            (malloc=1KB #53)

-                    Module (reserved=229KB, committed=229KB)
                            (malloc=229KB #1710)

-                 Safepoint (reserved=8KB, committed=8KB)
                            (mmap: reserved=8KB, committed=8KB)

-           Synchronization (reserved=48KB, committed=48KB)
                            (malloc=48KB #574)

-            Serviceability (reserved=1KB, committed=1KB)
                            (malloc=1KB #14)

-                 Metaspace (reserved=32870KB, committed=24998KB)
                            (malloc=102KB #52)
                            (mmap: reserved=32768KB, committed=24896KB)

-      String Deduplication (reserved=1KB, committed=1KB)
                            (malloc=1KB #8)           

這可能是不言而喻的 —— 這個場景僅用于說明目的。在現實生活中的應用程式中,我不建議使用如此少的資源進行操作。您所感到舒适的程度将取決于您可觀察性實踐的成熟程度(換句話說——您多快注意到有問題),工作負載的重要性以及其他因素,例如故障轉移。

結語

感謝您堅持閱讀這篇長文章!我想提供一些建議,幫助您遠離麻煩:

  1. 設定記憶體的 request 和 limit 一樣,這樣你就可以避免由于節點資源不足而導緻 pod 被驅逐(缺點就是會導緻節點資源使用率降低)。
  2. 僅在出現 Java OutOfMemory 錯誤時增加 pod 的記憶體限制。如果發生 OOMKilled 崩潰,請将更多記憶體留給非堆使用。
  3. 将最大和初始堆大小設定為相同的值。這樣,您将在堆配置設定增加的情況下防止性能損失,并且如果堆百分比/非堆記憶體/pod 限制錯誤,您将“快速失敗”

作者:淩虛

連結:https://juejin.cn/post/7225141192606335031

來源:稀土掘金