2018年的Docker和JVM

banq發表於2018-12-28

即使Docker是2016年的大事情,它今天仍然很熱!它是最受歡迎的Orchestration平臺Kubernetes的基礎,它已成為雲部署的首選解決方案。
Docker是容器應用/微服務的事實標準解決方案。如果你執行Java應用程式,你需要知道一些陷阱和技巧。

為什麼我要將JAVA放在容器中呢?
這是一個很好的問題。Java不是用“ 一次編寫,隨處執行 ”的口號構建的嗎?儘管如此,該語句的意思Java應用僅包含Java二進位制檔案。
您的位元組碼(Jar檔案)將可能在每個的JVM版本上正常執行。然而,資料庫驅動程式呢?檔案系統訪問?聯網?可用熵?您依賴的第三方應用程式?所有這些因素都會因作業系統而異。
通常,您的應用程式需要在Java二進位制檔案和任何第三方依賴項之間取得良好的平衡。如果您曾經支援過客戶安裝的Java應用程式,那麼您就會知道我的意思。

首先是虛擬機器
在容器之前,通用解決方案是使用虛擬機器。使用您選擇的作業系統建立一個新的空白虛擬計算機,安裝所有第三方依賴項,複製Java二進位制檔案,獲得快照並最終遞交。
使用VM,您可以確定所傳送的內容與其執行方式完全相同,並且每次都一致。環境配置問題沒有空間。
它們還提供強大的封裝。如果您在雲中執行應用程式,則每個虛擬機器都將被隔離。在同一硬體上執行的VM之間的損壞空間非常有限。
但是,總有一個但是,卻很重!如果您在應用程式中發現了一個錯誤並且必須更改一行程式碼,則必須重新編譯Jar檔案,重新安裝VM並運送整個程式碼。一行程式碼變成 幾個GB檔案,可以上傳到雲端或下載到客戶端。作業系統檔案很重,可能比Java二進位制檔案重得多,並且每次都必須傳送它們,即使它們沒有真正改變。

Docker來救援
如上所述,VM擁有自己的作業系統副本,而容器則更小,並且只包含您要傳送的內容。

使用容器,作業系統(確切地說,它是正在共享的核心,您可以選擇從不同的發行版(如Ubuntu,Debian,Alpine等)構建影像)由引擎(例如Docker)提供,並且您不需要將其和你的應用一起交付。
使用Docker,您可以交付以層為單位構建的影像。構建映象的說明放在Dockerfile中。

從概念上講,Dockerfile可能是這樣的:

  1. 從空白的Ubuntu發行版開始
  2. 安裝Java
  3. 安裝依賴項A.
  4. 安裝依賴關係B.
  5. 複製jar檔案

Dockerfile中的每條指令都會建立一個不可變 層。這很聰明,也是一個很好的最佳化。只有當你修改改變最後一層程式碼時,則只需上傳最後一層,之前未更改的圖層將被快取; 終端使用者只需從映象底部下載更改的圖層。使用Docker,一行程式碼的更改意味著只有幾MB上傳/下載(如果這是VM,則更改將以GB而不是MB)。

請注意,容器不提供與VM相同級別的封裝。Docker容器只是在主機上執行的程式。有一些Linux核心功能(即名稱空間和控制組)有助於降低Docker容器的訪問級別,但這遠遠不如VM隔離那樣具有彈性。這可能是您的業務的問題,也可能不是,但您需要注意。

JAVA + DOCKER = ???
在我們研究如何在Docker容器中打包Jar檔案之前,我們需要涵蓋一些重要的限制。Java 1.0於1996年釋出,而Linux容器起源於2008年左右。由此可見,預計JVM不會容納Linux容器。

Docker的一個關鍵功能是能夠限制容器的記憶體和CPU使用。這是在雲中執行許多Docker容器在經濟上有趣的主要原因之一。像Kubernetes(k8s)這樣的業務流程解決方案將嘗試在多個節點上有效地 “包裝” 容器。這裡的“包裝”是指打包記憶體和CPU。如果為Docker容器提供記憶體和CPU的合理界限,K8將能夠在多個節點上有效地安排它們。

不幸的是,這正是Java缺乏的地方。讓我們用一個例子來理解這個問題。

想象一下,你有一個32GB記憶體的節點,你想使用Docker執行一個限制為1GB的Java應用程式。如果未提供-Xmx引數,則JVM將使用其預設配置:

  • JVM將檢查總可用記憶體。因為JVM不知道Linux容器(特別是限制記憶體的控制組),所以它認為它在主機上執行並且可以訪問完整的 32GB可用記憶體。
  • 預設情況下,JVM將使用MaxMemory / 4,在這種情況下為8GB(32GB / 4)。
  • 隨著堆大小的增長並超過1GB,容器將被Docker殺死。

