容器中的 Spring Boot

工程 | Dave Syer | 2018 年 11 月 08 日 | ...

許多人使用容器來封裝他們的 Spring Boot 應用程式,而建立容器並非易事。這篇文章是為 Spring Boot 應用程式的開發人員所撰寫的,而且容器對開發人員來說不總是個好的抽象概念 - 它們迫使你學習並思考非常底層的問題 - 但有時你會被要求建立或使用容器,因此了解基本構建模組是值得的。在這裡,我們的目標是向您展示,當您面臨需要建立自己的容器的前景時,您可以做出的一些選擇。

我們假設您知道如何建立和建置基本的 Spring Boot 應用程式。 如果您不知道,請前往其中一個入門指南,例如建立REST 服務的指南。 從那裡複製程式碼並練習下面的一些想法。 還有一個關於Docker的入門指南,這也將是一個很好的起點,但它沒有涵蓋我們在這裡擁有的各種選擇,也沒有那麼詳細。

此部落格也是 spring.io 網站上的「主題」指南。 在此處造訪以取得更新:https://spring.dev.org.tw/guides/topicals/spring-boot-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-args=target/*.jar -t myorg/myapp .

對於 Gradle

$ docker build --build-args=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
...

請注意,基本映像是 openjdk:8-jdk-alpinealpine 映像比來自Dockerhub的標準 openjdk 函式庫映像小。 Java 11 尚未有官方的 alpine 映像(AdoptOpenJDK 曾經有,但它不再出現在他們的Dockerhub 頁面上)。

如果您想在映像中探索,您可以像這樣在其中開啟 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
/ #

到目前為止,docker 組態非常簡單,而且產生的映像效率不高。 docker 映像具有一個單一的檔案系統層,其中包含 fat jar,而且我們對應用程式程式碼所做的每次變更都會變更該層,該層可能會是 10MB 或更多(對於某些應用程式甚至可能高達 50MB)。 我們可以透過將 JAR 分割成多個層來改善這一點。

更好的 Dockerfile

Spring Boot fat jar 自然有「層」,因為 jar 本身是這樣封裝的。 如果我們首先解壓縮它,它將已經被分成外部和內部依賴關係。 要在 docker 建置中一步完成此操作,我們需要先解壓縮 jar。 例如(堅持使用 Maven,但 Gradle 版本非常相似)

