「Spring」Boot Docker 認證指南(上)

語言: CN / TW / HK

許多人使用容器來包裝他們的 Spring Boot 應用程式,而構建容器並不是一件簡單的事情。這是針對 Spring Boot 應用程式開發人員的指南,容器對於開發人員來說並不總是一個好的抽象。它們迫使你去了解和思考低層次的問題。但是,有時可能會要求您建立或使用容器,因此瞭解構建塊是值得的。在本指南中,我們旨在向您展示如果您面臨需要建立自己的容器的前景,您可以做出的一些選擇。

我們假設您知道如何建立和構建基本的 Spring Boot 應用程式。如果沒有,請轉到入門指南之一 ——例如,關於構建REST 服務的指南。從那裡複製程式碼並練習本指南中包含的一些想法。

還有一個關於Docker的入門指南,這也是一個很好的起點,但它沒有涵蓋我們在此處介紹的選擇範圍或詳細介紹它們。

一個基本的 Dockerfile

Spring Boot 應用程式很容易轉換為可執行的 JAR 檔案。所有的入門指南都是這樣做的,你從Spring Initializr下載的每個應用程式都有一個構建步驟來建立一個可執行的 JAR。使用 Maven,你執行 ./mvnw install ,使用 Gradle,你執行 ./gradlew build 。執行該 JAR 的基本 Dockerfile 將如下所示,位於專案的頂層:

Dockerfile:

FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG JAR_FILE
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]複製

JAR_FILE 您可以作為命令的一部分傳入 docker (Maven 和 Gradle 不同)。對於 Maven,以下命令有效:

