為何考慮採用Docker?
Docker是提供使用者構建映象的一種容器化技術,所構建的映象包含了主要的應用程式和執行應用所需的所有依賴項。該映象可在任何虛擬機器或物理機器上的Docker容器上執行。它的強大之處在於允許使用者在開發、測試、預生產和生產中運行同樣的映象,而不必擔心在每個環境中依賴項的安裝或配置。
採用Docker構建和執行應用以Java程式設計師的視角看,Docker的典型應用場景是在容器內執行應用。這固然不錯,但如果Docker能提供應用的構建是不是更好?本文中,我將演示如何在容器內用Docker來編排、構建和執行Spring Boot應用。請先按如下步驟建立一個Docker映象:
從源主機複製應用程式原始碼到映象的臨時構建目錄採用Maven完成應用的編譯和打包,生成可執行的JAR檔案採用JRE執行JAR檔案映象大小的提示關注所構建映象檔案的大小非常重要。較小的映象檔案具有更快的構建速度、下載速度和更低的儲存成本優勢。所以要儘可能地讓映象只包括所需的幾項元件即可。
採用較小的基本映象
同樣的道理,選用只包含必須功能的基礎映象檔案也是最佳的選擇。本文後續採用Alpine映象也是基於同樣考慮,Alpine是隻有5MB的超細Linux發行版。非常適合構建精細的映象。同時Alpine提供一個包管理器,讓使用者可以安裝任何需要的包。但由於Alpine的初始包非常小,所以安裝大量包的過程會有些麻煩。如果有看DockerHub的話,就會發現很多流行的映象都提供了Alpine版,可以直接使用。後續我們也將用到Alpine版本的Maven和Open JDK JRE映象。
拋棄不需要的內容
在稍後過程中所定義編譯、打包並執行的Spring Boot應用的映象。就是可部署執行的最終Docker映象,因此它只需要包含應用本身和執行時依賴項,能夠滿足在單個容器中構建和執行就可以了。也就是說它可以純粹就是可執行的JAR包和執行所需的Java JRE檔案,而無需包含Maven(包括本地Maven庫)或目標目錄的全部內容。那麼,使用者所要做的就是構建應用,然後從最終映象中剔除不需要的內容。這個正是多階段構建的作用所在。它允許使用者將Docker構建分解為不同的步驟,並在步驟之間複製特定的目標項,拋棄非必須的內容,從而實現拋棄構建工具本身和其他對應用沒有關聯的內容。
測試案例執行步驟專案構建非常簡單,舉個例子,我用一個類建立一個標準的Spring Boot應用,並在專案的根目錄中添加了一個Dockerfile。(使用者可在GitHub上獲取這個實驗的完整原始碼,同步實驗。)
主類的程式碼顯示如下,且沒有新增任何其他內容。接下來我將採用預設的執行器健康狀況端點來測試這個應用。
packagecom.blog.samples.docker;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplicationpublicclassApplication{publicstaticvoidmain(String[]args){SpringApplication.run(Application.class,args);}}定義Docker映象
如下內容是Dockerfile中定義的映象檔案,儘管內容不多,但包含了很多步的工作。我將在下面詳細解釋每一行。
FROMmaven:3.5.2-jdk-8-alpineASMAVEN_BUILDMAINTAINERBrianHannawayCOPYpom.xml/build/COPYsrc/build/src/WORKDIR/build/RUNmvnpackageFROMopenjdk:8-jre-alpineWORKDIR/appCOPY--from=MAVEN_BUILD/build/target/docker-boot-intro-0.1.0.jar/app/ENTRYPOINT["java","-jar","docker-boot-intro-0.1.0.jar"]
構建映象程式碼備註:FROM maven:3.5.2-jdk-8-alpine AS MAVEN_BUILD告知Docker採用Maven編譯器。maven:3.5.2-jdk-8-alpine構建第一步採用的基礎映象,Docker將首先在本地查詢映象,本地不存在後,將從DockerHub拉取。 Maven會在最後階段被剔除掉(後續COPY命令介紹)考慮下載快速和映象大小控制的原因,選擇Alpine版的Maven映象。MAINTAINER Brian Hannaway非必選項,但是為映像作者提供一個接觸點可提高可維護性。(本實驗應用驗證的點)COPY pom.xml /build/在映象中建立一個build目錄, 並拷入pom.xml檔案。COPY src /build/src/拷入src 目錄到映象中build目錄。WORKDIR /build/設定build 為工作目錄. 後續任何命令都在此目錄中執行。RUN mvn package執行mvn包來執行編譯和打包應用,生成成可執行的JAR檔案。在第一次構建映象時,Maven將從公共Maven庫拉取所有需要的依賴項,並將它們快取在映象的本地。後續的構建將使用這個快取版的映象層,這意味著依賴項將在本地引用,而不必再次從外部拉取。至此,已經完成了映象定義,只需等其構建成一個可執行的JAR檔案。這是多階段構建的第一部分。下一階段將獲取JAR並執行它。FROM openjdk:8-jre-alpine告知Docker多階段構建的下一步採用openjdk:8-jre-alpine的基礎映象。再次使用Java 8 JRE的Alpine版本,這一步的選擇其實比前面的Maven版本選擇更為重要,因為存在於最終版的映象只是openjdk:8-jre-alpine,因此如果要儘可能控制最終映象大小的話,選擇輕量級JRE映象就非常重要。WORKDIR /app告知Docker在映象內建立另一個/app工作目錄,後續任何命令都在此目錄中執行。COPY --from=MAVEN_BUILD /build/target/docker-boot-intro-0.1.0.jar /app/告知Docker從MAVEN_BUILD階段的/build/target目錄複製ocker-boot-intro-0.1.0.jar到/app目錄。如前文所述,多階段構建的優勢就是允許使用者將特定的內容從一個構建階段複製到另一個構建階段,並丟棄其他所有的內容。如果需要保留從MAVEN_BUILD階段開始的所有內容,那最終映象會包含Maven(包括Maven本地庫)工具,以及目標目錄中生成的所有類檔案。通過從MAVEN_BUILD階段選擇必須要的內容,那最終得到的映象會小很多。ENTRYPOINT ["java", "-jar", "app.jar"]告知Docker在容器執行本映象時,執行哪些命令。本部分用冒號進行多命令的隔離。本案例中,需要把執行JAR檔案複製到/app目錄執行。
完成Docker映象定義後,就可以著手構建。開啟包含Dockerfile(根目錄)的目錄。執行以下命令構建映象:
dockerimagebuild-tdocker-boot-intro
-t 引數為指定名稱和可選標籤。如果不指定標籤,Docker 會自動標記為最 latest。
$dockerimagebuild-tdocker-boot-intro.SendingbuildcontexttoDockerdaemon26.56MBStep1/10:FROMmaven:3.5.2-jdk-8-alpineASMAVEN_BUILD--->293423a981a7Step2/10:MAINTAINERBrianHannaway--->Usingcache--->db354a426bfdStep3/10:COPYpom.xml/build/--->Usingcache--->256340699bc3Step4/10:COPYsrc/build/src/--->Usingcache--->65eb0f98bb79Step5/10:WORKDIR/build/--->Usingcache--->b16b294b6b74Step6/10:RUNmvnpackage--->Usingcache--->c48659e0197eStep7/10:FROMopenjdk:8-jre-alpine--->f7a292bbb70cStep8/10:WORKDIR/app--->Usingcache--->1723d5b9c22fStep9/10:COPY--from=MAVEN_BUILD/build/target/docker-boot-intro-0.1.0.jar/app/--->Usingcache--->d0e2f8fbe5c9Step10/10:ENTRYPOINT["java","-jar","docker-boot-intro-0.1.0.jar"]--->Usingcache--->f265acb14147Successfullybuiltf265acb14147Successfullytaggeddocker-boot-intro:latestSECURITYWARNING:YouarebuildingaDockerimagefromWindowsagainstanon-WindowsDockerhost.Allfilesanddirectoriesaddedtobuildcontextwillhave'-rwxr-xr-x'permissions.Itisrecommendedtodoublecheckandresetpermissionsforsensitivefilesanddirectories.BriansComputer@DESKTOP-077OUJ8MINGW64/c/dev/docker-boot-intro(master)
執行構建時,Docker將逐條執行Docker檔案中的每個命令。為每個步驟建立一個帶有唯一ID的層。例如,步驟1建立的層的ID為293423a981a7。第一次構建影象時,Docker將從DockerHub獲取它需要的任何外部影象,然後在此之上開始構建新的層。這會使得第一次構建速度非常慢。在構建過程中,Docker在嘗試構建層之前會檢查快取,看看是否已經有所構建層的快取版本。如果該層的快取版本可用,Docker將直接使用它而不是從頭開始構建。這意味著一旦構建了一個映象層,後續的構建就是重用,速度會快很多。你可以在上面的構建輸出中通過Docker快取輸出的hash值看到使用了快取層。以上面第6步所發生的為例:作為RUN mvn包命令的一部分,Docker將從公共Maven庫獲取所有POM依賴項,構建成一個可執行JAR,並將所有這些內容儲存在ID為c48659e0197e的層中。下一次構建這個映象時,Maven依賴項和應用程式JAR將從快取層中取出,而不必再次下載和構建。
映象大小執行docker image ls命令將羅列出所有的本地映象。可發現docker-boot-intro 映象大小為105 MB。
BriansComputer@DESKTOP-077OUJ8MINGW64/c/dev/docker-boot-intro(master)$dockerimagelsREPOSITORYTAGIMAGEIDCREATEDSIZEdocker-boot-introlatest823730301d6015minutesago105MB<none><none>853d42b823c315minutesago136MB<none><none>39ac5e9e956219minutesago105MB<none><none>dfda2356bd3619minutesago136MBBriansComputer@DESKTOP-077OUJ8MINGW64/c/dev/docker-boot-intro(master)
我在前文中提到過儘可能保持映象大小的最佳實踐,接下來讓我們細探一下docker-boot-intro映象的105MB由什麼組成的。執行如下命令:
dockerimagehistoryboot-docker-intro
將看到映象中各個層的內容情況。
BriansComputer@DESKTOP-077OUJ8MINGW64/c/dev/docker-boot-intro/target(master)$dockerimagehistorydocker-boot-introIMAGECREATEDCREATEDBYSIZECOMMENT823730301d6019minutesago/bin/sh-c#(nop)ENTRYPOINT["java""-jar"...0B7e43d899f02f19minutesago/bin/sh-c#(nop)COPYfile:05f3666306f8c7af...20.1MB1723d5b9c22f6daysago/bin/sh-c#(nop)WORKDIR/app0Bf7a292bbb70c4monthsago/bin/sh-cset-x&&apkadd--no-cacheo...79.4MB<missing>4monthsago/bin/sh-c#(nop)ENVJAVA_ALPINE_VERSION=8...0B<missing>4monthsago/bin/sh-c#(nop)ENVJAVA_VERSION=8u2120B<missing>4monthsago/bin/sh-c#(nop)ENVPATH=/usr/local/sbin:...0B<missing>4monthsago/bin/sh-c#(nop)ENVJAVA_HOME=/usr/lib/jv...0B<missing>4monthsago/bin/sh-c{echo'#!/bin/sh';echo'set...87B<missing>4monthsago/bin/sh-c#(nop)ENVLANG=C.UTF-80B<missing>4monthsago/bin/sh-c#(nop)CMD["/bin/sh"]0B<missing>4monthsago/bin/sh-c#(nop)ADDfile:a86aea1f3a7d68f6a...5.53MBBriansComputer@DESKTOP-077OUJ8MINGW64/c/dev/docker-boot-intro/target(master)
如上所顯示5.53 MB的Alpine基礎映象處於第一層。在之上的幾層配置了一系列的環境變數,然後是大小為79.4 MB的JRE檔案。最後的3層是我們在Dockerfile中定義的層,幷包含了20.1 MB的應用JAR。可以發現這個映象只包括了執行應用所必須的元件,是一個非常不錯的輕量級映象。
執行容器映象構建好後,可以使用以下命令執行一個容器:
dockercontainerrun-p8080:8080docker-boot-intro
run命令包括一個可選的-p引數,作用是允許使用者將容器應用的埠對映到主機的埠。熟悉Spring Boot的人都知道,應用程式的預設啟動埠就是8080。執行一個容器時,Docker將執行可執行JAR檔案來啟動應用,使用容器的8080埠。但如果要訪問容器中的應用,需要通過主機的埠訪問,通過埠對映去到容器埠。-p 8080:8080引數就是將容器埠8080對映到主機埠8080。如果沒有異常的話,應該可以看到應用程式在埠8080成功啟動的資訊。
BriansComputer@DESKTOP-077OUJ8MINGW64/c/dev/docker-boot-intro/target(master)$dockercontainerrun-p8080:8080docker-boot-intro._________/\\\\/___'_____(_)______\\\\\\\\(()\\___|'_|'_||'_\\/_`|\\\\\\\\\\\\/___)||_)|||||||(_||))))'|____|.__|_||_|_||_\\__,|////=========|_|==============|___/=/_/_/_/::SpringBoot::(v2.1.7.RELEASE)5436[main]INFOcom.blog.samples.docker.Application-StartingApplicationv0.1.0on934a1d731576withPID1(/app/docker-boot-intro-0.1.0.jarstartedbyrootin/app)5466[main]INFOcom.blog.samples.docker.Application-Noactiveprofileset,fallingbacktodefaultprofiles:default16585[main]INFOo.s.b.w.e.tomcat.TomcatWebServer-Tomcatinitializedwithport(s):8080(http)16742[main]INFOo.a.coyote.http11.Http11NioProtocol-InitializingProtocolHandler["http-nio-8080"]16886[main]INFOo.a.catalina.core.StandardService-Startingservice[Tomcat]16892[main]INFOo.a.catalina.core.StandardEngine-StartingServletengine:[ApacheTomcat/9.0.22]17622[main]INFOo.a.c.c.C.[Tomcat].[localhost].[/]-InitializingSpringembeddedWebApplicationContext17628[main]INFOo.s.web.context.ContextLoader-RootWebApplicationContext:initializationcompletedin11614ms21399[main]INFOo.s.s.c.ThreadPoolTaskExecutor-InitializingExecutorService'applicationTaskExecutor'23347[main]INFOo.s.b.a.e.web.EndpointLinksResolver-Exposing2endpoint(s)beneathbasepath'/actuator'23695[main]INFOo.a.coyote.http11.Http11NioProtocol-StartingProtocolHandler["http-nio-8080"]23791[main]INFOo.s.b.w.e.tomcat.TomcatWebServer-Tomcatstartedonport(s):8080(http)withcontextpath''23801[main]INFOcom.blog.samples.docker.Application-StartedApplicationin21.831seconds(JVMrunningfor25.901)應用測試
如果看到類似於上面顯示的資訊輸出,那表示容器已經順利啟動。接下來就可以測試應用。如果你在Windows或Mac上執行Docker,需要使用的工具是一個Linux虛擬機器Docker Toolbox。需要通過執行docker-machine ip命令可以獲得Linux VM的IP地址。本案例中的Linux VM IP是192.168.99.100。
BriansComputer@DESKTOP-077OUJ8MINGW64/c/dev/docker-boot-intro(master)$docker-machineip192.168.99.100
獲得IP後,可以使用cURL命令cURL 192.168.99.100:8080/actuator/health來呼叫應用的健康檢查點來測試應用情況。如果應用程式啟動並執行正常,即可獲得HTTP 200的響應,響應內容為{“status”:“up”}。
BriansComputer@DESKTOP-077OUJ8MINGW64/c/dev/docker-boot-intro(master)$curl192.168.99.100:8080/actuator/health%Total%Received%XferdAverageSpeedTimeTimeTimeCurrentDloadUploadTotalSpentLeftSpeed10015015009370--:--:----:--:----:--:--937{"status":"UP"本方法的侷限性
我在前文提到過,可以重用Docker快取層以減少構建時間。雖然這是事實,但是在構建Java應用時需要考慮存在的例外。每當對Java原始碼或POM檔案進行更改後,Docker將會發現變更差異,從而忽略快取的副本層,重新構建所需的層。這是正常的,但問題是這個變化會導致快取中的Maven依賴項丟失。因此,當使用mvn包命令重新構建這個層時,所有Maven依賴項將再次從遠端庫中拉取一次,導致顯著減慢了構建的速度,成為開發過程中真正的痛點。而且這個問題在構建沒有Docker的Java應用程式時完全不存在,僅僅發生在使用Docker構建應用層時發生。
解決方案是什麼?目前解決這個問題的方法是使用主機上的本地Maven儲存庫作為Maven依賴項的源。通過卷告訴Docker去訪問主機本地的Maven庫,而非從公共庫中拉取依賴項。這種方法可以解決這個問題。但也是有利有弊。從好的方面看,你使用的是主機快取的Maven依賴項,可以在更改原始碼後,快速重新構建,節省了構建時間。但不利的方面是Docker映象的管理因此而失去了一些自主性。使用Docker的主要初衷之一就是不必擔心在其執行的環境中的軟體配置。理想情況下,Docker映象應該是自我構建且擁有構建和執行所需的一切元素,而不必存在主機依賴。而這個方法恰好違背了這個初衷,讓Docker構建失去了部分自主性。在下一篇文章中,我們將介紹Docker卷,並展示如何使用它們訪問主機上的Maven庫。
結束語在本文中,我們定義了一個Docker映象來構建和執行一個Spring Boot應用程式。我們討論了讓映象保持儘可能小的重要性,可以通過使用超級小的Alpine基礎映象和在多階段構建過程中進行內容剔除的方式來實現。我們還討論了使用Docker構建Java應用程式的侷限性和可能的解決方案。使用者可以從GitHub獲取文章中的測試完整原始碼。