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

rife發表於2023-04-24

引言

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

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

JVM 記憶體模型簡介

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

JVM 記憶體模型

非 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 Out Of Memory

我們也來看看 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 記憶體管理機制的目標是確保工作負載被排程到資源充足的節點上,並將它們保持在一定的限制範圍內。

Kubernetes Cluster 示例

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

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

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

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

Pod 驅逐場景

關於驅逐的內部運作,肯定還有很多東西需要了解。有關此複雜過程的更多資訊,請點選此處。對於這個故事,我們就此打住,現在看看這兩種機制 —— 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 記憶體的 requestslimits,以及 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 因 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 記憶體限制的場景。

要點 —— OOMKilled 在 pod 的狀態中查詢。

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

最後一種不太常見的故障情況是 pod 驅逐。在這種情況下 — 記憶體requestlimit是不同的。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,例如etcdcoredns)。

對於這種情況,我們調整了 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 不斷重啟。下圖描述了這種情況。

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

場景 4 — 引數配置良好,應用程式執行良好

最後一個場景顯示應用程式在正確調整的引數下正常執行。為此,我們將pod 的requestlimit 都設定為 500MB,將 -XX:MaxRAMPercentage 設定為 80%。

讓我們收集一些統計資料,以瞭解節點級別和更深層次的 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. 設定記憶體的 requestlimit 一樣,這樣你就可以避免由於節點資源不足而導致 pod 被驅逐(缺點就是會導致節點資源利用率降低)。
  2. 僅在出現 Java OutOfMemory 錯誤時增加 pod 的記憶體限制。如果發生 OOMKilled 崩潰,請將更多記憶體留給非堆使用。
  3. 將最大和初始堆大小設定為相同的值。這樣,您將在堆分配增加的情況下防止效能損失,並且如果堆百分比/非堆記憶體/pod 限制錯誤,您將“快速失敗”。有關此建議的更多資訊,請點選此處

Kubernetes 資源管理和 JVM 記憶體區域的主題很深,本文只是淺嘗輒止。以下是另外一些參考資料:


文字翻譯自: https://danoncoding.com/tricky-kubernetes-memory-management-f...


相關文章