Spring Boot 在容器中

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

许多人使用容器来打包其 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。然后,一个用于运行该 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-alpine。来自 Dockerhubalpine 镜像比标准 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

由于 jar 本身的打包方式,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 个层,所有应用资源都在后 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 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"])
}

在此示例中,我们选择将 Spring Boot fat jar 解压到 build 目录中的特定位置,这是 docker 构建的根目录。然后,上面提到的多层(非多阶段)Dockerfile 即可工作。

Jib Maven 和 Gradle 插件

谷歌有一个开源工具叫做 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

要运行上述命令,您需要有权将镜像推送到 Dockerhub 的 myorg 存储库前缀下。如果您已经在命令行上使用 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 daemon 构建(就像在命令行上运行 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 registry 进行身份验证可能是最大的挑战,但所有自动化工具都提供了帮助功能。

然而,有时将容器创建完全留给自动化层更好,在这种情况下,用户代码可能不需要被“污染”。容器创建很棘手,开发者有时并不真正关心它。如果用户代码更清晰,那么不同的工具“做正确的事情”的可能性就更大,例如应用安全修复、优化缓存等。自动化有多种选择,如今它们都会附带一些与容器相关的功能。我们将只介绍几个。

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 是另一个流行的自动化服务器。它具有广泛的功能,但与此处其他自动化示例最接近的是流水线功能。这是一个 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 构建快得多。运维人员可以扫描容器以审计其内容,并对其进行转换以修补安全更新。您可以在本地运行 buildpacks(例如,在开发者机器上或 CI 服务中),或在 Cloud Foundry 等平台中运行。

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 旧版的 buildpacks,现在正在开发新一代。在这种情况下,输出镜像被发送到本地 docker daemon,但在自动化平台中,它可能是 docker registry。一旦 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 社区所有即将举办的活动。

查看全部