$ mkdir target/dependency
$ (cd target/dependency; tar -zxf ../*.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"]

現在有 3 個層,所有應用程式資源都在後面的 2 個層中。 如果應用程式依賴關係沒有改變,那麼第一層(來自 BOOT-INF/lib)就不會改變,因此建置速度會更快,而且只要基本層已經被快取,容器在運行時的啟動速度也會更快。

我們使用了硬編碼的主應用程式類別 hello.Application。 這對於您的應用程式來說可能會有不同。 如果您願意,可以使用另一個 ARG 對其進行參數化。 您也可以將 Spring Boot fat JarLauncher 複製到映像中並使用它來運作應用程式 - 它會運作並且您不需要指定主類別,但在啟動時會稍微慢一些。

調整

如果您想盡快啟動您的應用程式(大多數人都這樣做),您可以考慮進行一些調整。 以下是一些想法

  • 使用 spring-context-indexer連結到文件)。 它不會為小型應用程式增加太多,但積少成多。

  • 如果可以的話,不要使用actuators

  • 使用 Spring Boot 2.1 和 Spring 5.1。

  • 使用 spring.config.location(命令行引數或系統屬性等)修正Spring Boot 組態檔案的位置。

  • 關閉 JMX - 您可能不需要在容器中使用它 - 使用 spring.jmx.enabled=false

  • 使用 -noverify 運作 JVM。 也可以考慮 -XX:TieredStopAtLevel=1(這將在稍後減慢 JIT 的速度,但會節省啟動時間)。

  • 對 Java 8 使用容器記憶體提示:-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap。 使用 Java 11,這預設是自動的。

您的應用程式在運行時可能不需要完整的 CPU,但它需要多個 CPU 才能盡快啟動(至少 2 個,4 個更好)。 如果您不介意啟動速度較慢,您可以將 CPU 限制在 4 個以下。

多階段建置

上面的 Dockerfile 假設 fat 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 並建置 fat jar,然後解壓縮它。 解壓縮也可以由 Maven 或 Gradle 完成(這是入門指南中採用的方法) - 實際上沒有太大區別,只是必須編輯建置組態並新增外掛程式。

請注意,原始程式碼已分成 4 個層。 後面的層包含建置組態和應用程式的原始程式碼,而前面的層包含建置系統本身(Maven 封裝)。 這是一個小的最佳化,而且這也意味著我們不必將 target 目錄複製到 docker 映像,即使是用於建置的臨時映像。

每次原始程式碼變更的建置都會很慢,因為必須在第一個 RUN 區段中重新建立 Maven 快取。 但是您有一個完全獨立的建置,只要他們有 docker,任何人都可以運行它來讓您的應用程式運作。 這在某些環境中非常有用,例如,當您需要與不了解 Java 的人分享您的程式碼時。

建置外掛程式

如果您不想在建置過程中直接呼叫 docker,Maven 和 Gradle 提供相當豐富的外掛程式集,可以為您完成這項工作。以下僅列出幾個。

Spotify Maven 外掛程式

Spotify Maven 外掛程式 是廣受歡迎的選擇。它要求應用程式開發人員編寫 Dockerfile,然後為您執行 docker,就像您在命令列上執行一樣。docker 映像檔標籤和其他內容有一些組態選項,但它將應用程式中的 docker 知識集中在 Dockerfile 中,這是很多人喜歡的方式。

對於非常基本的使用,它可以直接使用,無需額外的組態

$ mvn com.spotify:dockerfile-maven-plugin:build
...
[INFO] Building Docker context /home/dsyer/dev/demo/workspace/myapp
[INFO]
[INFO] Image will be built without a name
[INFO]
...
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 7.630 s
[INFO] Finished at: 2018-11-06T16:03:16+00:00
[INFO] Final Memory: 26M/595M
[INFO] ------------------------------------------------------------------------

這會建置一個匿名的 docker 映像檔。我們現在可以使用命令列上的 docker 為其加上標籤,或者使用 Maven 組態將其設定為 repository。範例(無需變更 pom.xml

$ mvn com.spotify:dockerfile-maven-plugin:build -Ddockerfile.repository=myorg/myapp

或在 pom.xml

pom.xml

<build>
    <plugins>
        <plugin>
            <groupId>com.spotify</groupId>
            <artifactId>dockerfile-maven-plugin</artifactId>
            <version>1.4.8</version>
            <configuration>
                <repository>myorg/${project.artifactId}</repository>
            </configuration>
        </plugin>
    </plugins>
</build>

Palantir Gradle 外掛程式

Palantir Gradle 外掛程式 可以與 Dockerfile 搭配使用,它也可以為您產生 Dockerfile,然後執行 docker,就像您在命令列上執行一樣。

首先,您需要將外掛程式匯入到您的 build.gradle

build.gradle

buildscript {
    ...
    dependencies {
        ...
        classpath('gradle.plugin.com.palantir.gradle.docker:gradle-docker:0.13.0')
    }
}

然後,您最終應用外掛程式並呼叫其任務

build.gradle

apply plugin: 'com.palantir.docker'

group = 'myorg'

bootJar {
    baseName = 'myapp'
    version =  '0.1.0'
}

task unpack(type: Copy) {
    dependsOn bootJar
    from(zipTree(tasks.bootJar.outputs.files.singleFile))
    into("build/dependency")
}
docker {
    name "${project.group}/${bootJar.baseName}"
    copySpec.from(tasks.unpack.outputs).into("dependency")
    buildArgs(['DEPENDENCY': "dependency"])
}

在這個範例中,我們選擇將 Spring Boot fat jar 解壓縮到 build 目錄中的特定位置,這是 docker 建置的根目錄。然後,上面多層(非多階段)Dockerfile 將會生效。

Jib Maven 和 Gradle 外掛程式

Google 有一個名為 Jib 的開放原始碼工具,它相對較新,但由於許多原因而非常有趣。最有趣的事情可能是您不需要 docker 才能執行它 - 它使用與從 docker build 獲得的相同標準輸出建置映像檔,但不使用 docker,除非您要求它這樣做 - 因此它可以在未安裝 docker 的環境中運作(在建置伺服器中並不少見)。您也不需要 Dockerfile(無論如何都會被忽略),或者 pom.xml 中的任何內容,才能在 Maven 中建置映像檔(Gradle 至少需要在 build.gradle 中安裝外掛程式)。

Jib 的另一個有趣功能是,它對圖層有自己的看法,並且它以與上面建立的多層 Dockerfile 略有不同的方式對其進行最佳化。就像在 fat jar 中一樣,Jib 將本機應用程式資源與相依性分開,但它更進一步,也將快照相依性放入單獨的圖層中,因為它們更容易變更。還有一些組態選項可以進一步自訂版面配置。

使用 Maven 的範例(無需變更 pom.xml

$ mvn com.google.cloud.tools:jib-maven-plugin:build -Dimage=myorg/myapp

若要執行上述命令,您需要具有在 myorg 儲存庫前置詞下推送到 Dockerhub 的權限。如果您已使用命令列上的 docker 進行驗證,這將從您的本機 ~/.docker 組態中運作。您也可以在您的 ~/.m2/settings.xml 中設定 Maven "server" 驗證(儲存庫的 id 很重要)

settings.xml

    <server>
      <id>registry.hub.docker.com</id>
      <username>myorg</username>
      <password>...</password>
    </server>

還有其他選項,例如,您可以使用 dockerBuild 目標而不是 build,針對 docker 精靈在本機建置(就像在命令列上執行 docker 一樣)。也支援其他容器登錄,並且對於每個容器登錄,您都需要透過 docker 或 Maven 設定來設定本機驗證。

gradle 外掛程式具有類似的功能,一旦您將其放入您的 build.gradle 中,例如

build.gradle

plugins {
  ...
  id 'com.google.cloud.tools.jib' version '0.9.11'
}

或在入門指南中使用的舊版樣式

build.gradle

buildscript {
    repositories {
      maven {
        url "https://plugins.gradle.org/m2/"
      }
      mavenCentral()
    }
    dependencies {
        classpath('org.springframework.boot:spring-boot-gradle-plugin:2.0.5.RELEASE')
        classpath('com.google.cloud.tools.jib:com.google.cloud.tools.jib.gradle.plugin:0.9.11')
    }
}

然後您可以使用以下方式建置映像檔

$ ./gradlew jib --image=myorg/myapp

與 Maven 建置一樣,如果您已使用命令列上的 docker 進行驗證,則映像檔推送將從您的本機 ~/.docker 組態中進行驗證。

持續整合

自動化是現今每個應用程式生命週期的一部分(或應該是)。人們用來進行自動化的工具通常非常擅長從原始碼中呼叫建置系統。因此,如果這讓您獲得 docker 映像檔,並且建置代理程式中的環境與開發人員自己的環境充分一致,那麼這可能就足夠了。驗證 docker 登錄檔可能是最大的挑戰,但所有自動化工具中都有一些功能可以協助您解決這個問題。

但是,有時最好將容器建立完全留給自動化層,在這種情況下,使用者的程式碼可能不需要受到污染。容器建立很棘手,開發人員有時並不在乎它。如果使用者程式碼更乾淨,則更有機會讓不同的工具「做正確的事情」,應用安全性修補程式、最佳化快取等等。自動化有多種選項,而且現今它們都會附帶一些與容器相關的功能。我們僅要查看幾個。

Concourse

Concourse 是一個基於管道的自動化平台,可用於 CI 和 CD。它在 Pivotal 內部被大量使用,並且專案的主要作者在那裡工作。Concourse 中的所有內容都是無狀態的,並且所有內容都在容器中執行,除了 CLI。由於執行容器是自動化管道的主要任務,因此容器建立受到良好支援。Docker 映像檔資源 負責使您建置的輸出狀態保持最新,如果它是容器映像檔。

以下是一個範例管道,它為上述範例建置 docker 映像檔,假設它位於 github 上的 myorg/myapp 中,並且在根目錄中有一個 Dockerfile,並且在 src/main/ci/build.yml 中有一個建置任務宣告

resources:
- name: myapp
  type: git
  source:
    uri: https://github.com/myorg/myapp.git
- name: myapp-image
  type: docker-image
  source:
    email: {{docker-hub-email}}
    username: {{docker-hub-username}}
    password: {{docker-hub-password}}
    repository: myorg/myapp

jobs:
- name: main
  plan:
  - task: build
    file: myapp/src/main/ci/build.yml
  - put: myapp-image
    params:
      build: myapp

管道的結構非常宣告式:您定義「資源」(可以是輸入或輸出或兩者),以及「作業」(使用資源並對資源應用動作)。如果任何輸入資源變更,則會觸發新的建置。如果在作業期間任何輸出資源變更,則會更新它。

管道可以在與應用程式原始碼不同的位置定義。並且對於一般的建置設定,任務宣告也可以集中或外部化。如果這是您習慣的方式,這允許在開發和自動化之間進行一些關注點分離。

Jenkins

Jenkins 是另一個熱門的自動化伺服器。它具有非常多的功能,但其中一個最接近此處其他自動化範例的功能是 pipeline 功能。這裡有一個 Jenkinsfile,它使用 Maven 建置一個 Spring Boot 專案,然後使用 Dockerfile 建置映像檔並將其推送至儲存庫。

Jenkinsfile

node {
    checkout scm
    sh './mvnw -B -DskipTests clean package'
    docker.build("myorg/myapp").push()
}

對於需要在建置伺服器中進行身份驗證的 (真實) Docker 儲存庫,您可以使用 docker.withCredentials(…​) 將憑證新增到上面的 docker 物件。

Buildpacks

Cloud Foundry 多年來一直使用容器在內部,並且將使用者程式碼轉換為容器的技術之一是 Build Packs,這個概念最初是從 Heroku 借來的。目前這一代的 buildpack (v2) 產生通用的二進位輸出,該輸出由平台組裝成一個容器。新一代的 buildpack (v3) 是 Heroku 和包括 Pivotal 在內的其他公司之間的合作,它直接且明確地建置容器映像檔。這對開發人員和運營人員非常有趣。開發人員不需要太關心如何建置容器的細節,但如果需要,他們可以輕鬆地建立一個。Buildpack 還具有許多用於快取建置結果和相依性的功能,因此通常 buildpack 的執行速度比原生 Docker 建置快得多。運營人員可以掃描容器以審核其內容並轉換它們以修補安全更新。您也可以在本地 (例如,在開發人員機器上或在 CI 服務中) 或在像 Cloud Foundry 這樣的平台上執行 buildpack。

buildpack 生命週期的輸出是一個容器映像檔,但您不需要 Docker 或 Dockerfile,因此它對 CI 和自動化非常友好。輸出映像檔中的檔案系統層由 buildpack 控制,通常會進行許多最佳化,而開發人員不必知道或關心它們。在底層(例如包含作業系統的基礎映像檔)和上層(包含中介軟體和特定於語言的相依性)之間也存在一個 應用程式二進位介面。這使得像 Cloud Foundry 這樣的平台可以在存在安全更新時修補底層,而不會影響應用程式的完整性和功能。

為了讓您了解 buildpack 的功能,這是一個使用命令行中的 Pack CLI 的範例 (它適用於我們在本文章中使用的範例應用程式,無需 Dockerfile 或任何特殊的建置組態)

$ pack build myorg/myapp --builder=nebhale/java-build --path=.
2018/11/07 09:54:48 Pulling builder image 'nebhale/java-build' (use --no-pull flag to skip this step)
2018/11/07 09:54:49 Selected run image 'packs/run' from stack 'io.buildpacks.stacks.bionic'
2018/11/07 09:54:49 Pulling run image 'packs/run' (use --no-pull flag to skip this step)
*** DETECTING:
2018/11/07 09:54:52 Group: Cloud Foundry OpenJDK Buildpack: pass | Cloud Foundry Build System Buildpack: pass | Cloud Foundry JVM Application Buildpack: pass
*** ANALYZING: Reading information from previous image for possible re-use
*** BUILDING:
-----> Cloud Foundry OpenJDK Buildpack 1.0.0-BUILD-SNAPSHOT
-----> OpenJDK JDK 1.8.192: Reusing cached dependency
-----> OpenJDK JRE 1.8.192: Reusing cached launch layer

-----> Cloud Foundry Build System Buildpack 1.0.0-BUILD-SNAPSHOT
-----> Using Maven wrapper
       Linking Maven Cache to /home/pack/.m2
-----> Building application
       Running /workspace/app/mvnw -Dmaven.test.skip=true package
...
---> Running in e6c4a94240c2
---> 4f3a96a4f38c
---> 4f3a96a4f38c
Successfully built 4f3a96a4f38c
Successfully tagged myorg/myapp:latest
$ docker run -p 8080:8080 myorg/myapp
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.0.5.RELEASE)

2018-11-07 09:41:06.390  INFO 1 --- [ main] hello.Application: Starting Application on 1989fb9a00a4 with PID 1 (/workspace/app/BOOT-INF/classes started by pack in /workspace/app)
...

--builder 是一個執行 buildpack 生命週期的 Docker 映像檔 - 通常它將是所有開發人員或單一平台上所有開發人員的共享資源。這個是由 Ben Hale 開發中的,他維護 Cloud Foundry 的舊版 buildpack,現在正在開發新一代。在這種情況下,輸出轉到本地 Docker daemon,但在自動化平台中,它可以是一個 Docker 註冊表。一旦 pack CLI 達到穩定版本,預設建置器可能會做同樣的事情。

Knative

容器和平台領域的另一個新專案是 Knative。Knative 做了很多事情,但如果您不熟悉它,您可以將其視為建構無伺服器平台的基石。它建立在 Kubernetes 之上,因此最終它消耗容器映像檔,並將它們轉變為平台上的應用程式或「服務」。但它的主要功能之一是能夠消耗原始碼並為您建置容器,使其對開發人員和運營人員更加友好。Knative Build 是執行此操作的元件,它本身是一個將使用者程式碼轉換為容器的靈活平台 - 您幾乎可以以任何您喜歡的方式執行此操作。提供了一些範本,其中包含常見的模式,例如 Maven 和 Gradle 建置,以及使用 Kaniko 的多階段 Docker 建置。還有一個使用 Buildpacks 的範本,這對我們來說非常有趣,因為 buildpack 一直對 Spring Boot 有很好的支援。Knative 上的 Buildpack 也是 RiffPivotal Function Service 將使用者函數轉換為運作中的無伺服器應用程式的明智選擇。

總結

本文介紹了許多用於建置 Spring Boot 應用程式的容器映像檔的選項。所有這些都是完全有效的選擇,現在由您來決定您需要哪一個。您的第一個問題應該是「我真的需要建置容器映像檔嗎?」如果答案是「是」,那麼您的選擇可能會受到效率和可快取性以及關注點分離的驅動。您是否希望使開發人員無需過多了解如何建立容器映像檔?您是否希望讓開發人員負責在需要修補作業系統和中介軟體漏洞時更新映像檔?或者,開發人員可能需要完全控制整個過程,並且他們擁有他們需要的所有工具和知識。

取得 Spring 電子報

隨時掌握 Spring 電子報的最新消息

訂閱

取得領先優勢

VMware 提供培訓和認證,以加速您的進度。

了解更多

取得支援

Tanzu Spring 在一個簡單的訂閱中提供對 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位檔。

了解更多

即將舉行的活動

查看 Spring 社群中所有即將舉行的活動。

查看全部