容器中的 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-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 镜像只有一个包含胖 jar 的文件系统层,我们对应用程序代码所做的每一次更改都会更改该层,该层可能为 10MB 或更大(对于某些应用程序,甚至可能高达 50MB)。我们可以通过将 JAR 分割成多个层来改进这一点。

一个更好的 Dockerfile

Spring Boot 胖 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 胖 JarLauncher 复制到镜像中并使用它来运行应用程序 - 它可以工作,并且您不需要指定主类,但启动速度会慢一些。

调整

如果您希望尽快启动您的应用程序(大多数人都会这样做),您可以考虑进行一些调整。以下是一些想法

  • 使用 spring-context-indexer文档链接)。对于小型应用程序,它不会增加太多,但任何细微的改进都有帮助。

  • 如果您可以承受,请不要使用 执行器

  • 使用 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 假设胖 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 完成(这是入门指南中采用的方法) - 实际上没有太大区别,只是必须编辑构建配置并添加插件。

请注意,源代码已分成 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 插件

使用 DockerfilePalantir Gradle 插件 也可以为你生成 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

要运行上述命令,你需要有权限将镜像推送到 Dockerhub 的 myorg 存储库前缀下。如果你已在命令行中使用 docker 进行身份验证,则可以使用本地 ~/.docker 配置。你也可以在 ~/.m2/settings.xml 中设置 Maven "服务器" 身份验证(存储库的 id 非常重要)。

settings.xml

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

还有其他选项,例如,你可以针对 Docker 守护进程(就像在命令行中运行 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.cn/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 内部被广泛使用,该项目的主要作者也在那里工作。除了 CLI 之外,Concourse 中的所有内容都是无状态的,并且都在容器中运行。由于运行容器是自动化管道的主要业务,因此创建容器得到了很好的支持。如果输出状态是容器镜像,则 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 对象中。

构建包

Cloud Foundry 内部多年来一直使用容器,而用于将用户代码转换为容器的技术之一就是构建包,这是一个最初借鉴自 Heroku 的理念。当前一代构建包 (v2) 生成通用的二进制输出,该输出由平台组装到容器中。 新一代构建包 (v3) 是 Heroku 与 Pivotal 等其他公司之间的合作成果,它直接且明确地构建容器镜像。这对开发人员和运维人员来说非常有趣。开发人员无需过多关注构建容器的细节,但如果需要,他们可以轻松地创建一个容器。构建包还具有许多缓存构建结果和依赖项的功能,因此构建包的运行速度通常比原生 Docker 构建快得多。运维人员可以扫描容器以审核其内容并对其进行转换以修补安全更新。您可以在本地(例如,在开发人员机器上或在 CI 服务中)或在 Cloud Foundry 等平台上运行构建包。

构建包生命周期的输出是一个容器镜像,但您不需要 Docker 或 Dockerfile,因此它对 CI 和自动化非常友好。输出镜像中的文件系统层由构建包控制,通常会进行许多优化,而无需开发人员了解或关心它们。在较低层(例如包含操作系统的基础镜像)和较高层(包含中间件和特定于语言的依赖项)之间还有一个 应用程序二进制接口。这使得像 Cloud Foundry 这样的平台可以在有安全更新时修补较低层,而不会影响应用程序的完整性和功能。

为了让您了解构建包的功能,以下是一个使用命令行中的 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 是一个运行构建包生命周期的 Docker 镜像 - 通常它将是所有开发人员或单个平台上所有开发人员的共享资源。这个镜像是 Ben Hale 的正在进行的工作,他维护着 Cloud Foundry 的旧版构建包,现在正在开发新一代构建包。在这种情况下,输出发送到本地 Docker 守护程序,但在自动化平台中,它可以是 Docker 注册表。一旦 pack CLI 发布稳定版本,默认构建器可能会执行相同操作。

Knative

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

总结

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

获取 Spring 新闻通讯

与 Spring 新闻通讯保持联系

订阅

领先一步

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

了解更多

获取支持

Tanzu Spring 通过一个简单的订阅提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举行的活动

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

查看全部