在容器中使用 Java 的資源分配準則

  • 2019 年 11 月 25 日
  • 筆記

短短几年,容器就改變了軟體行業的開發模式。也許,很多開發者已經開始在容器中運行 Java 應用。但是,對於容器化的 Java 應用程式,當遇到 CPU 和記憶體佔用等問題時,還是有很多問題需要注意。本文假設讀者對 Java 和容器技術有基本了解,如果需要更多背景知識,可以閱讀文末的參考文獻。

堆空間

如果說在容器中運行 Java 應用有一條核心定律,那麼就是:對於在容器中運行的 Java 進程,不要手工設置 JVM 堆記憶體。相反的,設置容器的限制。

為什麼?

首先,設置容器的限制可以實現容器 /cgroup 提供的基本功能,既隔離容器內進程的資源使用。當我們通過 JVM 參數手工設置堆記憶體的時候,就意味著徹底無視這個功能。這樣能夠方便的調整容器資源分配,為自動化擴縮容容器(例如 K8s 垂直 pod 自動擴縮容)打開了大門,而無需手工調整 JVM 參數。

如果容器運行在編排引擎環境中(例如 Kubernetes),那麼容器的限制對於節點健康度和調度都非常重要。調度器需要使用這些限制來找到適合容器運行的節點,同時確保節點之間負載均衡。如果通過 JVM 參數設置記憶體使用,這個資訊無法通知到調度器,因此調度器無法知道如何為容器分配負載。

如果不設置容器限制,同時運行在容器中的 Java 進程也沒有顯式設置 JVM 記憶體參數,那麼 JVM 將會自動設置最大堆記憶體為運行節點總記憶體的 25%。例如,如果容器運行在一個記憶體為 64GB 的節點上,JVM 進程堆記憶體最大可設置成 16GB。如果這個節點上運行了 10 個容器(對於自動擴縮容經常發生),那麼可能會突然需要 160G 記憶體。

我們能做什麼?

設置容器記憶體(和 CPU)限制,依賴資源請求(軟限制)是不夠的。資源請求對調度器非常有用,但是設置硬限制讓 Docker(或者其他容器運行時環境)為容器分配指定資源,同時確保不會超出。這也讓 Java(在 Java 8u191 之後,默認提供「容器感知」功能)基於容器設置的資源限制自動分配記憶體,而不是通過運行節點分配。

關於 [Min|Max|Initial]RAMPercentage 參數

最近的 Java 版本中,引入了如下 JVM 參數(同時向後移植到了 Java 8u191):

  • -XX:MinRAMPercentage
  • -XX:MaxRAMPercentage
  • -XX:InitialRAMPercentage

本文不會詳細介紹這些參數如何工作,但是關鍵點是這些參數可以在不需要直接設置堆記憶體大小的情況下用於調優 JVM 堆大小。也就是說,容器仍然可以依賴對其設置的資源限制。

那麼,這些參數的值該怎麼設置呢?答案是:看情況,尤其是依賴於容器上設置的資源限制。

默認設置下,JVM 堆記憶體會設置成容器記憶體的 25%。我們可以通過這些參數來修改初始、最小、最大堆記憶體。例如,設置 -XX:MaxRAMPercentage=50 將會允許 JVM 將容器記憶體的 50% 作為堆記憶體使用,而不是默認的 25%。這樣設置是否安全主要取決於容器運行的記憶體以及容器內的進程情況。

例如,假設容器只運行一個 Java 進程,分配了 4GB 記憶體,而我們設置了 -XX:MaxRAMPercentage=50,此時 JVM 堆記憶體上限是 2GB。這與默認情況下只能使用 1GB 記憶體不同。在這種情況下,50% 基本上是非常安全的,也許也是最佳的,因為還有許多可用記憶體實際利用率都不高。相反,假設相同的容器只分配了 512MB 記憶體,現在設置了 -XX:MaxRAMPercentage=50 之後,堆記憶體會佔用 256MB 記憶體,而對於容器剩下的所有可用記憶體就只有 256MB 了。這些記憶體需要被容器中運行的其他進程共享,同時還有 JVM 的 Metaspace/PermGen 等其他記憶體使用。因此在這種場景下,50% 可能不太安全。

這裡提供如下建議:

  • 除非想為 Java 進程壓榨額外記憶體,否則不要修改這些參數。在大部分情況下默認值 25% 對於記憶體管理來說是比較安全的。這個設置對記憶體來說可能並不是最有效的,但是記憶體是相對廉價的,同時相比於 JVM 進程在未知情況下被 OOM-kill,還是謹慎一些比較好。
  • 如果非要調試這些參數,還是保守點為妙。50% 通常是個安全值,可以避免(大部分)問題。當然,這還是主要取決於容器記憶體大小。我不推薦設置成 75%,除非容器至少有 512MB 記憶體(最好是 1GB),同時需要對應用程式的實際記憶體使用非常了解。
  • 如果容器內除了 Java 進程之外還有其他進程,那麼在調整這些值的時候需要額外的注意。容器記憶體由其中所有進程共享,因此在這種情況下,了解整個容器記憶體使用會更加複雜。
  • 設置成超過 90% 可能是在自找麻煩。

對於 Metaspace/PermGen/ 其他記憶體呢?

這已經超出了本文的範圍,不過這些也可以調整,通常情況下最好不要。大多數情況下,JVM 默認行為已經很好了。如果你發現自己正試圖解決一個晦澀的記憶體問題,那麼可能需要研究一下 JVM 記憶體這個深奧的領域。其他情況,我儘可能避免直接去修改。

對於 CPU

對於 CPU 沒有什麼可做的。從 Java 8u191 開始,JVM 默認情況下已經實現「感知容器」,能夠正確解析 CPU 共享(CPU Share)設置。這裡有一些細節需要理解,因此我直接附上一篇不錯的文章,詳細介紹相關知識,就不在本文中概述。

總結

現代的 Java 已經為容器環境做好了準備,但是為了應用程式能夠有更好的性能,其中有一些不是那麼明顯的細節需要我們了解。我希望本文提供的資訊,加上優秀的參考文獻,可以幫助讀者達到這個目的。

附錄:

在 64GB/16GB JVM 例子中,這裡並不是說 JVM 進程會為堆記憶體自動消費 16GB 記憶體,只是說在記憶體溢出之前,堆記憶體可以增長到那麼大。另外,由於設置的最大堆記憶體還有很多,對於垃圾回收器來說沒有壓力,堆記憶體很容易在觸發垃圾回收之前,消耗多餘容器實際可以提供的記憶體。這必然會引起應用程式問題(例如 OOM 錯誤),甚至更嚴重的錯誤(例如被 OOM kill,崩潰)。