在Docker中運(yùn)行Java:為了防止失敗,你應(yīng)該知道的
如果你嘗試在容器中運(yùn)行Java程序,或者專注于Docker,你可能會(huì)遇到一些關(guān)于JVM和堆大小的問(wèn)題。本篇文章將介紹如何解決這些問(wèn)題。
很多開發(fā)者會(huì)(或者應(yīng)該)知道,當(dāng)我們?yōu)檫\(yùn)行在Linux容器(docker, rkt, runC, lxcfs, etc,)中的Java程序去設(shè)置JVM的GC、堆大小和運(yùn)行時(shí)編譯器的參數(shù)時(shí)并沒(méi)有得到預(yù)想的效果。當(dāng)我們通過(guò)“java -jar mypplication-fat.jar”的方式而不設(shè)置任何參數(shù)來(lái)運(yùn)行一個(gè)Java應(yīng)用時(shí),JVM會(huì)根據(jù)自身的許多參數(shù)進(jìn)行調(diào)整,以便在執(zhí)行環(huán)境中獲得最優(yōu)的性能。
本篇博客將通過(guò)簡(jiǎn)單的方式向開發(fā)人員展示在將Java應(yīng)用運(yùn)行在Linux容器內(nèi)時(shí)需要了解的內(nèi)容。
我們傾向于認(rèn)為容器可以像虛擬機(jī)一樣可以完整的定義虛擬機(jī)的CPU個(gè)數(shù)和虛擬機(jī)的內(nèi)存。容器更像是一個(gè)進(jìn)程級(jí)別的資源(CPU、內(nèi)存、文件系統(tǒng)、網(wǎng)絡(luò)等)隔離。這種隔離是依賴于Linux內(nèi)核中提供的一個(gè) cgroups 的功能。
然而,一些可以從運(yùn)行時(shí)環(huán)境中收集信息的應(yīng)用程序在cgroups功能出現(xiàn)之前已經(jīng)存在。在容器中執(zhí)行命令 ‘top‘, ‘free‘, ‘ps’,也包括沒(méi)有經(jīng)過(guò)優(yōu)化的JVM是一個(gè)會(huì)受到高限制的Linux進(jìn)程。讓我們來(lái)驗(yàn)證一下。
問(wèn)題
為了展示遇到的問(wèn)題,我使用命令“docker-machine create -d virtualbox –virtualbox-memory ‘1024’ docker1024”在虛擬機(jī)中創(chuàng)建了一個(gè)具有1GB內(nèi)存的Docker守護(hù)進(jìn)程,接下來(lái)在3個(gè)Linux容器中執(zhí)行命令“free -h”,使其只有100MB的內(nèi)存和Swap。結(jié)果顯示所有的容器總內(nèi)存是995MB。
即使是在 Kubernetes/OpenShift集群中,結(jié)果也是類似的。我在一個(gè)內(nèi)存是15G的集群中也執(zhí)行了命令使得Kubernetes Pod有511MB的內(nèi)存限制(命令:“kubectl run mycentos –image=centos -it –limits=’memory=512Mi’”),總內(nèi)存顯示為14GB。
想要知道為什么是這樣的結(jié)果,可以去閱讀此篇博客文章 “ Memory inside Linux containers – Or why don’t free and top work in a Linux container? ”
我們需要知道Docker參數(shù) (-m, –memory和–memory-swap)和Kubernetes參數(shù) (–limits)會(huì)讓Linux內(nèi)核在一個(gè)進(jìn)程的內(nèi)存超出限制時(shí)將其Kill掉,但是JVM根本不清楚這個(gè)限制的存在,當(dāng)超過(guò)這個(gè)限制時(shí),不好的事情發(fā)生了!
為了模擬當(dāng)一個(gè)進(jìn)程超出內(nèi)存限制時(shí)會(huì)被殺死的場(chǎng)景,我們可以通過(guò)命令“docker run -it –name mywildfly -m=50m jboss/wildfly”在一個(gè)容器中運(yùn)行WildFly Application Server并且為其限制內(nèi)存大小為50MB。在這個(gè)容器運(yùn)行期間,我們可以執(zhí)行命令“docker stats”來(lái)查看容器的限制。
但是過(guò)了幾秒之后,容器Wildfly將會(huì)被中斷并且輸出信息:*** JBossAS process (55) received KILL signal ***
通過(guò)命令 “docker inspect mywildfly -f ‘{{json .State}}'”可以查看容器被殺死的原因是發(fā)生了OOM(內(nèi)存不足)。容器中的“state”被記錄為OOMKilled=true 。
這將怎樣影響Java應(yīng)用
在Docker宿主機(jī)中創(chuàng)建一個(gè)具有1GB內(nèi)存的虛擬機(jī)(在之前使用命令已經(jīng)創(chuàng)建完畢 “docker-machine create -d virtualbox –virtualbox-memory ‘1024’ docker1024”) ,并且限制一個(gè)容器的內(nèi)存為150M,看起來(lái)已經(jīng)足夠運(yùn)行這個(gè)在 Dockerfile中設(shè)置過(guò)參數(shù)-XX: PrintFlagsFinal 和 -XX: PrintGCDetails的Spring Boot application了。這些參數(shù)使得我們可以讀取JVM的初始化參數(shù)并且獲得 Garbage Collection (GC)的運(yùn)行詳細(xì)情況。
嘗試一下:
- $ docker run -it --rm --name mycontainer150 -p 8080:8080 -m 150M rafabene/java-container:openjdk
我也提供了一個(gè)訪問(wèn)接口“/api/memory/”來(lái)使用String對(duì)象加載JVM內(nèi)存,模擬大量的消耗內(nèi)存,可以調(diào)用試試:
- $ curl http://X 41X:8080/api/memory
這個(gè)接口將會(huì)返回下面的信息 “Allocated more than 80% (219.8 MiB) of the max allowed JVM memory size (241.7 MiB)”
在這里我們至少有2個(gè)問(wèn)題:
- 為什么JVM會(huì)允許241.7MiB的最大內(nèi)容?
- 如果容器已經(jīng)限制了內(nèi)存為150MB,為什么允許Java分配內(nèi)存到220MB?
首先,我們應(yīng)該重新了解在 JVM ergonomic page 中所描述的 “maximum heap size”的定義,它將會(huì)使用1/4的物理內(nèi)存。JVM并不知道它運(yùn)行在一個(gè)容器中,所以它將被允許使用260MB的最大堆大小。通過(guò)添加容器初始化時(shí)的參數(shù)-XX: PrintFlagsFinal,我們可以檢查這個(gè)參數(shù)的值。
- $ docker logs mycontainer150|grep -i MaxHeapSize
- uintx MaxHeapSize := 262144000 {product}
其次,我們應(yīng)該理解當(dāng)在docker命令行中設(shè)置了 “-m 150M”參數(shù)時(shí),Docker守護(hù)進(jìn)程會(huì)限制RAM為150M并且Swap為150M。從結(jié)果上看,一個(gè)進(jìn)程可以分配300M的內(nèi)存,解釋了為什么我們的進(jìn)程沒(méi)有收到任何從Kernel中發(fā)出的退出信號(hào)。
更多的關(guān)于Docker命令中內(nèi)存限制 (–memory)和Swap (–memory-swap)的差別可以參考 這里 。
更多的內(nèi)存是解決方案嗎?
開發(fā)者如果不理解問(wèn)題可能會(huì)認(rèn)為運(yùn)行環(huán)境中沒(méi)有為JVM提供足夠的內(nèi)存。通常的解決對(duì)策就是為運(yùn)行環(huán)境提供更多的內(nèi)存,但是實(shí)際上,這是一個(gè)錯(cuò)誤的認(rèn)識(shí)。
假如我們將Docker Machine的內(nèi)存從1GB提高到8GB(使用命令 “docker-machine create -d virtualbox –virtualbox-memory ‘8192’ docker8192”),并且創(chuàng)建的容器從150M到800M:
- $ docker run -it --name mycontainer -p 8080:8080 -m 800M rafabene/java-container:openjdk
此時(shí)使用命令 “curl http://X 58X:8080/api/memory” 還不能返回結(jié)果,因?yàn)樵谝粋€(gè)擁有8GB內(nèi)存的JVM環(huán)境中經(jīng)過(guò)計(jì)算的MaxHeapSize大小是2092957696(~ 2GB)。可以使用命令“docker logs mycontainer|grep -i MaxHeapSize”查看。
應(yīng)用將會(huì)嘗試分配超過(guò)1.6GB的內(nèi)存,當(dāng)超過(guò)了容器的限制(800MB的RAM 800MB的Swap),進(jìn)程將會(huì)被kill掉。
很明顯當(dāng)在容器中運(yùn)行程序時(shí),通過(guò)增加內(nèi)存和設(shè)置JVM的參數(shù)不是一個(gè)好的方式。當(dāng)在一個(gè)容器中運(yùn)行Java應(yīng)用時(shí),我們應(yīng)該基于應(yīng)用的需要和容器的限制來(lái)設(shè)置最大堆大小(參數(shù):-Xmx)。
解決方案是什么?
在Dockerfile中稍作修改,為JVM指定擴(kuò)展的環(huán)境變量。修改內(nèi)容如下:
- CMD java -XX: PrintFlagsFinal -XX: PrintGCDetails $JAVA_OPTIONS -jar java-container.jar
現(xiàn)在我們可以使用JAVA_OPTIONS的環(huán)境變量來(lái)設(shè)置JVM Heap的大小。300MB看起來(lái)對(duì)應(yīng)用足夠了。稍后你可以查看日志,看到Heap的值是 314572800 bytes ( 300MBi)。
Docker下,可以使用“-e”的參數(shù)來(lái)設(shè)置環(huán)境變量進(jìn)行切換。
- $ 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]”來(lái)設(shè)置環(huán)境變量進(jìn)行切換:
- $ 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}
還能再改進(jìn)嗎?
有什么辦法可以根據(jù)容器的限制來(lái)自動(dòng)計(jì)算Heap的值?
事實(shí)上如果你的基礎(chǔ)Docker鏡像使用的是由Fabric8提供的,那么就可以實(shí)現(xiàn)。鏡像fabric8/java-jboss-openjdk8-jdk使用了腳本來(lái)計(jì)算容器的內(nèi)存限制,并且使用50%的內(nèi)存作為上限。也就是有50%的內(nèi)存可以寫入。你也可以使用這個(gè)鏡像來(lái)開/關(guān)調(diào)試、診斷或者其他更多的事情。讓我們看一下一個(gè)Spring Boot應(yīng)用的 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/
就這樣!現(xiàn)在,不管容器的內(nèi)存限制如何,我們的Java應(yīng)用將在容器中自動(dòng)的調(diào)節(jié)Heap大小,而不是再根據(jù)宿主機(jī)來(lái)設(shè)置。
總結(jié)到目前為止,Java JVM還不能意識(shí)到其是運(yùn)行在一個(gè)容器中 — 某些資源在內(nèi)存和CPU的使用上會(huì)受到限制。因此,你不能讓JVM自己來(lái)設(shè)置其認(rèn)為的最優(yōu)的最大Heap值。
一個(gè)解決對(duì)策是使用Fabric8作為基礎(chǔ)鏡像,它可以意識(shí)到應(yīng)用程序運(yùn)行在一個(gè)受限制的容器中,并且在你沒(méi)有做任何事情的情況下,可以自動(dòng)的調(diào)整最大Heap的值。
在JDK9中已經(jīng)開始進(jìn)行嘗試在容器 (i.e. Docker)環(huán)境中為JVM提供cgroup功能的內(nèi)存限制。