docker build --build-arg JAR_FILE=target/*.jar -t myorg/myapp .複製

對於 Gradle,以下命令有效:

docker build --build-arg JAR_FILE=build/libs/*.jar -t myorg/myapp .複製

一旦你選擇了一個構建系統,你就不需要 ARG . 您可以對 JAR 位置進行硬編碼。對於 Maven,如下所示:

Dockerfile:

FROM openjdk:8-jdk-alpine
VOLUME /tmp
COPY target/*.jar app.jar
ENTRYPOINT ["java","-jar","/app.jar"]複製

然後我們可以使用以下命令構建映象:

docker build -t myorg/myapp .複製

然後我們可以通過執行以下命令來執行它:

docker run -p 8080:8080 myorg/myapp複製

輸出類似於以下示例輸出:

.   ____          _            __ _ _
/\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/  ___)| |_)| | | | | || (_| |  ) ) ) )
'  |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.0.2.RELEASE)

Nov 06, 2018 2:45:16 PM org.springframework.boot.StartupInfoLogger logStarting
INFO: Starting Application v0.1.0 on b8469cdc9b87 with PID 1 (/app.jar started by root in /)
Nov 06, 2018 2:45:16 PM org.springframework.boot.SpringApplication logStartupProfileInfo
...複製

如果你想在映象內部四處尋找,你可以通過執行以下命令在其中開啟一個 shell(注意基礎映象沒有 bash ):

docker run -ti --entrypoint /bin/sh myorg/myapp複製

輸出類似於以下示例輸出:

/ # ls
app.jar  dev      home     media    proc     run      srv      tmp      var
bin      etc      lib      mnt      root     sbin     sys      usr
/ #

我們在示例中使用的 alpine 基礎容器沒有bash,所以這是一個ashshell。它具有一些但不是全部的特性bash。

如果你有一個正在執行的容器並且你想檢視它,你可以通過執行 docker exec

docker run --name myapp -ti --entrypoint /bin/sh myorg/myapp
docker exec -ti myapp /bin/sh
/ #複製

傳遞給命令 myapp 的位置在哪裡。如果您沒有使用,docker 會分配一個助記名稱,您可以從. 您還可以使用容器的 SHA 識別符號而不是名稱。SHA 識別符號在輸出中也可見。 --namedocker run--namedocker psdocker ps

入口點

使用Dockerfile的exec 形式 ENTRYPOINT ,以便沒有外殼包裝 Java 程序。優點是java程序響應 KILL 傳送到容器的訊號。實際上,這意味著(例如)如果您 docker run 在本地使用影象,則可以使用 CTRL-C . 如果命令列有點長,您可以 COPY 在執行之前將其提取到 shell 指令碼中並放入映像中。以下示例顯示瞭如何執行此操作:

Dockerfile:

FROM openjdk:8-jdk-alpine
VOLUME /tmp
COPY run.sh .
COPY target/*.jar app.jar
ENTRYPOINT ["run.sh"]複製

請記住使用 exec java … 啟動 java 程序(以便它可以處理 KILL 訊號):

run.sh:

#!/bin/sh
exec java -jar /app.jar複製

入口點的另一個有趣方面是您是否可以在執行時將環境變數注入 Java 程序。例如,假設您想要在執行時新增 Java 命令列選項。您可以嘗試這樣做:

Dockerfile:

FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","${JAVA_OPTS}","-jar","/app.jar"]複製

然後您可以嘗試以下命令:

docker build -t myorg/myapp .
docker run -p 9000:9000 -e JAVA_OPTS=-Dserver.port=9000 myorg/myapp複製

這失敗了,因為 ${} 替換需要一個外殼。exec 表單不使用 shell 來啟動程序,因此不應用選項。您可以通過將入口點移動到指令碼(如 run.sh 前面顯示的示例)或在入口點顯式建立 shell 來解決此問題。以下示例顯示瞭如何在入口點中建立 shell:

Dockerfile:

FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"]複製

然後,您可以通過執行以下命令來啟動此應用程式:

docker run -p 8080:8080 -e "JAVA_OPTS=-Ddebug -Xmx128m" myorg/myapp複製

該命令產生類似於以下的輸出:

.   ____          _            __ _ _
/\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/  ___)| |_)| | | | | || (_| |  ) ) ) )
'  |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.2.0.RELEASE)
...
2019-10-29 09:12:12.169 DEBUG 1 --- [           main] ConditionEvaluationReportLoggingListener :
============================
CONDITIONS EVALUATION REPORT
============================
...複製

(前面的輸出顯示了 Spring Boot DEBUG 生成的完整輸出的一部分。) -Ddebug。

將 an ENTRYPOINT 與顯式 shell 一起使用(如前面的示例所做的那樣)意味著您可以將環境變數傳遞給 Java 命令。但是,到目前為止,您還不能為 Spring Boot 應用程式提供命令列引數。以下命令不會在埠 9000 上執行應用程式:

docker run -p 9000:9000 myorg/myapp --server.port=9000複製

該命令產生以下輸出,將埠顯示為 8080 而不是 9000:

.   ____          _            __ _ _
/\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/  ___)| |_)| | | | | || (_| |  ) ) ) )
'  |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.2.0.RELEASE)
...
2019-10-29 09:20:19.718  INFO 1 --- [           main] o.s.b.web.embedded.netty.NettyWebServer  : Netty started on port(s): 8080複製

它不起作用,因為 docker 命令(該 --server.port=9000 部分)被傳遞到入口點 (  sh ),而不是它啟動的 Java 程序。要解決此問題,您需要將命令列從以下新增 CMDENTRYPOINT

Dockerfile:

FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar ${0} ${@}"]複製

然後您可以執行相同的命令並將埠設定為 9000:

$ docker run -p 9000:9000 myorg/myapp --server.port=9000複製

如以下輸出示例所示,埠確實設定為 9000:

.   ____          _            __ _ _
/\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/  ___)| |_)| | | | | || (_| |  ) ) ) )
'  |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.2.0.RELEASE)
...
2019-10-29 09:30:19.751  INFO 1 --- [           main] o.s.b.web.embedded.netty.NettyWebServer  : Netty started on port(s): 9000複製

注意 ${0} “命令”(在這種情況下是第一個程式引數)和 ${@} “命令引數”(程式引數的其餘部分)的使用。如果您使用指令碼作為入口點,那麼您不需要 ${0}/app/run.sh 在前面的示例中)。以下列表顯示了指令碼檔案中的正確命令:

run.sh:

#!/bin/sh
exec java ${JAVA_OPTS} -jar /app.jar ${@}複製

docker配置到現在都非常簡單,生成的映象效率不是很高。docker 映象有一個檔案系統層,其中包含 fat JAR,我們對應用程式程式碼所做的每一次更改都會更改該層,這可能是 10MB 或更多(對於某些應用程式甚至高達 50MB)。我們可以通過將 JAR 拆分為多個層來改進這一點。

較小的影象

請注意,前面示例中的基本映像是 openjdk:8-jdk-alpine . 這些 alpine 影象小於Dockerhub openjdk 的標準庫影象。您還可以通過使用標籤而不是. 並非所有應用程式都使用 JRE(與 JDK 相對),但大多數應用程式都可以。一些組織強制執行一個規則,即每個應用程式都必須使用 JRE,因為存在濫用某些 JDK 功能(例如編譯)的風險。 jrejdk

另一個可以讓您獲得更小的映像的技巧是使用JLink,它與 OpenJDK 11 捆綁在一起。JLink 允許您從完整 JDK 中的模組子集構建自定義 JRE 分發,因此您不需要 JRE 或 JDK基礎影象。原則上,這將使您獲得比使用 openjdk 官方 docker 影象更小的總影象大小。在實踐中,您(還)不能將 alpine 基礎映象與 JDK 11 一起使用,因此您對基礎映象的選擇是有限的,並且可能會導致最終映象的大小更大。此外,您自己的基本映像中的自定義 JRE 不能在其他應用程式之間共享,因為它們需要不同的自定義。因此,您的所有應用程式可能都有較小的影象,但它們仍然需要更長的時間才能啟動,因為它們沒有從快取 JRE 層中受益。

最後一點突出了影象構建者的一個非常重要的問題:目標不一定總是儘可能地構建最小的影象。較小的影象通常是一個好主意,因為它們需要更少的時間來上傳和下載,但前提是它們中的所有圖層都沒有被快取。如今,影象註冊非常複雜,您很容易通過嘗試巧妙地構建影象而失去這些功能的好處。如果您使用通用基礎層,影象的總大小就不再那麼重要了,而且隨著註冊中心和平臺的發展,它可能變得更不重要。話雖如此,嘗試優化應用程式映像中的層仍然很重要且有用。然而,

更好的 Dockerfile

由於 JAR 本身的打包方式,Spring Boot fat JAR 自然有“層”。如果我們先解包,它已經分為外部依賴和內部依賴。要在 docker 構建中一步完成此操作,我們需要先解壓縮 JAR。以下命令(堅持使用 Maven,但 Gradle 版本非常相似)解壓縮 Spring Boot fat JAR:

mkdir target/dependency
(cd target/dependency; jar -xf ../*.jar)
docker build -t myorg/myapp .複製

然後我們可以使用下面的 Dockerfile

Dockerfile:

FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG DEPENDENCY=target/dependency
COPY ${DEPENDENCY}/BOOT-INF/lib /app/lib
COPY ${DEPENDENCY}/META-INF /app/META-INF
COPY ${DEPENDENCY}/BOOT-INF/classes /app
ENTRYPOINT ["java","-cp","app:app/lib/*","hello.Application"]複製

現在有三層,所有應用程式資源都在後面兩層。如果應用程式依賴沒有改變,第一層(from  BOOT-INF/lib )不需要改變,所以構建更快,並且容器在執行時的啟動也更快,只要基礎層已經被快取。

我們使用了一個硬編碼的主應用程式類:hello.Application. 這對於您的應用程式可能有所不同。如果你願意,你可以用另一個引數化它ARG。您還可以將 Spring Boot fat 複製JarLauncher到映像中並使用它來執行應用程式。它可以工作,您不需要指定主類,但啟動時會慢一些。

Spring Boot 層索引

從 Spring Boot 2.3.0 開始,使用 Spring Boot Maven 或 Gradle 外掛構建的 JAR 檔案在 JAR 檔案中包含層資訊。該層資訊根據應用程式構建之間更改的可能性來分離應用程式的各個部分。這可以用來使 Docker 映象層更加高效。

層資訊可用於將 JAR 內容提取到每個層的目錄中:

mkdir target/extracted
java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted
docker build -t myorg/myapp .複製

然後我們可以使用以下內容 Dockerfile

Dockerfile:

FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG EXTRACTED=/workspace/app/target/extracted
COPY ${EXTRACTED}/dependencies/ ./
COPY ${EXTRACTED}/spring-boot-loader/ ./
COPY ${EXTRACTED}/snapshot-dependencies/ ./
COPY ${EXTRACTED}/application/ ./
ENTRYPOINT ["java","org.springframework.boot.loader.JarLauncher"]

Spring Boot fatJarLauncher是從 JAR 中提取到映象中的,因此它可以用於啟動應用程式,而無需對主應用程式類進行硬編碼。

有關使用分層功能的更多資訊,請參閱Spring Boot 文件。

調整

如果您想盡快啟動您的應用程式(大多數人都這樣做),您可能會考慮一些調整:

  • 使用 spring-context-indexer (連結到文件)。它不會為小型應用程式增加太多,但每一點都有幫助。
  • 如果您負擔得起,請不要使用執行器。
  • 使用 Spring Boot 2.1(或更高版本)和 Spring 5.1(或更高版本)。
  • 使用(通過命令列引數、系統屬性或其他方法)修復Spring Boot 配置檔案的位置。 spring.config.location
  • 通過設定來關閉 JMX(您可能不需要在容器中使用它) spring.jmx.enabled=false
  • 使用 -noverify . 還要考慮 -XX:TieredStopAtLevel=1 (這會在以後減慢 JIT 但會縮短啟動時間)。
  • 使用 Java 8 的容器記憶體提示: -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap . 在 Java 11 中,預設情況下這是自動的。

您的應用程式在執行時可能不需要完整的 CPU,但它確實需要多個 CPU 才能儘快啟動(至少兩個,四個更好)。如果您不介意啟動速度較慢,則可以將 CPU 限制在四個以下。如果您被迫從少於四個 CPU 開始,設定 可能會有所幫助 -Dspring.backgroundpreinitializer.ignore=true ,因為它可以防止 Spring Boot 建立一個它可能無法使用的新執行緒(這適用於 Spring Boot 2.1.0 及更高版本)。

多階段構建

A Better Dockerfile中 Dockerfile 所示的假設假設胖 JAR 已經在命令列上構建。您還可以通過使用多階段構建並將結果從一個影象複製到另一個影象來在 docker 中執行該步驟。以下示例通過使用 Maven 來實現:

Dockerfile:

FROM openjdk:8-jdk-alpine as build
WORKDIR /workspace/app
COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
COPY src src
RUN ./mvnw install -DskipTests
RUN mkdir -p target/dependency && (cd target/dependency; jar -xf ../*.jar)
FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG DEPENDENCY=/workspace/app/target/dependency
COPY --from=build ${DEPENDENCY}/BOOT-INF/lib /app/lib
COPY --from=build ${DEPENDENCY}/META-INF /app/META-INF
COPY --from=build ${DEPENDENCY}/BOOT-INF/classes /app
ENTRYPOINT ["java","-cp","app:app/lib/*","hello.Application"]複製

第一個影象標記為 build ,它用於執行 Maven、構建胖 JAR 並解壓縮它。解包也可以由 Maven 或 Gradle 完成(這是入門指南中採用的方法)。沒有太大區別,只是必須編輯構建配置並新增外掛。

請注意,原始碼已分為四層。後面的層包含構建配置和應用程式的原始碼,前面的層包含構建系統本身(Maven 包裝器)。這是一個小的優化,也意味著我們不必將 target 目錄複製到 docker 映象,即使是用於構建的臨時映象。

RUN 每個原始碼更改的構建都很慢,因為必須在第一部分重新建立 Maven 快取。但是你有一個完全獨立的構建,只要他們有 docker,任何人都可以執行它來執行你的應用程式。這在某些環境中可能非常有用——例如,您需要與不瞭解 Java 的人共享您的程式碼。

實驗功能

Docker 18.06 帶有一些“實驗性”特性,包括快取構建依賴項的方法。要開啟它們,您需要在守護程序 (  dockerd ) 中有一個標誌,並在執行客戶端時需要一個環境變數。然後你可以新增一個“神奇”的第一行到你的 Dockerfile

Dockerfile:

# syntax=docker/dockerfile:experimental複製

然後該 RUN 指令接受一個新標誌: --mount . 以下清單顯示了一個完整示例:

Dockerfile:

# syntax=docker/dockerfile:experimental
FROM openjdk:8-jdk-alpine as build
WORKDIR /workspace/app
COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
COPY src src
RUN --mount=type=cache,target=/root/.m2 ./mvnw install -DskipTests
RUN mkdir -p target/dependency && (cd target/dependency; jar -xf ../*.jar)
FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG DEPENDENCY=/workspace/app/target/dependency
COPY --from=build ${DEPENDENCY}/BOOT-INF/lib /app/lib
COPY --from=build ${DEPENDENCY}/META-INF /app/META-INF
COPY --from=build ${DEPENDENCY}/BOOT-INF/classes /app
ENTRYPOINT ["java","-cp","app:app/lib/*","hello.Application"]複製

然後你可以執行它:

DOCKER_BUILDKIT=1 docker build -t myorg/myapp .複製

以下清單顯示了示例輸出:

...
 => /bin/sh -c ./mvnw install -DskipTests              5.7s
 => exporting to image                                 0.0s
 => => exporting layers                                0.0s
 => => writing image sha256:3defa...
 => => naming to docker.io/myorg/myapp複製

使用實驗性功能,您會在控制檯上獲得不同的輸出,但您可以看到,如果快取是熱的,現在 Maven 構建只需幾秒鐘而不是幾分鐘。

這個 Dockerfile 配置的 Gradle 版本非常相似:

Dockerfile:

# syntax=docker/dockerfile:experimental
FROM openjdk:8-jdk-alpine AS build
WORKDIR /workspace/app
COPY . /workspace/app
RUN --mount=type=cache,target=/root/.gradle ./gradlew clean build
RUN mkdir -p build/dependency && (cd build/dependency; jar -xf ../libs/*.jar)
FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG DEPENDENCY=/workspace/app/build/dependency
COPY --from=build ${DEPENDENCY}/BOOT-INF/lib /app/lib
COPY --from=build ${DEPENDENCY}/META-INF /app/META-INF
COPY --from=build ${DEPENDENCY}/BOOT-INF/classes /app
ENTRYPOINT ["java","-cp","app:app/lib/*","hello.Application"]

雖然這些功能處於實驗階段,但開啟和關閉 buildkit 的選項取決於docker您使用的版本。檢查您擁有的版本的文件(前面顯示的示例對於docker18.0.6 是正確的)。

安全方面

就像在經典 VM 部署中一樣,程序不應以 root 許可權執行。相反,映像應包含執行應用程式的非 root 使用者。

在 a Dockerfile 中,您可以通過新增另一個新增(系統)使用者和組並將其設定為當前使用者(而不是預設的 root)的層來實現此目的:

Dockerfile:

FROM openjdk:8-jdk-alpine
RUN addgroup -S demo && adduser -S demo -G demo
USER demo
...複製

如果有人設法突破您的應用程式並在容器內執行系統命令,這種預防措施會限制他們的能力(遵循最小許可權原則)。

一些進一步的Dockerfile命令只能以 root 身份執行,因此您可能必須將 USER 命令進一步向下移動(例如,如果您計劃在容器中安裝更多包,它只能以 root 身份執行)。

對於其他方法,不使用 aDockerfile可能更適合。例如,在後面描述的 buildpack 方法中,大多數實現預設使用非 root 使用者。

另一個考慮因素是大多數應用程式在執行時可能不需要完整的 JDK,因此一旦我們進行了多階段構建,我們就可以安全地切換到 JRE 基礎映像。因此,在前面顯示的多階段構建中,我們可以將其用於最終的可執行映像:

Dockerfile:

FROM openjdk:8-jre-alpine
...複製

如前所述,這也節省了映像中的一些空間,這些空間將被執行時不需要的工具佔用。