在Docker环境构建、打包和运行Spring Boot应用

浅析如何在Docker容器环境下设计和部署一个基本的Spring Boot应用
Oct 09, 2019, by Brian Hannaway

为何考虑采用Docker?

Docker是提供用户构建镜像的一种容器化技术,所构建的镜像包含了主要的应用程序和运行应用所需的所有依赖项。该映像可在任何任何虚拟机或物理机器上的Docker容器上运行。它的强大之处在于允许用户在开发、测试、预生产和生产中运行同样的映像,而不必担心在每个环境中依赖项的安装或配置。

采用Docker构建和运行应用

以Java程序员的视角看,Docker的典型应用场景是在容器内运行应用。这固然不错,但如果Docker能提供应用的构建是不是更好?本文中,我将演示如何在容器内用Docker来编排、构建和运行Spring Boot应用。请先按如下步骤创建一个Docker镜像:

  • 从源主机复制应用程序源代码到镜像的临时构建目录
  • 采用Maven完成应用的编译和打包,生成可执行的JAR文件
  • 采用JRE运行JAR文件

镜像大小的提示

关注所构建镜像文件的大小非常重要。较小的镜像文件具有更快的构建速度、下载速度和更低的存储成本优势。所以要尽可能地让镜像只包括所需的几项组件即可。

采用较小的基本镜像

同样的道理,选用只包含必须功能的基础镜像文件也是最佳的选择。本文后续采用Alpine镜像也是基于同样考虑,Alpine是只有5MB的超细Linux发行版。非常适合构建精细的镜像。同时Alpine提供一个包管理器,让用户可以安装任何需要的包。但由于Alpine的初始包非常小,所以安装大量包的过程会有些麻烦。如果有看DockerHub的话,就会发现很多流行的镜像都提供了Alpine版,可以直接使用。后续我们也将用到Alpine版本的Maven和open JDK JRE映像。

抛弃不需要的内容

在稍后过程中所定义编译、打包并运行的Spring Boot应用的镜像。就是可部署运行的最终Docker映像,因此它只需要包含应用本身和运行时依赖项,能够满足在单个容器中构建和运行就可以了。也就是说它可以纯粹就是可执行的JAR包和运行所需的Java JRE文件,而无需包含Maven(包括本地Maven库)或目标目录的全部内容。
那么,用户所要做的就是构建应用,然后从最终映像中剔除不需要的内容。这个正是多阶段构建的作用所在。它允许用户将Docker构建分解为不同的步骤,并在步骤之间复制特定的目标项,抛弃非必须的内容,从而实现抛弃构建工具本身和其他对应用没有关联的内容。

测试案例执行步骤

项目构建非常简单,举个例子,我用一个类创建一个标准的Spring Boot应用,并在项目的根目录中添加了一个Dockerfile。(用户可在GitHub上获取这个实验的完整源代码。同步实验。)
主类的代码显示如下,且没有添加任何其他内容。接下来我将采用默认的执行器健康状况端点来测试这个应用。

package com.blog.samples.docker;

import org.springframework.boot.SpringApplication;

import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication

public class Application {

public static void main(String[] args) {

    SpringApplication.run(Application.class, args);

}  

}

定义Docker镜像

如下内容是Dockerfile中定义的镜像文件,尽管内容不多,但包含了很多步的工作。我将在下面详细解释每一行。

FROM maven:3.5.2-jdk-8-alpine AS MAVEN_BUILD

MAINTAINER Brian Hannaway

COPY pom.xml /build/

COPY src /build/src/

WORKDIR /build/

RUN mvn package

FROM openjdk:8-jre-alpine

WORKDIR /app

COPY --from=MAVEN_BUILD /build/target/docker-boot-intro-0.1.0.jar /app/

ENTRYPOINT ["java", "-jar", "docker-boot-intro-0.1.0.jar"]

代码备注:

FROM maven:3.5.2-jdk-8-alpine AS MAVEN_BUILD:

告知Docker采用Maven编译器。

maven:3.5.2-jdk-8-alpine 

构建第一步采用的基础镜像,Docker将首先在本地查找镜像,本地不存在后,将从DockerHub拉取。 Maven会在最后阶段被剔除掉 (后续 COPY命令介绍) 考虑下载快速和镜像大小控制的原因,选择Alpine版的Maven镜像。

MAINTAINER Brian Hannaway

