天天看點

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

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

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

很多開發者會(或者應該)知道,當我們為運作在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。

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

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

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

想要知道為什麼是這樣的結果,可以去閱讀此篇部落格文章 “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”來檢視容器的限制。

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

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

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

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

這将怎樣影響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”檢視。

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

應用将會嘗試配置設定超過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大小,而不是再根據主控端來設定。

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

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

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