前言

在微服务开发中,使用 Spring Boot 往往是将全部内容打入一个 fat jar 中,提供外部调用。但是在使用Docker时,因为每次构建都会将一个 fat jar 单独构建一层,导致存放Docker镜像的速度快速膨胀。为了解决这个问题,需要对如何构建Docker镜像,如何写Dockerfile及如何对Dockerfile优化进行研究。

系统环境:

  • Docker 版本: 19.03.5 Docker Desktop
  • 基础镜像版本: adoptopenjdk:8-jre-openj9

一、 探究常规 Spring Boot 是如何构建 Docker 镜像

这里将使用常规 Spring Boot 的配置构建一个 Docker 镜像的Dockerfile 写法,感受一下这种方式编译的镜像使用的情况。

1. 准备 Spring Boot 项目

这里我们准备一个标准的 Spring Boot项目来构建Docker镜像。项目内容如下图所示:

使用Maven构建之后,可以看到jar包大小是 16.7 MB。

2. 准备 Dockerfile 文件

构建Docker镜像需要提前准备 Dockerfile 文件,这个文件中的内容为构建 Docker 镜像执行的指令。下面是一个常用的 Spring Boot 构建 Docker 镜像的 Dockerfile, 将它放入 Java 源码目录(target的上级目录),确保下面设置的 Dockerfile脚本中设置的路径和 target 路径对应。

FROM adoptopenjdk:8-jre-openj9
RUN echo "Asia/Shanghai" > /etc/timezone
VOLUME /tmp

EXPOSE 8080
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-Xshareclasses","-Xquickstart","-jar","/app.jar"]