非必选项,但是为映像作者提供一个接触点可提高可维护性。(本实验应用验证的点)

COPY pom.xml /build/ 

在镜像中创建一个build目录, 并拷入pom.xml文件。

COPY src /build/src/ 

拷入src 目录到镜像中build目录。

WORKDIR /build/ 

设置build 为工作目录. 后续任何命令都在此目录中运行。

RUN mvn package

执行mvn包来运行编译和打包应用,生成成可执行的JAR文件。在第一次构建映像时,Maven将从公共Maven库拉取所有需要的依赖项,并将它们缓存在映像的本地。后续的构建将使用这个缓存版的映像层,这意味着依赖项将在本地引用,而不必再次从外部拉取。
至此,已经完成了映像定义,只需等其构建成一个可执行的JAR文件。这是多阶段构建的第一部分。下一阶段将获取JAR并运行它。

FROM openjdk:8-jre-alpine

告知Docker多阶段构建的下一步采用openjdk:8-jre-alpine的基础镜像. 再次使用Java 8 JRE的Alpine版本,这一步的选择其实比前面的Maven版本选择更为重要,因为存在于最终版的镜像只是 openjdk:8-jre-alpine,因此如果要尽可能控制最终镜像大小的话,选择轻量级JRE镜像就非常重要.

WORKDIR /app

告知Docker在镜像内创建另一个/app工作目录.后续任何命令都在此目录中运行。

COPY --from=MAVEN_BUILD /build/target/docker-boot-intro-0.1.0.jar /app/ 

告知Docker从MAVEN_BUILD 阶段的/build/target目录复制ocker-boot-intro-0.1.0.jar 到 /app 目录.
如前文所述,多阶段构建的优势就是允许用户将特定的内容从一个构建阶段复制到另一个构建阶段,并丢弃其他所有的内容。如果需要保留从MAVEN_BUILD阶段开始的所有内容,那最终镜像会包含Maven(包括Maven本地库)工具,以及目标目录中生成的所有类文件。通过从MAVEN_BUILD阶段选择必须要的内容,那最终得到的映像会小很多。

ENTRYPOINT ["java", "-jar", "app.jar"] 

告知Docker在容器运行本镜像时,运行哪些命令。本部分用冒号进行多命令的隔离。本案例中,需要把执行JAR文件复制到/app目录运行。

构建镜像

完成Docker镜像定义后,就可以着手构建。打开包含Dockerfile(根目录)的目录。运行以下命令构建映像:

docker image build -t docker-boot-intro

-t参数为指定名称和可选标签。如果不指定标签,Docker会自动标记为最latest。

$ docker image build -t docker-boot-intro .

Sending build context to Docker daemon  26.56MB

Step 1/10 : FROM maven:3.5.2-jdk-8-alpine AS MAVEN_BUILD

---> 293423a981a7

Step 2/10 : MAINTAINER Brian Hannaway

---> Using cache

---> db354a426bfd

Step 3/10 : COPY pom.xml /build/

---> Using cache

---> 256340699bc3

Step 4/10 : COPY src /build/src/

---> Using cache

---> 65eb0f98bb79

Step 5/10 : WORKDIR /build/

---> Using cache

---> b16b294b6b74

Step 6/10 : RUN mvn package

---> Using cache

---> c48659e0197e

Step 7/10 : FROM openjdk:8-jre-alpine

---> f7a292bbb70c

Step 8/10 : WORKDIR /app

---> Using cache

---> 1723d5b9c22f

Step 9/10 : COPY --from=MAVEN_BUILD /build/target/docker-boot-intro-0.1.0.jar /app/

---> Using cache

---> d0e2f8fbe5c9

Step 10/10 : ENTRYPOINT ["java", "-jar", "docker-boot-intro-0.1.0.jar"]

---> Using cache

---> f265acb14147

Successfully built f265acb14147

Successfully tagged docker-boot-intro:latest

SECURITY WARNING: You are building a Docker image from Windows against a non-Windows Docker host. All files and directories added to build context will have '-rwxr-xr-x' permissions. It is recommended to double check and reset permissions for sensitive files and directories.

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro (master)

