1. 程式人生 > >在Docker中執行Java:為了防止失敗,你需要知道這些

在Docker中執行Java:為了防止失敗,你需要知道這些

摘要: 很多開發者會(或者應該)知道,當我們為執行在Linux容器(Docker、rkt、runC、lxcfs等)中的Java程式去設定JVM的GC、堆大小和執行時編譯器的引數時並沒有得到預想的效果。當我們通過“java -jar mypplication-fat.jar”的方式而不設定任何引數來執行一個Java應用時,JVM會根據自身的許多引數進行調整,以便在執行環境中獲得最優的效能。本篇部落格將通過簡單的方式向開發人員展示在將Java應用執行在Linux容器內時需要了解的內容。

很多開發者會(或者應該)知道,當我們為執行在Linux容器(Docker、rkt、runC、lxcfs等)中的Java程式去設定JVM的GC、堆大小和執行時編譯器的引數時並沒有得到預想的效果。當我們通過“java -jar mypplication-fat.jar”的方式而不設定任何引數來執行一個Java應用時,JVM會根據自身的許多引數進行調整,以便在執行環境中獲得最優的效能。

本篇部落格將通過簡單的方式向開發人員展示在將Java應用執行在Linux容器內時需要了解的內容。

我們傾向於認為容器可以像虛擬機器一樣可以完整的定義虛擬機器的CPU個數和虛擬機器的記憶體。容器更像是一個程序級別的資源(CPU、記憶體、檔案系統、網路等)隔離。這種隔離是依賴於Linux核心中提供的一個cgroups的功能。

然而,一些可以從執行時環境中收集資訊的應用程式在cgroups功能出現之前已經存在。在容器中執行命令 ‘top‘、‘free‘、‘ps’,也包括沒有經過優化的JVM是一個會受到高限制的Linux程序。讓我們來驗證一下。

問題

為了展示遇到的問題,我使用命令“docker-machine create -d virtualbox –virtualbox-memory ‘1024’ docker1024”在虛擬機器中建立了一個具有1GB記憶體的Docker守護程序,接下來在3個Linux容器中執行命令“free -h”,使其只有100MB的記憶體和Swap。結果顯示所有的容器總記憶體是995MB。

即使是在 Kubernetes/OpenShift叢集中,結果也是類似的。我在一個記憶體是15G的叢集中也執行了命令使得Kubernetes Pod有511MB的記憶體限制(命令:“kubectl run mycentos –image=centos -it –limits=’memory=512Mi’”),總記憶體顯示為14GB。

想要知道為什麼是這樣的結果,可以去閱讀此篇部落格文章 “Memory inside Linux containers – Or why don’t free and top work in a Linux container?”(https://fabiokung.com/2014/03/13/memory-inside-linux-containers/

 )

我們需要知道Docker引數(-m、–memory和–memory-swap)和Kubernetes引數(–limits)會讓Linux核心在一個程序的記憶體超出限制時將其Kill掉,但是JVM根本不清楚這個限制的存在,當超過這個限制時,不好的事情發生了!

為了模擬當一個程序超出記憶體限制時會被殺死的場景,我們可以通過命令“docker run -it –name mywildfly -m=50m jboss/wildfly”在一個容器中執行WildFly Application Server並且為其限制記憶體大小為50MB。在這個容器執行期間,我們可以執行命令“docker stats”來檢視容器的限制。

但是過了幾秒之後,容器Wildfly將會被中斷並且輸出資訊:*** JBossAS process (55) received KILL signal ***

通過命令 “docker inspect mywildfly -f ‘{{json .State}}'”可以檢視容器被殺死的原因是發生了OOM(記憶體不足)。容器中的“state”被記錄為OOMKilled=true 。

這將怎樣影響Java應用

在Docker宿主機中建立一個具有1GB記憶體的虛擬機器(在之前使用命令已經建立完畢 “docker-machine create -d virtualbox –virtualbox-memory ‘1024’ docker1024”) ,並且限制一個容器的記憶體為150M,看起來已經足夠執行這個在 Dockerfile中設定過引數-XX: PrintFlagsFinal 和 -XX: PrintGCDetails的Spring Boot application了。這些引數使得我們可以讀取JVM的初始化引數並且獲得 Garbage Collection(GC)的執行詳細情況。

嘗試一下:

$ docker run -it --rm --name mycontainer150 -p 8080:8080 -m 150M rafabene/java-container:openjdk

我也提供了一個訪問介面“/api/memory/”來使用String物件載入JVM記憶體,模擬大量的消耗記憶體,可以呼叫試試:

$ curl http://`docker-machine ip docker1024`:8080/api/memory

這個介面將會返回下面的資訊 “Allocated more than 80% (219.8 MiB) of the max allowed JVM memory size (241.7 MiB)”。

在這裡我們至少有2個問題:

  • 為什麼JVM會允許241.7MiB的最大內容?

  • 如果容器已經限制了記憶體為150MB,為什麼允許Java分配記憶體到220MB?

