Spring Boot 与 Docker
观察 GraphQL 的实际运行

许多人使用容器来封装他们的 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 eclipse-temurin:17-jdk-alpine
VOLUME /tmp
ARG JAR_FILE
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

你可以在执行 docker 命令时传递 JAR_FILE 参数(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 eclipse-temurin:17-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.7.4)

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,因此这里使用的是 ash shell。它具有部分但并非全部的 bash 特性。

如果你有一个正在运行的容器,并想查看其内部情况,可以使用 docker exec 命令来实现:

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

其中 myapp 是传递给 docker run 命令的 --name 参数值。如果没有使用 --name,Docker 会自动分配一个助记名,你可以在 docker ps 的输出中找到它。此外,你也可以使用容器的 SHA 标识符来代替名称。SHA 标识符同样会在 docker ps 的输出中显示。

入口点

Dockerfile 中的 exec 形式ENTRYPOINT 被使用,以确保没有 shell 包裹 Java 进程。这样做的优点是 Java 进程会响应发送到容器的 KILL 信号。实际上这意味着(例如),如果你在本地运行你的镜像时,可以使用 CTRL-C 来停止它。如果命令行变得很长,你可以将其提取出来放到一个 shell 脚本中,并通过 COPY 将其复制到镜像中再进行运行。以下示例展示了如何做到这一点:

Dockerfile

FROM eclipse-temurin:17-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 eclipse-temurin:17-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

这会失败是因为 ${} 替换需要使用 shell。由于 exec 形式不通过 shell 启动进程,因此选项不会被应用。你可以通过将入口点移到脚本(如前面所示的 run.sh 示例)或在入口点显式创建一个 shell 来解决此问题。以下示例展示了如何在入口点创建一个 shell:

Dockerfile

FROM eclipse-temurin:17-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.7.4)
...
2019-10-29 09:12:12.169 DEBUG 1 --- [           main] ConditionEvaluationReportLoggingListener :


============================
CONDITIONS EVALUATION REPORT
============================
...

(上述输出展示了使用 -Ddebug 参数由 Spring Boot 生成的完整 DEBUG 日志的一部分。)

使用带有明确 shell 的 ENTRYPOINT(如前例所示)意味着你可以将环境变量传递给 Java 命令。然而,目前你无法向 Spring Boot 应用程序提供命令行参数。以下命令无法在端口 9000 上运行应用程序:

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

执行该命令后,会得到如下输出,显示端口为8080而非9000:

.   ____          _            __ _ _
/\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/  ___)| |_)| | | | | || (_| |  ) ) ) )
'  |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.7.4)
...
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 进程。要修复这个问题,你需要将 CMD 中的命令行添加到 ENTRYPOINT 中:

Dockerfile

FROM eclipse-temurin:17-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.7.4)
...
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 拆分成多个层次,我们可以改进这一点。

较小的图像

注意,在之前的示例中,基础镜像是 eclipse-temurin:17-jdk-alpinealpine 镜像比从 Dockerhub 提供的标准 eclipse-temurin 库镜像更小。通过使用 jre 标签而不是 jdk,你还可以在基础镜像中节省大约 20MB 的空间。虽然并非所有应用程序都能与 JRE(而非 JDK)一起工作,但大多数可以。一些组织规定每个应用都必须与 JRE 兼容,以避免某些 JDK 功能(如编译功能)被滥用的风险。

另一个可以让你获得更小镜像的技巧是使用JLink,它捆绑在 OpenJDK 11 及以上版本中。JLink 允许你从完整 JDK 中的一个模块子集构建一个自定义 JRE 发行版,因此你在基础镜像中不需要包含 JRE 或 JDK。理论上,这会比使用官方的 Docker 镜像获得更小的整体镜像大小。但实际上,在你自己基础镜像中的自定义 JRE 不能在其他应用程序之间共享,因为它们需要不同的定制化。所以你可能会为所有应用程序拥有更小的镜像,但它们仍然启动得更慢,因为你无法利用缓存 JRE 层的优势。

