歡迎光臨
每天分享高質量文章

Java和Docker限制的那些事兒

Java和Docker不是天然的朋友。 Docker可以設定記憶體和CPU限制,而Java不能自動檢測到。使用Java的Xmx標識(繁瑣/重覆)或新的實驗性JVM標識,我們可以解決這個問題。

虛擬化中的不匹配

Java和Docker的結合併不是完美匹配的,最初的時候離完美匹配有相當大的距離。對於初學者來說,JVM的全部設想就是,虛擬機器可以讓程式與底層硬體無關。
那麼,把我們的Java應用打包到JVM中,然後整個再塞進Docker容器中,能給我們帶來什麼好處呢?大多數情況下,你只是在複製JVMs和Linux容器,除了浪費更多的記憶體,沒任何好處。感覺這樣子挺傻的。
不過,Docker可以把你的程式,設定,特定的JDK,Linux設定和應用伺服器,還有其他工具打包在一起,當做一個東西。站在DevOps/Cloud的角度來看,這樣一個完整的容器有著更高層次的封裝。
問題一:記憶體
時至今日,絕大多數產品級應用仍然在使用Java 8(或者更舊的版本),而這可能會帶來問題。Java 8(update 131之前的版本)跟Docker無法很好地一起工作。問題是在你的機器上,JVM的可用記憶體和CPU數量並不是Docker允許你使用的可用記憶體和CPU數量。
比如,如果你限制了你的Docker容器只能使用100MB記憶體,但是呢,舊版本的Java並不能識別這個限制。Java看不到這個限制。JVM會要求更多記憶體,而且遠超這個限制。如果使用太多記憶體,Docker將採取行動並殺死容器內的行程!JAVA行程被幹掉了,很明顯,這並不是我們想要的。
為瞭解決這個問題,你需要給Java指定一個最大記憶體限制。在舊版本的Java(8u131之前),你需要在容器中透過設定-Xmx來限制堆大小。這感覺不太對,你可不想定義這些限制兩次,也不太想在你的容器中來定義。
幸運的是我們現在有了更好的方式來解決這個問題。從Java 9之後(8u131+),JVM增加瞭如下標誌:
-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap
這些標誌強制JVM檢查Linux的cgroup配置,Docker是透過cgroup來實現最大記憶體設定的。現在,如果你的應用到達了Docker設定的限制(比如500MB),JVM是可以看到這個限制的。JVM將會嘗試GC操作。如果仍然超過記憶體限制,JVM就會做它該做的事情,丟擲OutOfMemoryException。也就是說,JVM能夠看到Docker的這些設定。
從Java 10之後(參考下麵的測試),這些體驗標誌位是預設開啟的,也可以使用-XX:+UseContainerSupport來使能(你可以透過設定-XX:-UseContainerSupport來禁止這些行為)。
問題二:CPU
第二個問題是類似的,但它與CPU有關。簡而言之,JVM將檢視硬體並檢測CPU的數量。它會最佳化你的runtime以使用這些CPUs。但是同樣的情況,這裡還有另一個不匹配,Docker可能不允許你使用所有這些CPUs。可惜的是,這在Java 8或Java 9中並沒有修複,但是在Java 10中得到瞭解決。
從Java 10開始,可用的CPUs的計算將採用以不同的方式(預設情況下)解決此問題(同樣是透過UseContainerSupport)。

Java和Docker的記憶體處理測試

作為一個有趣的練習,讓我們驗證並測試Docker如何使用幾個不同的JVM版本/標誌甚至不同的JVM來處理記憶體不足。
首先,我們建立一個測試應用程式,它只是簡單地“吃”記憶體並且不釋放它。
java
import java.util.ArrayList;
import java.util.List;
public class MemEat {
   public static void main(String[] args) {
       List l = new ArrayList<>();
       while (true) {
           byte b[] = new byte[1048576];
           l.add(b);
           Runtime rt = Runtime.getRuntime();
           System.out.println( "free memory: " + rt.freeMemory() );
       }
   }
}
我們可以啟動Docker容器並執行這個應用程式來檢視會發生什麼。
測試一:Java 8u111
首先,我們將從具有舊版本Java 8的容器開始(update 111)。
shell
docker run -m 100m -it java:openjdk-8u111 /bin/bash
我們編譯並執行MemEat.java檔案:
shell
javac MemEat.java
java MemEat
...
free memory: 67194416
free memory: 66145824
free memory: 65097232
Killed
正如所料,Docker已經殺死了我們的Java行程。不是我們想要的(!)。你也可以看到輸出,Java認為它仍然有大量的記憶體需要分配。
我們可以透過使用-Xmx標誌為Java提供最大記憶體來解決此問題:
shell
javac MemEat.java
java -Xmx100m MemEat
...
free memory: 1155664
free memory: 1679936
free memory: 2204208
free memory: 1315752
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
   at MemEat.main(MemEat.java:8)