早期的Docker Java採用者有一段時間試圖理解為什麼他們的JVM在沒有任何錯誤訊息的情況下崩潰。要了解發生了什麼,你需要檢查被殺死的Docker容器,在這種情況下,你會看到一條訊息說“OOM被殺 ”(OutOf Memory)。

當然,一個明顯的解決方案是使用Xmx引數修復JVM的堆大小,但這意味著您需要控制記憶體兩次,一次在Docker中,一次在JVM中。每當你想要做出改變時,你必須做兩次。不理想。

此問題的第一個解決方法是使用Java 8u131和Java 9釋出的版本,我說解決方法是因為你必須使用心愛的-XX:+ UnlockExperimentalVMOptions引數。如果您從事金融服務,我相信您很樂意向您的客戶或您的老闆解釋這是明智之舉。

然後你必須使用-XX:+ UseCGroupMemoryLimitForHeap,這將告訴JVM檢查控制組記憶體限制以設定最大堆大小。
最後,您必須使用-XX:MaxRAMFraction來決定可以為JVM分配的最大記憶體部分。不幸的是,這個引數是一個自然數。例如,將Docker記憶體限制設定為1GB,您將擁有以下內容:
  • -XX:MaxRAMFraction = 1最大堆大小為1GB。這不是很好,因為你不能給JVM 100%的允許記憶體。該容器上可能還有其他元件正在執行
  • -XX:MaxRAMFraction = 2最大堆大小為500MB。那更好但現在看來我們浪費了很多記憶體。
  • -XX:MaxRAMFraction = 3最大堆大小為250MB。你正在支付1GB的記憶體,你的Java應用程式可以使用250MB。這有點荒謬
  • -XX:MaxRAMFraction = 4 太小。

基本上,控制最大可用RAM的JVM標誌被設定為分數而不是百分比,這使得很難設定能夠有效利用可用(允許)RAM的值。
我們專注於記憶體,但同樣適用於CPU。你需要使用像這樣的引數
-Djava.util.concurrent.ForkJoinPool.common.parallelism = 2
控制應用程式中不同執行緒池的大小。2表示兩個執行緒(最大值將限制為主機上可用的超執行緒數)。
總而言之,使用Java 8u131和Java 9,你會有類似的配置:

-XX:+UnlockExperimentalVMOptions
-XX:+UseCGroupMemoryLimitForHeap
-XX:MaxRAMFraction=2
-Djava.util.concurrent.ForkJoinPool.common.parallelism=2


幸運的是Java 10來救援。首先,您不必使用可怕的實驗功能標誌。如果在Linux容器中執行Java應用程式,JVM將自動檢測控制組記憶體限制。否則,您只需新增-XX:-UseContainerSupport。

然後,您可以使用-XX控制記憶體:InitialRAMPercentage,-XX:MaxRAMPercentage和-XX:MinRAMPercentage。比如
  • Docker記憶體限制:1GB
  • -XX:InitialRAMPercentage = 50
  • -XX:MaxRAMPercentage = 70

您的JVM將從500MB(50%)堆大小開始,並將增長到700MB(70%),在容器中最大可用記憶體為1GB。

Java2Docker
將Java應用程式轉換為Docker映象的方法有很多種。
您可以使用Maven外掛(fabric8Spotify)或Graddle外掛。但也許最簡單和更語義的方法是自己編寫Dockerfile。這種方法還允許您利用在JDK9引入的JLINK。使用jlink,您可以構建一個自定義JDK二進位制檔案,其中只包含應用程式所需的模組。
我們來看一個例子:

FROM adoptopenjdk/openjdk11 AS jdkBuilder
RUN $JAVA_HOME/bin/jlink \
--module-path /opt/jdk/jmods \
--verbose \
--**add**-modules java.base \
--output /opt/jdk-minimal \
--compress 2 \
--no-header-files
 
