容器中的 Spring Boot

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

许多人正在使用容器来封装他们的 Spring Boot 应用程序,而构建容器并非易事。这篇文章是为 Spring Boot 应用程序的开发者准备的。容器并非总是对开发者来说是很好的抽象——它们迫使你学习和考虑非常底层的细节——但你偶尔会被要求创建或使用容器,因此了解其构建模块是值得的。在这里,我们旨在向你展示一些在需要创建自己的容器时可以做出的选择。

我们将假设您知道如何创建和构建一个基本的 Spring Boot 应用程序。如果您不知道,请参阅其中一个 入门指南,例如关于构建 REST 服务 的指南。从那里复制代码,并尝试一些下面的想法。还有一个关于 Docker 的入门指南,这也是一个很好的起点,但它不像我们在这里所介绍的这样涵盖了多种选择,或者没有那么详细。

这篇博文也是 spring.io 网站上的一个“专题”指南。在此处访问以获取更新:https://springframework.org.cn/guides/topicals/spring-boot-docker/

一个基本的 Dockerfile

Spring Boot 应用程序很容易转换成一个可执行的 JAR 文件。所有 入门指南 都会这样做,而且您从 Spring Initializr 下载的每个应用程序都会有一个构建步骤来创建一个可执行的 JAR。使用 Maven,您执行 ./mvnw install,使用 Gradle,您执行 ./gradlew build。一个基本的 Dockerfile 来运行该 JAR 将会是这样的,位于您项目的顶层目录:

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 由于其打包方式,自然具有“层”。如果我们先将其解压,它将已经分成外部和内部依赖。要在 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 个层,应用程序资源都在后两个层中。如果应用程序依赖项没有改变,那么第一层(来自 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 wrapper)。这是一个小的优化,并且意味着我们不必将 target 目录复制到 Docker 镜像,即使是用于构建的临时镜像。

源代码发生变化的每一次构建都会很慢,因为 Maven 缓存必须在第一个 RUN 部分重新创建。但是,您拥有一个完全独立的构建,任何人都可以运行它来运行您的应用程序,只要他们有 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"])
}

在这个例子中,我们选择在 build 目录下的特定位置解压 Spring Boot fat JAR,该目录是 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>

还有其他选项,例如,您可以本地构建到 Docker daemon(就像在命令行上运行 docker 一样),使用 dockerBuild 目标而不是 build。其他容器注册表也得到支持,对于每个注册表,您都需要通过 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 Image Resource 负责保持您的构建输出状态的最新。

下面是一个构建上述示例 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 借来的。当前一代的 buildpacks (v2) 生成通用的二进制输出,然后由平台将其组装成容器。 新一代 buildpacks (v3) 是 Heroku 和包括 Pivotal 在内的其他公司之间的合作,它直接且明确地构建容器镜像。这对开发人员和运维人员来说都非常有趣。开发人员不必过于关心如何构建容器的细节,但如果需要,他们可以轻松地创建一个。Buildpacks 还具有许多缓存构建结果和依赖项的功能,因此 buildpack 的运行速度通常比本地 Docker 构建快得多。运维人员可以扫描容器以审计其内容,并对其进行转换以修补安全更新。您可以在本地(例如在开发人员机器上,或在 CI 服务中)或在 Cloud Foundry 等平台上运行 buildpacks。

Buildpack 生命周期生成的输出是一个容器镜像,但您不需要 Docker 或 Dockerfile,因此它对 CI 和自动化友好。输出镜像中的文件系统层由 buildpack 控制,并且通常会进行许多优化,而开发人员无需了解或关心它们。在较低层(如包含操作系统的基础镜像)和上层(包含中间件和特定于语言的依赖项)之间还有一个 应用程序二进制接口 (ABI)。这使得平台(如 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 镜像——通常它将是所有开发人员或单个平台上的所有开发人员的共享资源。这个 builder 是 Ben Hale 的工作成果,他维护着 Cloud Foundry 的旧 buildpacks,现在正在从事新一代的工作。在这种情况下,输出被发送到了本地 Docker daemon,但在自动化平台中,它可以是 Docker 注册表。一旦 pack CLI 达到稳定版本,默认的 builder 可能会做同样的事情。

Knative

容器和平台领域的另一个新项目是 Knative。Knative 是很多东西,但如果您不熟悉它,您可以将其视为构建无服务器平台的构建块。它构建在 Kubernetes 之上,因此最终它会消耗容器镜像,并将它们转换为平台上的应用程序或“服务”。然而,它的一项主要功能是能够消费源代码并为您构建容器,从而使其对开发人员和运维人员更加友好。 Knative Build 是执行此操作的组件,它本身就是一个灵活的平台,用于将用户代码转换为容器——您可以以几乎任何您喜欢的方式来完成。提供了一些常见模式的模板,如 Maven 和 Gradle 构建,以及使用 Kaniko 的多阶段 Docker 构建。还有一个使用 Buildpacks 的模板,这对我们来说非常有趣,因为 buildpacks 一直对 Spring Boot 有很好的支持。Knative 上的 Buildpacks 也是 RiffPivotal Function Service 将用户函数转换为正在运行的无服务器应用程序的首选方式。

结语

本文介绍了许多为 Spring Boot 应用程序构建容器镜像的选项。它们都是完全有效的选择,现在由您决定需要哪一个。您第一个要问的问题应该是“我真的需要构建容器镜像吗?”如果答案是“是”,那么您的选择很可能会受到效率和可缓存性的驱动,以及关注点分离。您是想让开发人员无需了解太多关于容器镜像如何创建的细节吗?您想让开发人员负责在操作系统和中间件漏洞需要修补时更新镜像吗?或者也许开发人员需要对整个过程拥有完全的控制权,并且他们拥有所需的所有工具和知识。

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,助您加速进步。

了解更多

获得支持

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件,只需一份简单的订阅。

了解更多

即将举行的活动

查看 Spring 社区所有即将举行的活动。

查看所有