Spring Boot 分层构建Docker镜像
前言
在微服务开发中,使用 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
我们可以发现 依赖那一层的是复用的上一次构建时产生的,而不是重新构建得到的。