在提供了我們自己的記憶體限制之後,行程正常停止,JVM理解它正在執行的限制。然而,問題在於你現在將這些記憶體限制設定了兩次,Docker一次,JVM一次。
測試二:Java 8u144
如前所述,隨著增加新標誌來修複問題,JVM現在可以遵循Docker所提供的設定。我們可以使用版本新一點的JVM來測試它。
shell
docker run -m 100m -it adoptopenjdk/openjdk8 /bin/bash
(在撰寫本文時,此OpenJDK Java映象的版本是Java 8u144)
接下來,我們再次編譯並執行MemEat.java檔案,不帶任何標誌:
shell
javac MemEat.java
java MemEat
...
free memory: 67194416
free memory: 66145824
free memory: 65097232
Killed
依然存在同樣的問題。但是我們現在可以提供上面提到的實驗性標誌來試試看:

shell
javac MemEat.java
java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap MemEat
...
free memory: 1679936
free memory: 2204208
free memory: 1155616
free memory: 1155600
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
   at MemEat.main(MemEat.java:8)

這一次我們沒有告訴JVM限制的是什麼,我們只是告訴JVM去檢查正確的限制設定!現在感覺好多了。
測試三:Java 10u23
有些人在評論和Reddit上提到Java 10透過使實驗標誌成為新的預設值來解決所有問題。這種行為可以透過禁用此標誌來關閉:-XX:-UseContainerSupport。
當我測試它時,它最初不起作用。在撰寫本文時,AdoptAJDK OpenJDK10映象與jdk-10+23一起打包。這個JVM顯然還是不理解UseContainerSupport標誌,該行程仍然被Docker殺死。

shell
docker run -m 100m -it adoptopenjdk/openjdk10 /bin/bash

測試了程式碼(甚至手動提供需要的標誌):
shell
javac MemEat.java
java MemEat
...
free memory: 96262112
free memory: 94164960
free memory: 92067808
free memory: 89970656
Killed
java -XX:+UseContainerSupport MemEat
Unrecognized VM option 'UseContainerSupport'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
測試四:Java 10u46(Nightly)
我決定嘗試AdoptAJDK OpenJDK 10的最新nightly構建。它包含的版本是Java 10+46,而不是Java 10+23。
shell
docker run -m 100m -it adoptopenjdk/openjdk10:nightly /bin/bash
然而,在這個ngithly構建中有一個問題,匯出的PATH指向舊的Java 10+23目錄,而不是10+46,我們需要修複這個問題。
shell
export PATH=$PATH:/opt/java/openjdk/jdk-10+46/bin/
javac MemEat.java
java MemEat
...
free memory: 3566824
free memory: 2796008
free memory: 1480320
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
 at MemEat.main(MemEat.java:8)