运行构建时,Docker将逐条执行Docker文件中的每个命令。为每个步骤创建一个带有唯一ID的层。例如,步骤1创建的的层的ID为293423a981a7。
第一次构建图像时,Docker将从DockerHub获取它需要的任何外部图像,然后在此之上开始构建新的层。这会使得第一次构建速度非常慢。
在构建过程中,Docker在尝试构建层之前会检查缓存,看看是否已经有所构建层的缓存版本。如果该层的缓存版本可用,Docker将直接使用它而不是从头开始构建。这意味着一旦构建了一个映像层,后续的构建就是重用,速度会快很多。您可以在上面的构建输出中通过Docker缓存输出的hash值看到使用了缓存层。以上面第6步所发生的为例:
作为RUN mvn包命令的一部分,Docker将从公共Maven库获取所有POM依赖项,构建成一个可执行JAR,并将所有这些内容存储在ID为c48659e0197e的层中。下一次构建这个映像时,Maven依赖项和应用程序JAR将从缓存层中取出,而不必再次下载和构建。

镜像大小

运行 docker image ls 命令将罗列出所有的本地镜像。可发现docker-boot-intro 镜像大小为105 MB.

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro (master)

$ docker image ls

REPOSITORY                               TAG                  IMAGE ID            CREATED             SIZE

docker-boot-intro                        latest               823730301d60        15 minutes ago      105MB

                                                  853d42b823c3        15 minutes ago      136MB

                                                  39ac5e9e9562        19 minutes ago      105MB

                                                  dfda2356bd36        19 minutes ago      136MB

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro (master)

我在前文中提到过尽可能保持镜像大小的最佳实践,接下来让我们细探一下docker-boot-intro映像的105MB由什么组成的。运行如下命令:

docker image history boot-docker-intro 

将看到镜像中各个层的内容情况。

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro/target (master)

$ docker image history docker-boot-intro

IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT

823730301d60        19 minutes ago      /bin/sh -c #(nop)  ENTRYPOINT ["java" "-jar"...   0B

7e43d899f02f        19 minutes ago      /bin/sh -c #(nop) COPY file:05f3666306f8c7af...   20.1MB

1723d5b9c22f        6 days ago          /bin/sh -c #(nop) WORKDIR /app                  0B

f7a292bbb70c        4 months ago        /bin/sh -c set -x  && apk add --no-cache   o...   79.4MB

           4 months ago        /bin/sh -c #(nop)  ENV JAVA_ALPINE_VERSION=8...   0B

           4 months ago        /bin/sh -c #(nop)  ENV JAVA_VERSION=8u212       0B

           4 months ago        /bin/sh -c #(nop)  ENV PATH=/usr/local/sbin:...   0B

           4 months ago        /bin/sh -c #(nop)  ENV JAVA_HOME=/usr/lib/jv...   0B

           4 months ago        /bin/sh -c {   echo '#!/bin/sh';   echo 'set...   87B

           4 months ago        /bin/sh -c #(nop)  ENV LANG=C.UTF-8             0B

           4 months ago        /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B

           4 months ago        /bin/sh -c #(nop) ADD file:a86aea1f3a7d68f6a...   5.53MB

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro/target (master)

如上所显示5.53 MB的Alpine 基础镜像处于第一层。在之上的几层配置了一系列的环境变量,然后是大小为79.4 MB的JRE文件. 最后的3层是我们在Dockerfile中定义的层,并包含了20.1 MB的应用JAR. 可以发现这个镜像只包括了运行应用所必须的组件,是一个非常不错的轻量级镜像。

运行容器

映像构建好后,可以使用以下命令运行一个容器:

docker container run -p 8080:8080 docker-boot-intro

run命令包括一个可选的-p参数,作用是允许用户将容器应用的端口映射到主机的端口。熟悉Spring Boot的人都知道,应用程序的默认启动端口就是8080。运行一个容器时,Docker将运行可执行JAR文件来启动应用,使用容器的8080端口。但如果要访问容器中的应用,需要通过主机的端口访问,通过端口映射去到容器端口。-p 8080:8080参数就是将容器端口8080映射到主机端口8080。如果没有异常的话,应该可以看到应用程序在端口8080成功启动的信息。

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro/target (master)

$ docker container run -p 8080:8080 docker-boot-intro

.   ____          _            __ _ _

/\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \

( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \

\\/  ___)| |_)| | | | | || (_| |  ) ) ) )

'  |____| .__|_| |_|_| |_\__, | / / / /

=========|_|==============|___/=/_/_/_/

:: Spring Boot ::        (v2.1.7.RELEASE)

