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
,因此这是一个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 标识符代替名称,该名称也可以从 docker ps
中看到。
入口点
使用 Dockerfile ENTRYPOINT
的 exec 形式,这样就没有 shell 封装 Java 进程。优点是 java 进程将响应发送到容器的 KILL
信号。实际上,这意味着,例如,如果我们在本地 docker run
镜像,则可以使用 CTRL-C
停止它。如果命令行太长,我们可以将其提取到 shell 脚本中,然后将其 COPY
到镜像中,然后再运行它。例如:
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
这将失败,因为 ${}
替换需要使用 shell;exec 形式不使用 shell 来启动该进程,因此不会应用该选项。我们可以通过将入口点移动到脚本(例如上面的 run.sh
示例),或通过在入口点显式创建 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 使用 -Ddebug
生成的完整 DEBUG
输出的一部分。)
将 ENTRYPOINT
与上述的显式 shell 一起使用意味着我们可以将环境变量传递到 java 命令中,但是到目前为止,我们还不能向 Spring Boot 应用提供命令行参数。该技巧无法在端口 9000 上运行该应用:
$ docker run -p 9000:9000 myorg/myapp --server.port=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 进程。要解决该问题,我们需要将命令行从 CMD
添加到 ENTRYPOINT
:
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} ${@}"]
$ docker run -p 9000:9000 myorg/myapp --server.port=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 镜像只有一个文件系统層,其中包含胖 jar,我们对应用代码进行的每一次更改都会更改该层,该层可能为 10MB 或更多(对于某些应用甚至可能为 50MB)。我们可以通过将 JAR 分为多个层来改善这一点。
更小的镜像
请注意,上面示例中的基镜像是 openjdk:8-jdk-alpine。alpine 镜像比 Dockerhub 的标准 openjdk 库镜像小。尚无 Java 11 的 alpine 镜像(AdoptOpenJDK 已有一段时间,但不再出现在其 Dockerhub 页面上)。我们还可以使用 “jre” 标签代替 “jdk” 以在基镜像中节省大约 20MB。并不是所有的应用都可以与 JRE 一起使用(而不是 JDK),但是大多数应用都可以,并且确实有些组织强制制定了每个应用必须遵循的规则,因为存在滥用某些 JDK 功能(例如编译)的风险。
可以使我们获得更小的镜像的另一种技巧是使用与 OpenJDK 11 捆绑在一起的 JLink。JLink 允许我们从完整 JDK 中的模块子集构建自定义 JRE 分发,因此,在基镜像中我们不需要 JRE 或 JDK。原则上与使用 openjdk 官方 docker 镜像相比,这将使我们具有较小的总镜像大小。实际上,我们还无法(目前来说)在 JDK 11 上使用 alpine 基镜像,因此我们对基镜像的选择将受到限制,并且最终结果可能会更大。另外,我们自己的基镜像中的自定义 JRE 无法在其他应用之间共享,因为它们将需要不同的自定义。因此,对于所有应用来说,它们的镜像可能较小,但是它们的启动时间仍然较长,因为它们无法从缓存 JRE 层中受益。
最后一点突出了镜像构建者的一个真正重要的关键点:目标不一定总是要构建尽可能小的镜像。通常,较小的镜像是一个好主意,因为它们只需要较少的时间就可以上传和下载,但前提是它们中的所有层都没有被缓存。如今,镜像注册表非常复杂,通过尝试巧妙地构建镜像,我们很容易失去这些功能的优势。如果使用公共基础层,则镜像的总大小将不再是问题,随着注册管理机构和平台的发展,镜像的总大小可能甚至会减少。话虽如此,尝试和优化应用镜像中的各层仍然很重要,也很有用,但是目标始终应该是将变化最快的东西放在最高层,并尽可能多地与其他应用共享大型底层。