成功!不提供任何標誌,Java 10依然可以正確檢測到Dockers記憶體限制。
測試五:OpenJ9
我最近也在試用OpenJ9,這個免費的替代JVM已經從IBM J9開源,現在由Eclipse維護。
請在我的下一篇博文(http://royvanrijn.com/blog/2018/05/openj9-jvm-shootout/)中閱讀關於OpenJ9的更多資訊。
它執行速度快,記憶體管理非常好,效能卓越,經常可以為我們的微服務節省多達30-50%的記憶體。這幾乎可以將Spring Boot應用程式定義為’micro’了,其執行時間只有100-200mb,而不是300mb+。我打算儘快就此寫一篇關於這方面的文章。
但令我驚訝的是,OpenJ9還沒有類似於Java 8/9/10+中針對cgroup記憶體限制的標誌(backported)的選項。如果我們將以前的測試用例應用到最新的AdoptAJDK OpenJDK 9 + OpenJ9 build:
shell
docker run -m 100m -it adoptopenjdk/openjdk9-openj9 /bin/bash
我們新增OpenJDK標誌(OpenJ9會忽略的標誌):
shell
java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap MemEat
...
free memory: 83988984
free memory: 82940400
free memory: 81891816
Killed
Oops,JVM再次被Docker殺死。
我真的希望類似的選項將很快新增到OpenJ9中,因為我希望在生產環境中執行這個選項,而不必指定最大記憶體兩次。 Eclipse/IBM正在努力修複這個問題,已經提了issues,甚至已經針對issues提交了PR。
更新:(不推薦Hack)
一個稍微醜陋/hacky的方式來解決這個問題是使用下麵的組合標誌:
shell
java -Xmx`cat /sys/fs/cgroup/memory/memory.limit_in_bytes` MemEat
...
free memory: 3171536
free memory: 2127048
free memory: 2397632
free memory: 1344952
JVMDUMP039I Processing dump event "systhrow", detail "java/lang/OutOfMemoryError" at 2018/05/15 14:04:26 - please wait.
JVMDUMP032I JVM requested System dump using '//core.20180515.140426.125.0001.dmp' in response to an event
JVMDUMP010I System dump written to //core.20180515.140426.125.0001.dmp
JVMDUMP032I JVM requested Heap dump using '//heapdump.20180515.140426.125.0002.phd' in response to an event
JVMDUMP010I Heap dump written to //heapdump.20180515.140426.125.0002.phd
JVMDUMP032I JVM requested Java dump using '//javacore.20180515.140426.125.0003.txt' in response to an event
JVMDUMP010I Java dump written to //javacore.20180515.140426.125.0003.txt
JVMDUMP032I JVM requested Snap dump using '//Snap.20180515.140426.125.0004.trc' in response to an event
JVMDUMP010I Snap dump written to //Snap.20180515.140426.125.0004.trc
JVMDUMP013I Processed dump event "systhrow", detail "java/lang/OutOfMemoryError".
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
 at MemEat.main(MemEat.java:8)
在這種情況下,堆大小受限於分配給Docker實體的記憶體,這適用於較舊的JVM和OpenJ9。這當然是錯誤的,因為容器本身和堆外的JVM的其他部分也使用記憶體。但它似乎工作,顯然Docker在這種情況下是寬鬆的。也許某些bash大神會做出更好的版本,從其他行程的位元組中減去一部分。
無論如何,不要這樣做,它可能無法正常工作。
測試六:OpenJ9(Nightly)
有人建議使用OpenJ9的最新nightly版本。
shell
docker run -m 100m -it adoptopenjdk/openjdk9-openj9:nightly /bin/bash
最新的OpenJ9夜間版本,它有兩個東西:
  1. 另一個有問題的PATH引數,需要先解決這個問題

  2. JVM支援新標誌UseContainerSupport(就像Java 10一樣)

shell
export PATH=$PATH:/opt/java/openjdk/jdk-9.0.4+12/bin/
javac MemEat.java
java -XX:+UseContainerSupport MemEat
...
free memory: 5864464
free memory: 4815880
free memory: 3443712
free memory: 2391032
JVMDUMP039I Processing dump event "systhrow", detail "java/lang/OutOfMemoryError" at 2018/05/15 21:32:07 - please wait.
JVMDUMP032I JVM requested System dump using '//core.20180515.213207.62.0001.dmp' in response to an event
JVMDUMP010I System dump written to //core.20180515.213207.62.0001.dmp
JVMDUMP032I JVM requested Heap dump using '//heapdump.20180515.213207.62.0002.phd' in response to an event
JVMDUMP010I Heap dump written to //heapdump.20180515.213207.62.0002.phd
JVMDUMP032I JVM requested Java dump using '//javacore.20180515.213207.62.0003.txt' in response to an event
JVMDUMP010I Java dump written to //javacore.20180515.213207.62.0003.txt
JVMDUMP032I JVM requested Snap dump using '//Snap.20180515.213207.62.0004.trc' in response to an event
JVMDUMP010I Snap dump written to //Snap.20180515.213207.62.0004.trc
JVMDUMP013I Processed dump event "systhrow", detail "java/lang/OutOfMemoryError".
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
TADAAA,正在修複中!
奇怪的是,這個標誌在OpenJ9中預設沒有啟用,就像它在Java 10中一樣。再說一次:確保你測試了這是你想在一個Docker容器中執行Java。

結論

簡言之:註意資源限制的不匹配。測試你的記憶體設定和JVM標誌,不要假設任何東西。
如果您在Docker容器中執行Java,請確保你設定了Docker記憶體限制和在JVM中也做了限制,或者你的JVM能夠理解這些限制。
如果您無法升級您的Java版本,請使用-Xmx設定您自己的限制。
對於Java 8和Java 9,請更新到最新版本並使用:
-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap
對於Java 10,確保它支援’UseContainerSupport’(更新到最新版本)。
對於OpenJ9(我強烈建議使用,可以在生產環境中有效減少記憶體佔用量),現在使用-Xmx設定限制,但很快會出現一個支援UseContainerSupport標誌的版本。
原文連結:http://royvanrijn.com/blog/2018/05/java-and-docker-memory-limits/

Kubernetes入門與進階實戰培訓

本次培訓內容包括:Docker基礎、容器技術、Docker映象、資料共享與持久化、Docker三駕馬車、Docker實踐、Kubernetes基礎、Pod基礎與進階、常用物件操作、服務發現、Helm、Kubernetes核心元件原理分析、Kubernetes服務質量保證、排程詳解與應用場景、網路、基於Kubernetes的CI/CD、基於Kubernetes的配置管理等,點選瞭解具體培訓內容

6月22日正式上課,點選閱讀原文連結即可報名。
贊(0)

分享創造快樂