前言

在微服务开发中,使用 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 路径对应。

  1. FROM adoptopenjdk:8-jre-openj9
  2. RUN echo "Asia/Shanghai" > /etc/timezone
  3. VOLUME /tmp
  4. EXPOSE 8080
  5. ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-Xshareclasses","-Xquickstart","-jar","/app.jar"]
  6. ADD target/*.jar app.jar
  7. RUN bash -c 'touch /app.jar'

3. 构建 Docker 镜像

通过 Docker 命令构建 Docker 镜像

  1. docker build -t demo:v1 .

构建过程

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

查看该镜像各层的大小

执行命令:

  1. docker history 2fd7d6d42ce9

输出内容

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

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

  1. > docker build -t demo:v2 .
  2. Sending build context to Docker daemon 17.88MB
  3. Step 1/7 : FROM adoptopenjdk:8-jre-openj9
  4. ---> 1fb7eb3cfd1d
  5. Step 2/7 : RUN echo "Asia/Shanghai" > /etc/timezone
  6. ---> Using cache
  7. ---> 688c821c70ef
  8. Step 3/7 : VOLUME /tmp
  9. ---> Using cache
  10. ---> 246f72a01e02
  11. Step 4/7 : EXPOSE 8080
  12. ---> Using cache
  13. ---> abe5b273c2e9
  14. Step 5/7 : ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-Xshareclasses","-Xquickstart","-jar","/app.jar"]
  15. ---> Using cache
  16. ---> 4c89e6c6dc2c
  17. Step 6/7 : ADD target/*.jar app.jar
  18. ---> 332a575d6f93
  19. Step 7/7 : RUN bash -c 'touch /app.jar'
  20. ---> Running in cf743c9f9811
  21. Removing intermediate container cf743c9f9811
  22. ---> 89cfccc12029
  23. Successfully built 89cfccc12029
  24. Successfully tagged demo:v2
  25. > docker history 89cfccc12029
  26. IMAGE CREATED CREATED BY SIZE COMMENT
  27. 89cfccc12029 32 seconds ago /bin/sh -c bash -c 'touch /app.jar' 17.6MB
  28. 332a575d6f93 33 seconds ago /bin/sh -c #(nop) ADD file:6dd0fde95d358414c… 17.6MB
  29. 4c89e6c6dc2c 5 minutes ago /bin/sh -c #(nop) ENTRYPOINT ["java" "-Djav… 0B
  30. abe5b273c2e9 5 minutes ago /bin/sh -c #(nop) EXPOSE 8080 0B
  31. 246f72a01e02 5 minutes ago /bin/sh -c #(nop) VOLUME [/tmp] 0B
  32. 688c821c70ef 8 hours ago /bin/sh -c echo "Asia/Shanghai" > /etc/timez… 14B
  33. 1fb7eb3cfd1d 6 days ago /bin/sh -c #(nop) ENV JAVA_TOOL_OPTIONS=-XX… 0B
  34. <missing> 6 days ago /bin/sh -c #(nop) ENV JAVA_HOME=/opt/java/o… 0B
  35. <missing> 6 days ago /bin/sh -c set -eux; ARCH="$(dpkg --prin… 127MB
  36. <missing> 6 days ago /bin/sh -c #(nop) ENV JAVA_VERSION=jdk8u242… 0B
  37. <missing> 6 days ago /bin/sh -c apt-get update && apt-get ins… 33.5MB
  38. <missing> 6 days ago /bin/sh -c #(nop) ENV LANG=en_US.UTF-8 LANG… 0B
  39. <missing> 6 days ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B
  40. <missing> 6 days ago /bin/sh -c mkdir -p /run/systemd && echo 'do… 7B
  41. <missing> 6 days ago /bin/sh -c set -xe && echo '#!/bin/sh' > /… 745B
  42. <missing> 6 days ago /bin/sh -c [ -z "$(apt-get indextargets)" ] 987kB
  43. <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 文件分离

我们引入以下几个插件:

  1. <!--设置应用 Main 参数启动依赖查找的地址指向外部 lib 文件夹-->
  2. <plugin>
  3. <groupId>org.apache.maven.plugins</groupId>
  4. <artifactId>maven-jar-plugin</artifactId>
  5. <configuration>
  6. <archive>
  7. <manifest>
  8. <addClasspath>true</addClasspath>
  9. <classpathPrefix>lib/</classpathPrefix>
  10. </manifest>
  11. </archive>
  12. </configuration>
  13. </plugin>
  14. <!--设置 SpringBoot 打包插件不包含任何 Jar 依赖包-->
  15. <plugin>
  16. <groupId>org.springframework.boot</groupId>
  17. <artifactId>spring-boot-maven-plugin</artifactId>
  18. <configuration>
  19. <includes>
  20. <include>
  21. <groupId>nothing</groupId>
  22. <artifactId>nothing</artifactId>
  23. </include>
  24. </includes>
  25. </configuration>
  26. </plugin>
  27. <!--设置将 lib 拷贝到应用 Jar 外面-->
  28. <plugin>
  29. <groupId>org.apache.maven.plugins</groupId>
  30. <artifactId>maven-dependency-plugin</artifactId>
  31. <executions>
  32. <execution>
  33. <id>copy-dependencies</id>
  34. <phase>prepare-package</phase>
  35. <goals>
  36. <goal>copy-dependencies</goal>
  37. </goals>
  38. <configuration>
  39. <outputDirectory>${project.build.directory}/lib</outputDirectory>
  40. </configuration>
  41. </execution>
  42. </executions>
  43. </plugin>

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

  1. mvn clean package

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

  1. java -jar demo-0.0.1-SNAPSHOT.jar

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

改造 Spring Boot 的Dockerfile

修改 Dockerfile 文件

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

  1. FROM adoptopenjdk:8-jre-openj9
  2. RUN echo "Asia/Shanghai" > /etc/timezone
  3. VOLUME /tmp
  4. EXPOSE 8080
  5. ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-Xshareclasses","-Xquickstart","-jar","/app.jar"]
  6. COPY target/lib/* /lib/
  7. ADD target/*.jar app.jar
  8. RUN bash -c 'touch /app.jar'

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

测试改造后的镜像构建

我们执行命令

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

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

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

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

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

我们再看一下各层的大小

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

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