「Spring」Boot Docker 认证指南(上)

语言: CN / TW / HK

许多人使用容器来包装他们的 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 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-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 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
...复制

如果你想在镜像内部四处寻找,你可以通过运行以下命令在其中打开一个 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,所以这是一个ashshell。它具有一些但不是全部的特性bash。

如果你有一个正在运行的容器并且你想查看它,你可以通过运行 docker exec

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

传递给命令 myapp 的位置在哪里。如果您没有使用,docker 会分配一个助记名称,您可以从. 您还可以使用容器的 SHA 标识符而不是名称。SHA 标识符在输出中也可见。 --namedocker run--namedocker psdocker ps

入口点

使用Dockerfile的exec 形式 ENTRYPOINT ,以便没有外壳包装 Java 进程。优点是java进程响应 KILL 发送到容器的信号。实际上,这意味着(例如)如果您 docker run 在本地使用图像,则可以使用 CTRL-C . 如果命令行有点长,您可以 COPY 在运行之前将其提取到 shell 脚本中并放入映像中。以下示例显示了如何执行此操作:

Dockerfile:

FROM openjdk:8-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 openjdk:8-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复制

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

Dockerfile:

FROM openjdk:8-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.2.0.RELEASE)
...
2019-10-29 09:12:12.169 DEBUG 1 --- [           main] ConditionEvaluationReportLoggingListener :
============================
CONDITIONS EVALUATION REPORT
============================
...复制

(前面的输出显示了 Spring Boot DEBUG 生成的完整输出的一部分。) -Ddebug。

将 an ENTRYPOINT 与显式 shell 一起使用(如前面的示例所做的那样)意味着您可以将环境变量传递给 Java 命令。但是,到目前为止,您还不能为 Spring Boot 应用程序提供命令行参数。以下命令不会在端口 9000 上运行应用程序:

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

该命令产生以下输出,将端口显示为 8080 而不是 9000:

.   ____          _            __ _ _
/\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/  ___)| |_)| | | | | || (_| |  ) ) ) )
'  |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.2.0.RELEASE)
...
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 进程。要解决此问题,您需要将命令行从以下添加 CMDENTRYPOINT

Dockerfile:

FROM openjdk:8-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.2.0.RELEASE)
...
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 拆分为多个层来改进这一点。

较小的图像

请注意,前面示例中的基本映像是 openjdk:8-jdk-alpine . 这些 alpine 图像小于Dockerhub openjdk 的标准库图像。您还可以通过使用标签而不是. 并非所有应用程序都使用 JRE(与 JDK 相对),但大多数应用程序都可以。一些组织强制执行一个规则,即每个应用程序都必须使用 JRE,因为存在滥用某些 JDK 功能(例如编译)的风险。 jrejdk

另一个可以让您获得更小的映像的技巧是使用JLink,它与 OpenJDK 11 捆绑在一起。JLink 允许您从完整 JDK 中的模块子集构建自定义 JRE 分发,因此您不需要 JRE 或 JDK基础图像。原则上,这将使您获得比使用 openjdk 官方 docker 图像更小的总图像大小。在实践中,您(还)不能将 alpine 基础镜像与 JDK 11 一起使用,因此您对基础镜像的选择是有限的,并且可能会导致最终镜像的大小更大。此外,您自己的基本映像中的自定义 JRE 不能在其他应用程序之间共享,因为它们需要不同的自定义。因此,您的所有应用程序可能都有较小的图像,但它们仍然需要更长的时间才能启动,因为它们没有从缓存 JRE 层中受益。

最后一点突出了图像构建者的一个非常重要的问题:目标不一定总是尽可能地构建最小的图像。较小的图像通常是一个好主意,因为它们需要更少的时间来上传和下载,但前提是它们中的所有图层都没有被缓存。如今,图像注册非常复杂,您很容易通过尝试巧妙地构建图像而失去这些功能的好处。如果您使用通用基础层,图像的总大小就不再那么重要了,而且随着注册中心和平台的发展,它可能变得更不重要。话虽如此,尝试优化应用程序映像中的层仍然很重要且有用。然而,

更好的 Dockerfile