FROM debian:9-slim
COPY --from=jdkBuilder /opt/jdk-minimal /opt/jdk-minimal
ENV JAVA_HOME=/opt/jdk-minimal
COPY target/*.jar /opt/
CMD $JAVA_HOME/bin/java $JAVA_OPTS -jar /opt/*.jar


讓我們一行一行地解釋它:

FROM adoptopenjdk/openjdk11 AS jdkBuilder

我們從包含完整JDK 11的現有Docker映象開始。這裡我們使用AdoptOpenJDK提供的構建,但您可以使用任何其他分發(例如新發布的AWS Corretto)。AS jdkBuilderinstruction是一個特殊的指令,告訴Docker我們想要啟動一個名為jdkBuilder的“階段”。這將在以後有用。

RUN $JAVA_HOME/bin/jlink \
--module-path /opt/jdk/jmods \
--verbose \
--add-modules java.base \
--output /opt/jdk-minimal \
--compress 2 \
--no-header-files


我們執行jlink來構建我們的自定義JDK二進位制檔案。在此示例中,我們僅使用java.base模組。如果您仍在編寫舊的類路徑型別應用程式,則必須手動新增應用程式所需的所有模組。例如,對於我的一個Spring應用程式,我使用以下模組:

--add-modules java.base,java.logging,java.xml,
java.xml.bind,java.sql,jdk.unsupported,
java.naming,java.desktop,java.management,
java.security.jgss,java.security.sasl,
jdk.crypto.cryptoki,jdk.crypto.ec,
java.instrument,jdk.management.agent,
jdk.localedata


如果您正在編寫帶有模組的Java應用程式,您可以讓jlink 推斷出需要哪些模組。為此,您需要將模組新增到module-path引數(MacOS / Linux上用“:”分隔的路徑列表和Windows上的“;”)。但是因為這個過程發生在Docker映象中,你需要使用COPY命令將其結束。然後,您只需要在-add-modules命令中新增自己的模組,並自動新增所需的模組。所以它會是這樣的:

FROM adoptopenjdk/openjdk11 AS jdkBuilder
COPY path/to/module-info.class /opt/myModules
RUN $JAVA_HOME/bin/jlink \
--module-path /opt/jdk/jmods:/opt/myModules \
--verbose \
--**add**-modules my-module \
--output /opt/jdk-minimal \
--compress 2 \
--no-header-files


FROM debian:9-slim
因為我們使用另一個FROM關鍵字,Docker將丟棄我們迄今為止所做的所有事情並開始一個全新的映象。這裡我們從一個安裝了Debian 9的Docker映象開始,並安裝了最小的依賴項(slim標籤)。這個Debian映像甚至沒有Java,所以我們接下來會安裝

COPY --from=jdkBuilder /opt/jdk-minimal /opt/jdk-minimal
ENV JAVA_HOME=/opt/jdk-minimal

這是舞臺名稱變得重要的地方。我們可以告訴Docker 從早期階段複製特定資料夾,在這種情況下從jdkBuilder階段複製。這很有趣,因為在第一階段我們可以下載很多最終不需要的中間庫。
在這種情況下,我們從完整的JDK 11發行版開始,重量為200 + MB,但我們只需複製我們的自定義JDK二進位制檔案,通常為~50 / 60MB; 取決於您必須匯入的JDK模組。然後我們將JAVA_HOME環境變數設定為指向我們新構建的JDK二進位制檔案。
這種技術稱為Docker多階段構建,確實非常有用。它可以有效地利用所建立的圖層,並有助於製作更纖薄的Docker映象。如果您檢視了典型的Dockerfile,可能會看到如下所示的說明:

rm -rf /var/lib/apt/lists/* \
apt-get clean && apt-get update && apt-get upgrade -y \
apt-get install -y --no-install-recommends curl ca-certificates \
rm -rf /var/lib/apt/lists/* \
...


這是一種種在單個Dockerfile指令中將盡可能多的命令分組的技術,這技術對於最小化映象的層數很有用。大量層可能會影響執行時的效能。但是,這種方法也存在缺陷。每條指令有一個層意味著建立了許多快取的檢查點。
如果你在第15條指令中在Dockerfile中犯了一個錯誤,Docker就不必重新執行前面的14,它可以簡單地從快取中恢復它們。如果你的一個步驟是下載一個400MB的檔案,這個指令快取將為你節省大量的時間。
好訊息是多階段使這種方法過時了!您可以建立第一個“Throw-away”階段,在這個階段建立任意數量的圖層。然後,您將建立一個新的“final”階段,在該階段中,您只從第一個Throw-away階段複製所需的檔案。
第一階段的許多層將被完全忽略!

COPY target/*.jar /opt/
現在我們安裝了Java,我們需要複製你的應用程式。上面這行將從目標目錄複製任何jar檔案並將它們放在opt資料夾中

CMD $JAVA_HOME/bin/java $JAVA_OPTS -jar /opt/*.jar
最後,這告訴Docker在容器執行時執行哪個命令。這裡我們只執行java並允許在執行時傳遞JAVA_OPTS變數。
 

相關文章