首先,我們應該重新瞭解在JVM ergonomic page中所描述的 “maximum heap size”的定義,它將會使用1/4的實體記憶體。JVM並不知道它執行在一個容器中,所以它將被允許使用260MB的最大堆大小。通過新增容器初始化時的引數-XX: PrintFlagsFinal,我們可以檢查這個引數的值。

$ docker logs mycontainer150|grep -i MaxHeapSize

uintx MaxHeapSize := 262144000 {product}

其次,我們應該理解當在docker命令列中設定了 “-m 150M”引數時,Docker守護程序會限制RAM為150M並且Swap為150M。從結果上看,一個程序可以分配300M的記憶體,解釋了為什麼我們的程序沒有收到任何從Kernel中發出的退出訊號。

更多的關於Docker命令中記憶體限制 (–memory)和Swap (–memory-swap)的差別可以參考這裡。

更多的記憶體是解決方案嗎?

開發者如果不理解問題可能會認為執行環境中沒有為JVM提供足夠的記憶體。通常的解決對策就是為執行環境提供更多的記憶體,但是實際上,這是一個錯誤的認識。

假如我們將Docker Machine的記憶體從1GB提高到8GB(使用命令 “docker-machine create -d virtualbox –virtualbox-memory ‘8192’ docker8192”),並且建立的容器從150M到800M:

$ docker run -it --name mycontainer -p 8080:8080 -m 800M rafabene/java-container:openjdk

此時使用命令 “curl http://X51X:8080/api/memory ” 還不能返回結果,因為在一個擁有8GB記憶體的JVM環境中經過計算的MaxHeapSize大小是2092957696(~ 2GB)。可以使用命令“docker logs mycontainer|grep -i MaxHeapSize”檢視。

應用將會嘗試分配超過1.6GB的記憶體,當超過了容器的限制(800MB的RAM 800MB的Swap),程序將會被Kill掉。

很明顯當在容器中執行程式時,通過增加記憶體和設定JVM的引數不是一個好的方式。當在一個容器中執行Java應用時,我們應該基於應用的需要和容器的限制來設定最大堆大小(引數:-Xmx)。

解決方案是什麼?

在Dockerfile中稍作修改,為JVM指定擴充套件的環境變數。修改內容如下:

CMD java -XX:+PrintFlagsFinal -XX:+PrintGCDetails $JAVA_OPTIONS -jar java-container.jar

現在我們可以使用JAVA_OPTIONS的環境變數來設定JVM Heap的大小。300MB看起來對應用足夠了。稍後你可以檢視日誌,看到Heap的值是 314572800 bytes(300MBi)。

Docker下,可以使用“-e”的引數來設定環境變數進行切換。

$ docker run -d --name mycontainer8g -p 8080:8080 -m 800M -e JAVA_OPTIONS='-Xmx300m' rafabene/java-container:openjdk-env

$ docker logs mycontainer8g|grep -i MaxHeapSize

uintx    MaxHeapSize := 314572800       {product}

在Kubernetes中,可以使用“–env=[key=value]”來設定環境變數進行切換:

$ kubectl run mycontainer --image=rafabene/java-container:openjdk-env --limits='memory=800Mi' --env="JAVA_OPTIONS='-Xmx300m'"

$ kubectl get pods

NAME                          READY  STATUS    RESTARTS AGE

mycontainer-2141389741-b1u0o  1/1    Running   0        6s

$ kubectl logs mycontainer-2141389741-b1u0o|grep MaxHeapSize

uintx     MaxHeapSize := 314572800     {product}

還能再改進嗎?

有什麼辦法可以根據容器的限制來自動計算Heap的值?

事實上如果你的基礎Docker映象使用的是由Fabric8提供的,那麼就可以實現。映象fabric8/java-jboss-openjdk8-jdk使用了指令碼來計算容器的記憶體限制,並且使用50%的記憶體作為上限。也就是有50%的記憶體可以寫入。你也可以使用這個映象來開/關除錯、診斷或者其他更多的事情。讓我們看一下一個Spring Boot應用的 Dockerfile :

FROM fabric8/java-jboss-openjdk8-jdk:1.2.3

ENV JAVA_APP_JAR java-container.jar

ENV AB_OFF true

EXPOSE 8080

ADD target/$JAVA_APP_JAR /deployments/

就這樣!現在,不管容器的記憶體限制如何,我們的Java應用將在容器中自動的調節Heap大小,而不是再根據宿主機來設定。

總結到目前為止,Java JVM還不能意識到其是執行在一個容器中 — 某些資源在記憶體和CPU的使用上會受到限制。因此,你不能讓JVM自己來設定其認為的最優的最大Heap值。

一個解決對策是使用Fabric8作為基礎映象,它可以意識到應用程式執行在一個受限制的容器中,並且在你沒有做任何事情的情況下,可以自動的調整最大Heap的值。