由于 JAR 本身的打包方式,Spring Boot fat JAR 自然有“层”。如果我们先解包,它已经分为外部依赖和内部依赖。要在 docker 构建中一步完成此操作,我们需要先解压缩 JAR。以下命令(坚持使用 Maven,但 Gradle 版本非常相似)解压缩 Spring Boot fat JAR:

mkdir target/dependency
(cd target/dependency; jar -xf ../*.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"]复制

现在有三层,所有应用程序资源都在后面两层。如果应用程序依赖没有改变,第一层(from  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 openjdk:8-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.JarLauncher"]

Spring Boot fatJarLauncher是从 JAR 中提取到镜像中的,因此它可以用于启动应用程序,而无需对主应用程序类进行硬编码。

有关使用分层功能的更多信息,请参阅Spring Boot 文档。

调整

如果您想尽快启动您的应用程序(大多数人都这样做),您可能会考虑一些调整:

  • 使用 spring-context-indexer (链接到文档)。它不会为小型应用程序增加太多,但每一点都有帮助。
  • 如果您负担得起,请不要使用执行器。
  • 使用 Spring Boot 2.1(或更高版本)和 Spring 5.1(或更高版本)。
  • 使用(通过命令行参数、系统属性或其他方法)修复Spring Boot 配置文件的位置。 spring.config.location
  • 通过设置来关闭 JMX(您可能不需要在容器中使用它) spring.jmx.enabled=false
  • 使用 -noverify . 还要考虑 -XX:TieredStopAtLevel=1 (这会在以后减慢 JIT 但会缩短启动时间)。
  • 使用 Java 8 的容器内存提示: -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap . 在 Java 11 中,默认情况下这是自动的。

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

多阶段构建

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

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

RUN 每个源代码更改的构建都很慢,因为必须在第一部分重新创建 Maven 缓存。但是你有一个完全独立的构建,只要他们有 docker,任何人都可以运行它来运行你的应用程序。这在某些环境中可能非常有用——例如,您需要与不了解 Java 的人共享您的代码。

实验功能

Docker 18.06 带有一些“实验性”特性,包括缓存构建依赖项的方法。要打开它们,您需要在守护进程 (  dockerd ) 中有一个标志,并在运行客户端时需要一个环境变量。然后你可以添加一个“神奇”的第一行到你的 Dockerfile

Dockerfile:

# syntax=docker/dockerfile:experimental复制

然后该 RUN 指令接受一个新标志: --mount . 以下清单显示了一个完整示例:

Dockerfile:

# syntax=docker/dockerfile:experimental
FROM openjdk:8-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 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"]复制

然后你可以运行它:

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 openjdk:8-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/*.jar)
FROM openjdk:8-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您使用的版本。检查您拥有的版本的文档(前面显示的示例对于docker18.0.6 是正确的)。

安全方面

就像在经典 VM 部署中一样,进程不应以 root 权限运行。相反,映像应包含运行应用程序的非 root 用户。

在 a Dockerfile 中,您可以通过添加另一个添加(系统)用户和组并将其设置为当前用户(而不是默认的 root)的层来实现此目的:

Dockerfile:

FROM openjdk:8-jdk-alpine
RUN addgroup -S demo && adduser -S demo -G demo
USER demo
...复制

如果有人设法突破您的应用程序并在容器内运行系统命令,这种预防措施会限制他们的能力(遵循最小权限原则)。

一些进一步的Dockerfile命令只能以 root 身份运行,因此您可能必须将 USER 命令进一步向下移动(例如,如果您计划在容器中安装更多包,它只能以 root 身份运行)。

对于其他方法,不使用 aDockerfile可能更适合。例如,在后面描述的 buildpack 方法中,大多数实现默认使用非 root 用户。

另一个考虑因素是大多数应用程序在运行时可能不需要完整的 JDK,因此一旦我们进行了多阶段构建,我们就可以安全地切换到 JRE 基础映像。因此,在前面显示的多阶段构建中,我们可以将其用于最终的可运行映像:

Dockerfile:

FROM openjdk:8-jre-alpine
...复制

如前所述,这也节省了映像中的一些空间,这些空间将被运行时不需要的工具占用。