5436 [main] INFO  com.blog.samples.docker.Application - Starting Application v0.1.0 on 934a1d731576 with PID 1 (/app/docker-boot-intro-0.1.0.jar started by root in /app)

5466 [main] INFO  com.blog.samples.docker.Application - No active profile set, falling back to default profiles: default

16585 [main] INFO  o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port(s): 8080 (http)

16742 [main] INFO  o.a.coyote.http11.Http11NioProtocol - Initializing ProtocolHandler ["http-nio-8080"]

16886 [main] INFO  o.a.catalina.core.StandardService - Starting service [Tomcat]

16892 [main] INFO  o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/9.0.22]

17622 [main] INFO  o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext

17628 [main] INFO  o.s.web.context.ContextLoader - Root WebApplicationContext: initialization completed in 11614 ms

21399 [main] INFO  o.s.s.c.ThreadPoolTaskExecutor - Initializing ExecutorService 'applicationTaskExecutor'

23347 [main] INFO  o.s.b.a.e.web.EndpointLinksResolver - Exposing 2 endpoint(s) beneath base path '/actuator'

23695 [main] INFO  o.a.coyote.http11.Http11NioProtocol - Starting ProtocolHandler ["http-nio-8080"]

23791 [main] INFO  o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port(s): 8080 (http) with context path ''

23801 [main] INFO  com.blog.samples.docker.Application - Started Application in 21.831 seconds (JVM running for 25.901)

应用测试

如果看到类似于上面显示的信息输出,那表示容器已经顺利启动。接下来就可以测试应用。如果你在Windows或Mac上运行Docker,需要使用的工具是一个Linux虚拟机Docker Toolbox。需要通过运行docker-machine ip命令可以获得Linux VM的IP地址。本案例中的Linux VM IP是192.168.99.100。

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro (master)

$ docker-machine ip

192.168.99.100

获得IP后,可以使用cURL命令cURL 192.168.99.100:8080/actuator/health来调用应用的健康检查点来测试应用情况。如果应用程序启动并运行正常,即可获得HTTP 200的响应,响应内容为{“status”:“up”}。

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro (master)

$ curl 192.168.99.100:8080/actuator/health

% Total    % Received % Xferd  Average Speed   Time    Time     Time  Current

                             Dload  Upload   Total   Spent    Left  Speed

100    15    0    15    0     0    937      0 --:--:-- --:--:-- --:--:--   937{"status":"UP"

}

本方法的局限性

我在前文提到过,可以重用Docker缓存层以减少构建时间。虽然这是事实,但是在构建Java应用时需要考虑存在的例外。每当对Java源代码或POM文件进行更改后,Docker将会发现变更差异,从而忽略缓存的副本层,重新构建所需的层。这是正常的,但问题是这个变化会导致缓存中的Maven依赖项丢失。因此,当使用mvn包命令重新构建这个层时,所有Maven依赖项将再次从远程库中拉取一次,导致显著减慢了构建的速度,成为开发过程中真正的痛点。而且这个问题在构建没有Docker的Java应用程序时完全不存在,仅仅发生在使用Docker构建应用层时发生。

解决方案是什么?

目前解决这个问题的方法是使用主机上的本地Maven存储库作为Maven依赖项的源。通过卷告诉Docker去访问主机本地的Maven库,而非从公共库中拉取依赖项。这种方法可以解决这个问题。但也是有利有弊。
从好的方面看,您使用的是主机缓存的Maven依赖项,可以在更改源代码后,快速重新构建,节省了构建时间。
但不利的方面是Docker映像的管理因此而失去了一些自主性。使用Docker的主要初衷之一就是不必担心在其运行的环境中的软件配置。理想情况下,Docker映像应该是自我构建且拥有构建和运行所需的一切元素,而不必存在主机依赖。而这个方法恰好违背了这个初衷,让Docker构建失去了部分自主性。在下一篇文章中,我们将介绍Docker卷,并展示如何使用它们访问主机上的Maven 库。

结束语

在本文中,我们定义了一个Docker映像来构建和运行一个Spring Boot应用程序。我们讨论了让镜像保持尽可能小的重要性,可以通过使用超级小的Alpine基础镜像和在多阶段构建过程中进行内容剔除的方式来实现。我们还讨论了使用Docker构建Java应用程序的局限性和可能的解决方案。用户可以从 GitHub
获取文章中的测试完整源代码。
原文链接:(翻译:易理林)