那最后一点突出了图像构建者的一个重要问题:目标不一定总是要构建尽可能小的镜像。较小的镜像通常是个好主意,因为它们上传和下载所需的时间更少,但前提是这些镜像中的层没有被缓存。如今,镜像仓库非常复杂,试图通过巧妙地构建镜像来利用这些特性可能会得不偿失。如果你使用常见的基础层,那么整个镜像的大小就不太重要了,并且随着仓库和平台的发展,这一点可能变得越来越不重要。尽管如此,优化我们应用程序镜像中的层仍然是重要的和有用的。然而,目标始终应该是将变化最频繁的部分放在顶层,并尽可能多地与其他应用程序共享较大的底层。

更优的 Dockerfile

由于 JAR 文件的打包方式,Spring Boot 的胖 Jar 自然具有“层次”。如果我们先解压这个胖 Jar,它会被分为外部和内部依赖。为了在 docker 构建过程中一步完成解压,我们需要首先解压 JAR 文件。以下命令(仍然使用 Maven,但 Gradle 版本也非常相似)用于解压 Spring Boot 胖 Jar:

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

然后我们可以使用如下的 Dockerfile

Dockerfile

FROM eclipse-temurin:17-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"]
现在有三个层次,后两个层次包含了所有的应用资源。如果应用程序的依赖没有变化,第一个层次(来自 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 eclipse-temurin:17-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.launch.JarLauncher"]
Spring Boot 的胖 JarLauncher 从 JAR 文件中提取到镜像中,因此可以在不硬编码主应用类的情况下启动应用程序。

关于使用分层功能的更多详情,请参阅Spring Boot 文档

调整

如果你希望尽快启动应用程序(大多数人都希望如此),你可以考虑进行一些调整:

  • 使用 spring-context-indexer文档链接)。虽然对于小型应用程序来说,这可能不会带来太多好处,但每一点改进都是有帮助的。

  • 如果可以避免使用 actuators,就不要使用。

  • 使用最新版本的 Spring Boot 和 Spring 框架。

  • 使用 spring.config.location(通过命令行参数、系统属性或其他方式)来设置 Spring Boot 配置文件的位置。

你的应用程序在运行时可能不需要完整的 CPU,但为了尽可能快速启动,需要多个 CPU(至少两个,四个更好)。如果你不介意启动速度较慢,可以将 CPU 数量限制在四以下。如果被迫使用少于四个 CPU 启动,设置 -Dspring.backgroundpreinitializer.ignore=true 可能会有帮助,因为它防止 Spring Boot 创建一个它可能无法使用的额外线程(这适用于 Spring Boot 2.1.0 及以上版本)。

多阶段构建

更好的Dockerfile中展示的Dockerfile假设胖JAR已经在命令行上构建完成。你也可以通过使用多阶段构建,并在一个镜像与另一个镜像之间复制结果,在Docker中完成这一步骤。以下示例展示了如何使用Maven来实现:

Dockerfile

FROM eclipse-temurin:17-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 eclipse-temurin:17-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 来完成(这是入门指南中的方法)。除了需要编辑构建配置并添加插件之外,并没有太大差别。

请注意,源代码已被分成四层。后面的层次包含构建配置和应用程序源代码,而前面的层次则包含构建系统本身(Maven包装器)。这是一个小优化,并且意味着我们不需要将 target 目录复制到Docker镜像中,即使是用于构建的临时镜像也不需要。

每次源代码发生变化时,构建过程都会变慢,因为 Maven 缓存需要在第一个 RUN 部分重新创建。不过,你有一个完全独立的构建环境,任何人都可以运行它来启动你的应用程序,只要他们有 Docker 即可。这在某些环境中非常有用——例如,在你需要与不了解 Java 的人分享代码的情况下。

实验性功能

Docker 18.06 带有一些“实验性”功能,其中包括缓存构建依赖项的功能。要启用这些功能,你需要在守护进程(dockerd)中设置一个标志,并在运行客户端时设置一个环境变量。然后你可以在你的 Dockerfile 中添加一个“魔法”第一行:

Dockerfile

# syntax=docker/dockerfile:experimental