ADD target/*.jar app.jar
RUN bash -c 'touch /app.jar'

3. 构建 Docker 镜像

通过 Docker 命令构建 Docker 镜像

docker build -t demo:v1 .

构建过程

Sending build context to Docker daemon  17.88MB
Step 1/7 : FROM adoptopenjdk:8-jre-openj9
 ---> 1fb7eb3cfd1d
Step 2/7 : RUN echo "Asia/Shanghai" > /etc/timezone
 ---> Using cache
 ---> 688c821c70ef
Step 3/7 : VOLUME /tmp
 ---> Running in e89638149d1c
Removing intermediate container e89638149d1c
 ---> 246f72a01e02
Step 4/7 : EXPOSE 8080
 ---> Running in 896e0d98b841
Removing intermediate container 896e0d98b841
 ---> abe5b273c2e9
Step 5/7 : ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-Xshareclasses","-Xquickstart","-jar","/app.jar"]
 ---> Running in 2c975b030b04
Removing intermediate container 2c975b030b04
 ---> 4c89e6c6dc2c
Step 6/7 : ADD target/*.jar app.jar
 ---> db9385446c45
Step 7/7 : RUN bash -c 'touch /app.jar'
 ---> Running in d5ca2eba74ca
Removing intermediate container d5ca2eba74ca
 ---> 2fd7d6d42ce9
Successfully built 2fd7d6d42ce9
Successfully tagged demo:v1

查看该镜像各层的大小

执行命令:

docker history 2fd7d6d42ce9

输出内容

IMAGE               CREATED              CREATED BY                                      SIZE                COMMENT
2fd7d6d42ce9        About a minute ago   /bin/sh -c bash -c 'touch /app.jar'             17.6MB
db9385446c45        About a minute ago   /bin/sh -c #(nop) ADD file:01df0e80f3c861304…   17.6MB
4c89e6c6dc2c        About a minute ago   /bin/sh -c #(nop)  ENTRYPOINT ["java" "-Djav…   0B
abe5b273c2e9        About a minute ago   /bin/sh -c #(nop)  EXPOSE 8080                  0B
246f72a01e02        About a minute ago   /bin/sh -c #(nop)  VOLUME [/tmp]                0B
688c821c70ef        8 hours ago          /bin/sh -c echo "Asia/Shanghai" > /etc/timez…   14B
1fb7eb3cfd1d        6 days ago           /bin/sh -c #(nop)  ENV JAVA_TOOL_OPTIONS=-XX…   0B
<missing>           6 days ago           /bin/sh -c #(nop)  ENV JAVA_HOME=/opt/java/o…   0B
<missing>           6 days ago           /bin/sh -c set -eux;     ARCH="$(dpkg --prin…   127MB
<missing>           6 days ago           /bin/sh -c #(nop)  ENV JAVA_VERSION=jdk8u242…   0B
<missing>           6 days ago           /bin/sh -c apt-get update     && apt-get ins…   33.5MB
<missing>           6 days ago           /bin/sh -c #(nop)  ENV LANG=en_US.UTF-8 LANG…   0B
<missing>           6 days ago           /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B
<missing>           6 days ago           /bin/sh -c mkdir -p /run/systemd && echo 'do…   7B
<missing>           6 days ago           /bin/sh -c set -xe   && echo '#!/bin/sh' > /…   745B
<missing>           6 days ago           /bin/sh -c [ -z "$(apt-get indextargets)" ]     987kB
<missing>           6 days ago           /bin/sh -c #(nop) ADD file:594fa35cf803361e6…   63.2MB

4. 修改 Java 源代码之后重新打包jar后再次尝试

> docker build -t demo:v2 .
Sending build context to Docker daemon  17.88MB
Step 1/7 : FROM adoptopenjdk:8-jre-openj9
 ---> 1fb7eb3cfd1d
Step 2/7 : RUN echo "Asia/Shanghai" > /etc/timezone
 ---> Using cache
 ---> 688c821c70ef
Step 3/7 : VOLUME /tmp
 ---> Using cache
 ---> 246f72a01e02
Step 4/7 : EXPOSE 8080
 ---> Using cache
 ---> abe5b273c2e9
Step 5/7 : ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-Xshareclasses","-Xquickstart","-jar","/app.jar"]
 ---> Using cache
 ---> 4c89e6c6dc2c
Step 6/7 : ADD target/*.jar app.jar
 ---> 332a575d6f93
Step 7/7 : RUN bash -c 'touch /app.jar'
 ---> Running in cf743c9f9811
Removing intermediate container cf743c9f9811
 ---> 89cfccc12029
Successfully built 89cfccc12029
Successfully tagged demo:v2

> docker history 89cfccc12029
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
89cfccc12029        32 seconds ago      /bin/sh -c bash -c 'touch /app.jar'             17.6MB
332a575d6f93        33 seconds ago      /bin/sh -c #(nop) ADD file:6dd0fde95d358414c…   17.6MB
4c89e6c6dc2c        5 minutes ago       /bin/sh -c #(nop)  ENTRYPOINT ["java" "-Djav…   0B
abe5b273c2e9        5 minutes ago       /bin/sh -c #(nop)  EXPOSE 8080                  0B
246f72a01e02        5 minutes ago       /bin/sh -c #(nop)  VOLUME [/tmp]                0B
688c821c70ef        8 hours ago         /bin/sh -c echo "Asia/Shanghai" > /etc/timez…   14B
1fb7eb3cfd1d        6 days ago          /bin/sh -c #(nop)  ENV JAVA_TOOL_OPTIONS=-XX…   0B
<missing>           6 days ago          /bin/sh -c #(nop)  ENV JAVA_HOME=/opt/java/o…   0B
<missing>           6 days ago          /bin/sh -c set -eux;     ARCH="$(dpkg --prin…   127MB
<missing>           6 days ago          /bin/sh -c #(nop)  ENV JAVA_VERSION=jdk8u242…   0B
<missing>           6 days ago          /bin/sh -c apt-get update     && apt-get ins…   33.5MB
<missing>           6 days ago          /bin/sh -c #(nop)  ENV LANG=en_US.UTF-8 LANG…   0B
<missing>           6 days ago          /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B
<missing>           6 days ago          /bin/sh -c mkdir -p /run/systemd && echo 'do…   7B
<missing>           6 days ago          /bin/sh -c set -xe   && echo '#!/bin/sh' > /…   745B
<missing>           6 days ago          /bin/sh -c [ -z "$(apt-get indextargets)" ]     987kB
<missing>           6 days ago          /bin/sh -c #(nop) ADD file:594fa35cf803361e6…   63.2MB

感受

通过这种方式对 Spring Boot 项目构建 Docker 镜像来使用,只要源代码发生一点改变,那么 Spring Boot 项目就需要将项目经过 Maven 编译后在经过 Docker 镜像构建,每次都会将一个十几MB以上的应用jar文件存入Docker中,这只是一个示例程序,实际项目中,往往会添加大量的依赖,构建的jar文件往往是上百MB的文件,这样对镜像存储,网络传输都会有很大的压力。

了解 Docker 分层及缓存机制

1. Docker 分层缓存简介

Docker 为了节约存储空间,所以采用了分层存储的概念,共享数据会对镜像和容器进行分层,不同镜像可以共享数据,并且在镜像上为容器分配一个RW曾来加快容器的启动顺序。

在构建镜像的过程中 Docker 将按照 Dockerfile 中指定的顺序逐步执行 Dockerfile 中的指令。随着每条指令的检查,Docker 将在其缓存中查找可重用的现有镜像,而不是创建一个新的(重复)镜像。

Dockerfile 的每一行命令都创建新的一层,包含了这一行命令执行前后文件系统的变化。为了优化这个过程,Docker 使用了一种缓存机制:只要这一行命令不变,那么结果和上一次是一样的,直接使用上一次的结果即可。

为了充分利用层级缓存,我们必须要理解 Dockerfile 中的命令行是如何工作的,尤其是RUN,ADD和COPY这几个命令。

参考 Docker 文档了解 Docker 镜像缓存:https://docs.docker.com/develop/develop-images/dockerfile_best-practices/

2、SpringBoot Docker 镜像的分层

SpringBoot 编译成镜像后,底层会是一个系统,如 Ubantu,上一层是依赖的 JDK 层,然后才是 SpringBoot 层,最下面两层我们无法操作,考虑优化只能是 SpringBoot 层琢磨。

是什么导致 jar 包臃肿

在我们的项目中,占据绝大部分存储空间的内容是项目的依赖,源代码通常占比非常小,往往只占据 fat jar 的2%或者3%。既然如此,那么如果我们构建一个只包含源代码的jar包,那么它的大小可能只有几百KB,现在要探究一下如何将依赖的 jar 和我们的源代码编译成的 class 文件进行分离。

在 Spring Boot 的 Maven 插件在执行 Maven 编译打包的时候做了很多事情,如果改变某些插件的打包逻辑,将构建应用 jar 包时,将依赖的 jar 文件都拷贝到应用 jar 外面,只留下编译好的字节码文件。那么我们就可以实现对 Docker 镜像的瘦身了。

解决依赖与 class 文件分离

我们引入以下几个插件:

<!--设置应用 Main 参数启动依赖查找的地址指向外部 lib 文件夹-->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-jar-plugin</artifactId>
    <configuration>
        <archive>
            <manifest>
                <addClasspath>true</addClasspath>
                <classpathPrefix>lib/</classpathPrefix>
            </manifest>
        </archive>
    </configuration>
</plugin>
<!--设置 SpringBoot 打包插件不包含任何 Jar 依赖包-->
<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
         <includes>
             <include>
                 <groupId>nothing</groupId>
                 <artifactId>nothing</artifactId>
             </include>
         </includes>
    </configuration>
</plugin>
<!--设置将 lib 拷贝到应用 Jar 外面-->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-dependency-plugin</artifactId>
    <executions>
        <execution>
            <id>copy-dependencies</id>
            <phase>prepare-package</phase>
            <goals>
                <goal>copy-dependencies</goal>
            </goals>
            <configuration>
                <outputDirectory>${project.build.directory}/lib</outputDirectory>
            </configuration>
        </execution>
    </executions>
</plugin>

执行 Maven 的构建命令来构建 jar

mvn clean package

我们会发现在 target 目录下出现了一个 lib目录,该目录下是我们工程依赖的全部jar。并在 target 目录下可以看到一个体积变得很小的 jar包。我们来测试一下这个 jar 是否是可以执行。

java -jar demo-0.0.1-SNAPSHOT.jar

我们可以看到正常输出的日志,并且可以正常的输出。

改造 Spring Boot 的Dockerfile

修改 Dockerfile 文件

修改上面的 Dockerfile 文件,增加一层指令用于将 lib 目录里面依赖的 jar 复制到镜像中, 其它保持和上面的 Dockerfile 一致。

FROM adoptopenjdk:8-jre-openj9
RUN echo "Asia/Shanghai" > /etc/timezone
VOLUME /tmp

EXPOSE 8080
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-Xshareclasses","-Xquickstart","-jar","/app.jar"]

COPY target/lib/* /lib/

ADD target/*.jar app.jar
RUN bash -c 'touch /app.jar'

这里我们需要确保拷贝依赖jar的命令在复制应用jar之前,这样改造之后,每次只要lib目录下的依赖的jar不变,就不会重新创建层,而是复用缓存。

测试改造后的镜像构建

我们执行命令

> docker build -t demo:v3 .
Sending build context to Docker daemon  29.83MB
Step 1/8 : FROM adoptopenjdk:8-jre-openj9
 ---> 1fb7eb3cfd1d
Step 2/8 : RUN echo "Asia/Shanghai" > /etc/timezone
 ---> Using cache
 ---> 688c821c70ef
Step 3/8 : VOLUME /tmp
 ---> Using cache
 ---> 246f72a01e02
Step 4/8 : EXPOSE 8080
 ---> Using cache
 ---> abe5b273c2e9
Step 5/8 : ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-Xshareclasses","-Xquickstart","-jar","/app.jar"]
 ---> Using cache
 ---> 4c89e6c6dc2c
Step 6/8 : COPY target/lib/* /lib/
 ---> c4c250ff14a2
Step 7/8 : ADD target/*.jar app.jar
 ---> a487348d5357
Step 8/8 : RUN bash -c 'touch /app.jar'
 ---> Running in 569a21f89ea4
Removing intermediate container 569a21f89ea4
 ---> 354e90509311
Successfully built 354e90509311
Successfully tagged demo:v3

我们来看一下这个镜像每层的大小

> docker history 354e90509311
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
354e90509311        51 seconds ago      /bin/sh -c bash -c 'touch /app.jar'             102kB
a487348d5357        52 seconds ago      /bin/sh -c #(nop) ADD file:6eab181d1b45a5e59…   102kB
c4c250ff14a2        53 seconds ago      /bin/sh -c #(nop) COPY multi:3a758228837efec…   29.4MB
4c89e6c6dc2c        37 minutes ago      /bin/sh -c #(nop)  ENTRYPOINT ["java" "-Djav…   0B
abe5b273c2e9        37 minutes ago      /bin/sh -c #(nop)  EXPOSE 8080                  0B
246f72a01e02        37 minutes ago      /bin/sh -c #(nop)  VOLUME [/tmp]                0B
688c821c70ef        9 hours ago         /bin/sh -c echo "Asia/Shanghai" > /etc/timez…   14B
1fb7eb3cfd1d        6 days ago          /bin/sh -c #(nop)  ENV JAVA_TOOL_OPTIONS=-XX…   0B
<missing>           6 days ago          /bin/sh -c #(nop)  ENV JAVA_HOME=/opt/java/o…   0B
<missing>           6 days ago          /bin/sh -c set -eux;     ARCH="$(dpkg --prin…   127MB
<missing>           6 days ago          /bin/sh -c #(nop)  ENV JAVA_VERSION=jdk8u242…   0B
<missing>           6 days ago          /bin/sh -c apt-get update     && apt-get ins…   33.5MB
<missing>           6 days ago          /bin/sh -c #(nop)  ENV LANG=en_US.UTF-8 LANG…   0B
<missing>           6 days ago          /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B
<missing>           6 days ago          /bin/sh -c mkdir -p /run/systemd && echo 'do…   7B
<missing>           6 days ago          /bin/sh -c set -xe   && echo '#!/bin/sh' > /…   745B
<missing>           6 days ago          /bin/sh -c [ -z "$(apt-get indextargets)" ]     987kB
<missing>           6 days ago          /bin/sh -c #(nop) ADD file:594fa35cf803361e6…   63.2MB

这里我们和未改造之前进行对比可以发现,虽然这里层数变多了,但是,我们的应用在依赖层之上,这样依赖不发生变化的情况下,我们每次都可以复用这一层,来减小镜像存储服务与网络的压力。下面,我们对源代码做一些修改,然后重新构建镜像,来确认这一点。

> docker build -t demo:v4 .
Sending build context to Docker daemon  29.83MB
Step 1/8 : FROM adoptopenjdk:8-jre-openj9
 ---> 1fb7eb3cfd1d
Step 2/8 : RUN echo "Asia/Shanghai" > /etc/timezone
 ---> Using cache
 ---> 688c821c70ef
Step 3/8 : VOLUME /tmp
 ---> Using cache
 ---> 246f72a01e02
Step 4/8 : EXPOSE 8080
 ---> Using cache
 ---> abe5b273c2e9
Step 5/8 : ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-Xshareclasses","-Xquickstart","-jar","/app.jar"]
 ---> Using cache
 ---> 4c89e6c6dc2c
Step 6/8 : COPY target/lib/* /lib/
 ---> Using cache
 ---> c4c250ff14a2
Step 7/8 : ADD target/*.jar app.jar
 ---> ab3636050d5d
Step 8/8 : RUN bash -c 'touch /app.jar'
 ---> Running in 229e24ee64a7
Removing intermediate container 229e24ee64a7
 ---> 653569e97e04
Successfully built 653569e97e04
Successfully tagged demo:v4

我们再看一下各层的大小

>docker history 653569e97e04
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
653569e97e04        33 seconds ago      /bin/sh -c bash -c 'touch /app.jar'             102kB
ab3636050d5d        34 seconds ago      /bin/sh -c #(nop) ADD file:1d0a8f5780456dc2b…   102kB
c4c250ff14a2        4 minutes ago       /bin/sh -c #(nop) COPY multi:3a758228837efec…   29.4MB
4c89e6c6dc2c        41 minutes ago      /bin/sh -c #(nop)  ENTRYPOINT ["java" "-Djav…   0B
abe5b273c2e9        41 minutes ago      /bin/sh -c #(nop)  EXPOSE 8080                  0B
246f72a01e02        41 minutes ago      /bin/sh -c #(nop)  VOLUME [/tmp]                0B
688c821c70ef        9 hours ago         /bin/sh -c echo "Asia/Shanghai" > /etc/timez…   14B
1fb7eb3cfd1d        6 days ago          /bin/sh -c #(nop)  ENV JAVA_TOOL_OPTIONS=-XX…   0B
<missing>           6 days ago          /bin/sh -c #(nop)  ENV JAVA_HOME=/opt/java/o…   0B
<missing>           6 days ago          /bin/sh -c set -eux;     ARCH="$(dpkg --prin…   127MB
<missing>           6 days ago          /bin/sh -c #(nop)  ENV JAVA_VERSION=jdk8u242…   0B
<missing>           6 days ago          /bin/sh -c apt-get update     && apt-get ins…   33.5MB
<missing>           6 days ago          /bin/sh -c #(nop)  ENV LANG=en_US.UTF-8 LANG…   0B
<missing>           6 days ago          /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B
<missing>           6 days ago          /bin/sh -c mkdir -p /run/systemd && echo 'do…   7B
<missing>           6 days ago          /bin/sh -c set -xe   && echo '#!/bin/sh' > /…   745B
<missing>           6 days ago          /bin/sh -c [ -z "$(apt-get indextargets)" ]     987kB
<missing>           6 days ago          /bin/sh -c #(nop) ADD file:594fa35cf803361e6…   63.2MB

我们可以发现 依赖那一层的是复用的上一次构建时产生的,而不是重新构建得到的。