指令 RUN 然后接受一个新的标志: --mount。以下是一个完整的示例:

Dockerfile

# syntax=docker/dockerfile:experimental
FROM eclipse-temurin:17-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 eclipse-temurin:17-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 eclipse-temurin:17-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/*-SNAPSHOT.jar)

FROM eclipse-temurin:17-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 版本。请查阅你所使用版本的文档(前面所示的例子适用于 docker 18.0.6)。

安全方面

就像在经典的虚拟机部署中一样,进程不应该以 root 权限运行。相反,镜像应包含一个非 root 用户来运行应用程序。

Dockerfile 中,你可以通过添加一层来创建一个(系统)用户和组,并将该用户设为当前用户(而非默认的 root 用户)来实现这一目标:

Dockerfile

FROM eclipse-temurin:17-jdk-alpine

RUN addgroup -S demo && adduser -S demo -G demo
USER demo

...

如果有人设法突破你的应用程序并在容器内执行系统命令,这一预防措施会根据最小权限原则来限制其能力。

一些进一步的Dockerfile命令仅在 root 用户下才能运行,因此你可能需要将 USER 命令向后移(例如,在容器中安装更多软件包时,这些操作必须以 root 身份执行)。
对于其他方法,不使用Dockerfile可能更合适。例如,在后面描述的 buildpack 方法中,大多数实现默认使用非 root 用户。

另一个考虑是,大多数应用程序在运行时可能并不需要完整的 JDK。因此,在我们完成多阶段构建后,可以安全地切换到 JRE 基础镜像。所以在前面展示的多阶段构建中,我们可以为最终可执行的镜像使用:

Dockerfile

FROM eclipse-temurin:17-jre-alpine

...

如前所述,这样还可以节省一些空间,这些空间原本会被那些在运行时不必要的工具占用。

构建插件

如果你不想在构建过程中直接调用 docker,可以使用 Maven 和 Gradle 提供的一系列插件来完成相关任务。这里仅列举几个示例。

Spring Boot 的 Maven 和 Gradle 插件

你可以使用 Maven 和 Gradle 的 Spring Boot 构建插件来创建容器镜像。这些插件通过 Cloud Native Buildpacks 生成一个 OCI 镜像(与使用 docker build 创建的格式相同)。你不需要编写 Dockerfile,但需要有一个 Docker 守护进程,可以在本地运行(就像你在用 docker 构建时那样),或者通过设置 DOCKER_HOST 环境变量来远程使用。默认构建器针对 Spring Boot 应用程序进行了优化,并且镜像的分层效率很高,如上面的例子所示。

以下示例可以在不修改 pom.xml 文件的情况下于 Maven 中运行:

./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=myorg/myapp

以下示例与 Gradle 兼容,无需修改 build.gradle 文件:

./gradlew bootBuildImage --imageName=myorg/myapp

第一次构建可能会花费较长时间,因为需要下载一些容器镜像和 JDK,但是后续的构建应该会快很多。

然后你可以运行该镜像,如下示例所示(包括输出):

docker run -p 8080:8080 -t myorg/myapp
Setting Active Processor Count to 6
Calculating JVM memory based on 14673596K available memory
Calculated JVM Memory Configuration: -XX:MaxDirectMemorySize=10M -Xmx14278122K -XX:MaxMetaspaceSize=88273K -XX:ReservedCodeCacheSize=240M -Xss1M (Total Memory: 14673596K, Thread Count: 50, Loaded Class Count: 13171, Headroom: 0%)
Adding 129 container CA certificates to JVM truststore
Spring Cloud Bindings Enabled
Picked up JAVA_TOOL_OPTIONS: -Djava.security.properties=/layers/paketo-buildpacks_bellsoft-liberica/java-security-properties/java-security.properties -agentpath:/layers/paketo-buildpacks_bellsoft-liberica/jvmkill/jvmkill-1.16.0-RELEASE.so=printHeapHistogram=1 -XX:ActiveProcessorCount=6 -XX:MaxDirectMemorySize=10M -Xmx14278122K -XX:MaxMetaspaceSize=88273K -XX:ReservedCodeCacheSize=240M -Xss1M -Dorg.springframework.cloud.bindings.boot.enable=true
....
2015-03-31 13:25:48.035  INFO 1 --- [           main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)
2015-03-31 13:25:48.037  INFO 1 --- [           main] hello.Application

你可以看到应用程序正常启动。你可能还会注意到,JVM 内存需求已经被计算出来,并作为容器内的命令行选项设置。这种内存计算方法多年来一直被用于 Cloud Foundry 构建包中。它代表了对各种 JVM 应用程序(包括但不限于 Spring Boot 应用程序)的最佳选择的研究成果,通常比 JVM 的默认设置要好得多。你可以通过设置环境变量来自定义命令行选项并覆盖内存计算器,如Paketo 构建包文档所示。

Spotify Maven 插件

Spotify Maven 插件(https://github.com/spotify/dockerfile-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 大型 JAR 文件,这是 docker 构建的根目录。然后前面展示的多层(而不是多阶段)Dockerfile 就可以正常工作了。

Jib Maven 和 Gradle 插件

Google 有一个名为Jib的开源工具,这个工具相对较新但非常有趣。最有趣的一点是,你不需要 Docker 来运行它。Jib 使用与 docker build 相同的标准输出来构建镜像,并且除非你明确要求,否则不会使用 docker,因此在没有安装 Docker 的环境中也可以正常工作(这在构建服务器中很常见)。此外,你不需要一个 Dockerfile (即使有也会被忽略),也不需要任何内容在你的 pom.xml 中来通过 Maven 构建镜像(Gradle 则需要你在 build.gradle 中至少安装插件)。

Jib 的另一个有趣特性是它对层次结构有自己的看法,并且优化方式与上述多层 Dockerfile 略有不同。就像 fat JAR 一样,Jib 将本地应用资源和依赖项分离,但更进一步地将快照依赖项单独放在一层中,因为这些依赖项更容易发生变化。提供了配置选项以进一步自定义布局。

以下示例无需修改 pom.xml,即可与 Maven 一起使用:

$ 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 设置来设置本地身份验证。

一旦你在 build.gradle 文件中添加了 Gradle �插件,它就会具备类似的功能:

build.gradle

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

然后你可以通过运行以下命令来构建一个镜像:

./gradlew jib --image=myorg/myapp

类似于 Maven 构建,如果你已经通过命令行使用 docker 完成了身份验证,那么在推送镜像时将从你的本地 ~/.docker 配置文件中获取认证信息。

持续集成

如今,每个应用程序生命周期中都包含自动化(或者应该包含)。人们用来进行自动化的工具通常在从源代码调用构建系统方面表现得很好。因此,如果这能为你生成一个 Docker 镜像,并且构建代理的环境与开发者的本地环境足够一致,那么这就已经足够了。认证到 Docker 注册表可能是最大的挑战,但所有自动化工具都提供了相应的功能来帮助解决这个问题。

然而,有时最好完全将容器创建交给自动化层处理,在这种情况下,用户的代码可能不需要被污染。容器创建很复杂,开发人员有时候并不需要真正关心它。如果用户代码更干净,其他工具就更有机会“做正确的事情”(例如应用安全修复、优化缓存等)。如今有许多自动化选项,并且它们都包含一些与容器相关的功能。我们将看一下其中的几个。

Concourse

Concourse 是一个基于管道的自动化平台,可用于 CI 和 CD。该项目的主要作者在 VMware 工作,并且该平台也在 VMware 内部使用。除了 CLI 之外,Concourse 中的一切都是无状态的并且运行在容器中。由于创建容器是自动化流水线的主要任务之一,因此 Concourse 对此提供了很好的支持。如果构建输出是一个容器镜像,则 Docker Image Resource 负责保持其最新状态。

以下示例管道为前面所示的样本构建 Docker 镜像。假设该样本位于 github 的 myorg/myapp 位置,并且在根目录下有一个 Dockerfile,同时在 src/main/ci/build.yml 中声明了 build 任务:
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 对象添加凭据。

构建包

Spring Boot 的 Maven 和 Gradle 插件以与 pack CLI 完全相同的方式使用 buildpacks。在以下示例中,给定相同的输入,生成的镜像将完全一致。

Cloud Foundry 多年来一直在内部使用容器,并且用于将用户代码转换为容器的技术之一是构建包(Build Packs),这个概念最初是从 Heroku 借鉴而来的。当前一代的构建包(v2)生成通用二进制输出,这些输出由平台组装成容器。新一代的构建包(v3)是由 Heroku 和其他公司(包括 VMware)合作开发的,并且可以直接明确地构建容器镜像。这对开发者和运维人员来说都很有趣。开发者不需要过多关注如何构建容器的细节,但如果需要的话可以轻松创建一个。构建包还具有许多用于缓存构建结果和依赖项的功能。通常情况下,构建包运行速度比原生 Docker 构建快得多。运维人员可以扫描容器以审计其内容并对其进行修补以更新安全补丁。此外,你可以在本地(例如在开发机或 CI 服务上)或像 Cloud Foundry 这样的平台上运行构建包。

构建包生命周期的输出是一个容器镜像,但你不需要编写 Dockerfile。输出镜像中的文件系统层次结构由构建包控制。通常,许多优化会在开发者无需了解或关心的情况下自动完成。在较低层级(如包含操作系统的基础镜像)和较高层级(包含中间件和特定语言依赖项)之间还有一个应用程序二进制接口。这使得平台(例如 Cloud Foundry)可以在有安全更新时修补较低层,而不影响应用的完整性和功能。

为了让你了解构建包的功能,以下示例及其输出使用命令行中的Pack CLI(它会与我们在本指南中使用的样本应用程序一起工作——无需Dockerfile或任何特殊的构建配置):

pack build myorg/myapp --builder=paketobuildpacks/builder:base --path=.
base: Pulling from paketobuildpacks/builder
Digest: sha256:4fae5e2abab118ca9a37bf94ab42aa17fef7c306296b0364f5a0e176702ab5cb
Status: Image is up to date for paketobuildpacks/builder:base
base-cnb: Pulling from paketobuildpacks/run
Digest: sha256:a285e73bc3697bc58c228b22938bc81e9b11700e087fd9d44da5f42f14861812
Status: Image is up to date for paketobuildpacks/run:base-cnb
===> DETECTING
7 of 18 buildpacks participating
paketo-buildpacks/ca-certificates   2.3.2
paketo-buildpacks/bellsoft-liberica 8.2.0
paketo-buildpacks/maven             5.3.2
paketo-buildpacks/executable-jar    5.1.2
paketo-buildpacks/apache-tomcat     5.6.1
paketo-buildpacks/dist-zip          4.1.2
paketo-buildpacks/spring-boot       4.4.2
===> ANALYZING
Previous image with name "myorg/myapp" not found
===> RESTORING
===> BUILDING

Paketo CA Certificates Buildpack 2.3.2
  https://github.com/paketo-buildpacks/ca-certificates
  Launch Helper: Contributing to layer
    Creating /layers/paketo-buildpacks_ca-certificates/helper/exec.d/ca-certificates-helper

Paketo BellSoft Liberica Buildpack 8.2.0
  https://github.com/paketo-buildpacks/bellsoft-liberica
  Build Configuration:
    $BP_JVM_VERSION              11              the Java version
  Launch Configuration:
    $BPL_JVM_HEAD_ROOM           0               the headroom in memory calculation
    $BPL_JVM_LOADED_CLASS_COUNT  35% of classes  the number of loaded classes in memory calculation
    $BPL_JVM_THREAD_COUNT        250             the number of threads in memory calculation
    $JAVA_TOOL_OPTIONS                           the JVM launch flags
  BellSoft Liberica JDK 11.0.12: Contributing to layer
    Downloading from https://github.com/bell-sw/Liberica/releases/download/11.0.12+7/bellsoft-jdk11.0.12+7-linux-amd64.tar.gz
    Verifying checksum
    Expanding to /layers/paketo-buildpacks_bellsoft-liberica/jdk
    Adding 129 container CA certificates to JVM truststore
    Writing env.build/JAVA_HOME.override
    Writing env.build/JDK_HOME.override
  BellSoft Liberica JRE 11.0.12: Contributing to layer
    Downloading from https://github.com/bell-sw/Liberica/releases/download/11.0.12+7/bellsoft-jre11.0.12+7-linux-amd64.tar.gz
    Verifying checksum
    Expanding to /layers/paketo-buildpacks_bellsoft-liberica/jre
    Adding 129 container CA certificates to JVM truststore
    Writing env.launch/BPI_APPLICATION_PATH.default
    Writing env.launch/BPI_JVM_CACERTS.default
    Writing env.launch/BPI_JVM_CLASS_COUNT.default
    Writing env.launch/BPI_JVM_SECURITY_PROVIDERS.default
    Writing env.launch/JAVA_HOME.default
    Writing env.launch/MALLOC_ARENA_MAX.default
  Launch Helper: Contributing to layer
    Creating /layers/paketo-buildpacks_bellsoft-liberica/helper/exec.d/active-processor-count
    Creating /layers/paketo-buildpacks_bellsoft-liberica/helper/exec.d/java-opts
    Creating /layers/paketo-buildpacks_bellsoft-liberica/helper/exec.d/link-local-dns
    Creating /layers/paketo-buildpacks_bellsoft-liberica/helper/exec.d/memory-calculator
    Creating /layers/paketo-buildpacks_bellsoft-liberica/helper/exec.d/openssl-certificate-loader
    Creating /layers/paketo-buildpacks_bellsoft-liberica/helper/exec.d/security-providers-configurer
    Creating /layers/paketo-buildpacks_bellsoft-liberica/helper/exec.d/security-providers-classpath-9
  JVMKill Agent 1.16.0: Contributing to layer
    Downloading from https://github.com/cloudfoundry/jvmkill/releases/download/v1.16.0.RELEASE/jvmkill-1.16.0-RELEASE.so
    Verifying checksum
    Copying to /layers/paketo-buildpacks_bellsoft-liberica/jvmkill
    Writing env.launch/JAVA_TOOL_OPTIONS.append
    Writing env.launch/JAVA_TOOL_OPTIONS.delim
  Java Security Properties: Contributing to layer
    Writing env.launch/JAVA_SECURITY_PROPERTIES.default
    Writing env.launch/JAVA_TOOL_OPTIONS.append
    Writing env.launch/JAVA_TOOL_OPTIONS.delim

Paketo Maven Buildpack 5.3.2
  https://github.com/paketo-buildpacks/maven
  Build Configuration:
    $BP_MAVEN_BUILD_ARGUMENTS  -Dmaven.test.skip=true package  the arguments to pass to Maven
    $BP_MAVEN_BUILT_ARTIFACT   target/*.[jw]ar                 the built application artifact explicitly.  Supersedes $BP_MAVEN_BUILT_MODULE
    $BP_MAVEN_BUILT_MODULE                                     the module to find application artifact in
    Creating cache directory /home/cnb/.m2
  Compiled Application: Contributing to layer
    Executing mvnw --batch-mode -Dmaven.test.skip=true package

[ ... Maven build output ... ]

[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  53.474 s
[INFO] Finished at: 2021-07-23T20:10:28Z
[INFO] ------------------------------------------------------------------------
  Removing source code

Paketo Executable JAR Buildpack 5.1.2
  https://github.com/paketo-buildpacks/executable-jar
  Class Path: Contributing to layer
    Writing env/CLASSPATH.delim
    Writing env/CLASSPATH.prepend
  Process types:
    executable-jar: java org.springframework.boot.loader.JarLauncher (direct)
    task:           java org.springframework.boot.loader.JarLauncher (direct)
    web:            java org.springframework.boot.loader.JarLauncher (direct)

Paketo Spring Boot Buildpack 4.4.2
  https://github.com/paketo-buildpacks/spring-boot
  Creating slices from layers index
    dependencies
    spring-boot-loader
    snapshot-dependencies
    application
  Launch Helper: Contributing to layer
    Creating /layers/paketo-buildpacks_spring-boot/helper/exec.d/spring-cloud-bindings
  Spring Cloud Bindings 1.7.1: Contributing to layer
    Downloading from https://repo.spring.io/release/org/springframework/cloud/spring-cloud-bindings/1.7.1/spring-cloud-bindings-1.7.1.jar
    Verifying checksum
    Copying to /layers/paketo-buildpacks_spring-boot/spring-cloud-bindings
  Web Application Type: Contributing to layer
    Reactive web application detected
    Writing env.launch/BPL_JVM_THREAD_COUNT.default
  4 application slices
  Image labels:
    org.opencontainers.image.title
    org.opencontainers.image.version
    org.springframework.boot.version
===> EXPORTING
Adding layer 'paketo-buildpacks/ca-certificates:helper'
Adding layer 'paketo-buildpacks/bellsoft-liberica:helper'
Adding layer 'paketo-buildpacks/bellsoft-liberica:java-security-properties'
Adding layer 'paketo-buildpacks/bellsoft-liberica:jre'
Adding layer 'paketo-buildpacks/bellsoft-liberica:jvmkill'
Adding layer 'paketo-buildpacks/executable-jar:classpath'
Adding layer 'paketo-buildpacks/spring-boot:helper'
Adding layer 'paketo-buildpacks/spring-boot:spring-cloud-bindings'
Adding layer 'paketo-buildpacks/spring-boot:web-application-type'
Adding 5/5 app layer(s)
Adding layer 'launcher'
Adding layer 'config'
Adding layer 'process-types'
Adding label 'io.buildpacks.lifecycle.metadata'
Adding label 'io.buildpacks.build.metadata'
Adding label 'io.buildpacks.project.metadata'
Adding label 'org.opencontainers.image.title'
Adding label 'org.opencontainers.image.version'
Adding label 'org.springframework.boot.version'
Setting default process type 'web'
Saving myorg/myapp...
*** Images (ed1f92885df0):
      myorg/myapp
Adding cache layer 'paketo-buildpacks/bellsoft-liberica:jdk'
Adding cache layer 'paketo-buildpacks/maven:application'
Adding cache layer 'paketo-buildpacks/maven:cache'
Successfully built image 'myorg/myapp'

--builder 是一个 Docker 镜像,用于运行 buildpack 生命周期。通常,这会是所有开发人员或单一平台上的所有开发人员共享的资源。你可以在命令行上设置默认构建器(在 ~/.pack 目录下创建一个文件),然后在后续构建中省略该标志。

paketobuildpacks/builder:base 构建器也知道如何从可执行的 JAR 文件构建镜像。因此,你可以先使用 Maven 进行构建,然后将 --path 指向生成的 JAR 文件,以达到相同的结果。

Knative

容器和平台领域中的另一个新项目是Knative。如果你不熟悉它,可以将其视为构建无服务器平台的组件。它是基于Kubernetes 构建的,因此最终会消费容器镜像并将它们转换为平台上的应用程序或“服务”。不过,它的主要功能之一是能够消费源代码并为你构建容器,使其更易于开发者和操作员使用。Knative Build 是执行此操作的组件,并且本身是一个灵活的平台,可以将用户代码转换为容器——你可以以几乎任何你喜欢的方式来做。提供了一些常见的模板(如 Maven 和 Gradle 构建)以及使用Kaniko 的多阶段 Docker 构建。还有一个使用Buildpacks 的模板,这对 Spring Boot 来说很有趣,因为 Buildpacks 一直对 Spring Boot 支持得很好。

结语

本指南介绍了为 Spring Boot 应用程序构建容器镜像的多种选项。所有这些选择都是有效的,现在由你决定哪一个最适合你的需求。首先你应该问自己:“我真的需要构建一个容器镜像吗?”如果答案是“是”,那么你的选择将主要取决于效率、缓存能力和职责分离。例如,你希望开发人员不必了解如何创建容器镜像的细节吗?或者你希望让开发人员负责在操作系统和中间件漏洞出现时更新镜像呢?又或许,开发人员需要完全控制整个过程,并且他们已经具备了所需的工具和知识。

本页目录