网上看到这篇文件,觉得很不错,这里转载一下,主要学习该文章中使用分层缓存,处理镜像大小 思路。
转自:Java SpringBoot 项目构建 Docker 镜像调优实践 | 小豆丁技术栈
PS:已经在生产实践中验证,解决在生产环境下,网速带宽小,每次推拉镜像影响线上服务问题,按本文方式构建镜像,除了第一次拉取、推送、构建镜像慢,第二、三...次都是几百K大小传输,速度非常快,构建、打包、推送几秒内完成。
前言:
以前的 SpringCloud 微服务时代以 “Jar包" 为服务的基础,每个服务都打成 Jar 供服务间相互关联与调用。而 现在随着 Kubernetes 流行,已经变迁到一个镜像一个服务,依靠 Kubernetes 对镜像的统一编排进行对服务进行统一管理。在对 Kubernetes 微服务实践过程中,接触最多的肯定莫过于 Docker 镜像。由于本人使用的编程语言是 Java,所以对 Java SpringBoot 项目接触比较多,所以比较关心如何更好的通过 Dockerfile 编译 Docker 的镜像。
Kubernetes 微服务简单说就是一群镜像间的排列组合与相互间调的关系,故而如何编译镜像会使服务性能更优,使镜像构建、推送、拉取速度更快,使其占用网络资源更少这里优化,更易使用成为了一个重中之重的事情,也是一个非常值得琢磨的问题。这里我将对 SpringBoot 项目打包 Docker 镜像如何写 Dockerfile 的探究进行简单叙述。
系统环境:
这里将用常规 SpringBoot 编译 Docker 镜像的 Dockerfile 写法,感受下这种方式编译的镜像用起来如何。
这里准备一个经过 Maven 编译后的普通的 springboot 项目来进行 Docker 镜像构建,项目内容如下图所示,可以看到要用到的就是里面的应用程序的 Jar 文件,将其存入镜像内完成镜像构建任务。
构建 Docker 镜像需要提前准备 Dockerfile 文件,这个 Dockerfile 文件中的内容为构建 Docker 镜像执行的指令。下面是一个常用的 SpringBoot 构建 Docker 镜像的 Dockerfile,将它放入 Java 源码目录(target 的上级目录),确保下面设置的 Dockerfile 脚本中设置的路径和 target 路径对应。
FROM openjdk:8u212-b04-jre-slim
VOLUME /tmp
ADD target/*.jar app.jar
RUN sh -c 'touch /app.jar'
ENV JAVA_OPTS="-XX:MaxRAMPercentage=80.0 -Duser.timezone=Asia/Shanghai"
ENV APP_OPTS=""
ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar /app.jar $APP_OPTS" ]
通过 Docker build 命令构建 Docker 镜像,观察编译的时间。
由于后续需要将镜像推送到 Aliyun Docker 仓库,所以镜像前缀用了 Aliyun。
$ time docker build -t registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1 .
构建过程
Sending build context to Docker daemon 148.7MB
Step 1/7 : FROM openjdk:8u212-b04-jre-slim
8u212-b04-jre-slim: Pulling from library/openjdk
743f2d6c1f65: Already exists
b83e581826a6: Pull complete
04305660f45e: Pull complete
bbe7020b5561: Pull complete
Digest: sha256:a5bcd678408a5fe94d13e486d500983ee6fa594940cbbe137670fbb90030456c
Status: Downloaded newer image for openjdk:8u212-b04-jre-slim
---> 7c6b62cf60ee
Step 2/7 : VOLUME /tmp
---> Running in 13a67ab65d2b
Removing intermediate container 13a67ab65d2b
---> 52011f49ddef
Step 3/7 : ADD target/*.jar app.jar
---> 26aa41a404fd
Step 4/7 : RUN sh -c 'touch /app.jar'
---> Running in 722e7e44e04d
Removing intermediate container 722e7e44e04d
---> 7baedb10ec62
Step 5/7 : ENV JAVA_OPTS="-XX:MaxRAMPercentage=80.0 -Duser.timezone=Asia/Shanghai"
---> Running in 2681d0c5edac
Removing intermediate container 2681d0c5edac
---> 5ef4a794b992
Step 6/7 : ENV APP_OPTS=""
---> Running in 5c8924a2a49d
Removing intermediate container 5c8924a2a49d
---> fba87c19053a
Step 7/7 : ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar /app.jar $APP_OPTS" ]
---> Running in c4cf97009b3c
Removing intermediate container c4cf97009b3c
---> d5f30cdfeb81
Successfully built d5f30cdfeb81
Successfully tagged registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1
real 0m13.778s
user 0m0.078s
sys 0m0.153s
看到这次编译在 14s 内完成。
将镜像推送到 Aliyun 仓库,然后查看并记录推送时间
$ time docker push registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1
执行过程
The push refers to repository [registry.cn-beijing.aliyuncs.com/mydlq/springboot]
cc1a2376d7c0: Pushed
2b940d07e9e7: Pushed
9544e87fb8dc: Pushed
feb5d0e1e192: Pushed
8fd22162ddab: Pushed
6270adb5794c: Pushed
0.0.1: digest: sha256:dc60d304383b1441941ca4e9abc08db775d7be57ccb7c534c929b34ff064a62f size: 1583
real 0m24.335s
user 0m0.052s
sys 0m0.059s
看到这次在 25s 内完成。
这里切换到另一台服务器上进行镜像拉取操作,观察镜像拉取时间。
$ time docker pull registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1
拉取过程
0.0.1: Pulling from mydlq/springboot
743f2d6c1f65: Already exists
b83e581826a6: Pull complete
04305660f45e: Pull complete
bbe7020b5561: Pull complete
4847672cbfa5: Pull complete
b60476972fc4: Pull complete
Digest: sha256:dc60d304383b1441941ca4e9abc08db775d7be57ccb7c534c929b34ff064a62f
Status: Downloaded newer image for registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1
real 0m27.528s
user 0m0.033s
sys 0m0.192s
看到这次拉取总共用时 28s 内完成。
这里将源码的 JAVA 文件内容修改,然后重新打 Jar 包,这样再次尝试编译、推送、拉取过程,由于 Docker 在执行构建时会采用分层缓存,所以这是一个执行较快过程。
(1)、编译
$ time docker build -t registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2 .
Sending build context to Docker daemon 148.7MB
Step 1/7 : FROM openjdk:8u212-b04-jre-slim
---> 7c6b62cf60ee
Step 2/7 : VOLUME /tmp
---> Using cache
---> 52011f49ddef
Step 3/7 : ADD target/*.jar app.jar
---> c67160dd2a23
Step 4/7 : RUN sh -c 'touch /app.jar'
---> Running in 474900d843a2
Removing intermediate container 474900d843a2
---> 3ce9a8bb2600
Step 5/7 : ENV JAVA_OPTS="-XX:MaxRAMPercentage=80.0 -Duser.timezone=Asia/Shanghai"
---> Running in f48620b1ad36
Removing intermediate container f48620b1ad36
---> 0478f8f14e5b
Step 6/7 : ENV APP_OPTS=""
---> Running in 98485fb15fc8
Removing intermediate container 98485fb15fc8
---> 0b567c848027
Step 7/7 : ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar /app.jar $APP_OPTS" ]
---> Running in e32242fc6efe
Removing intermediate container e32242fc6efe
---> 7b223b23ebfd
Successfully built 7b223b23ebfd
Successfully tagged registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2
real 0m3.190s
user 0m0.039s
sys 0m0.403s
可以看到在编译镜像过程中,前1、2层用的缓存,所以速度非常快。总编译过程耗时 4s 内完成。
(2)、推送
$ time docker push registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2
The push refers to repository [registry.cn-beijing.aliyuncs.com/mydlq/springboot]
d66a2fec30b5: Pushed
f4da2c7581aa: Pushed
9544e87fb8dc: Layer already exists
feb5d0e1e192: Layer already exists
8fd22162ddab: Layer already exists
6270adb5794c: Layer already exists
real 0m20.816s
user 0m0.024s
sys 0m0.081s
可以看到只推送了前两层,其它四次由于远程仓库未变化,所以没有推送。整个推送过程耗时 21s 内完成。
(3)、拉取
$ time docker pull registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2
0.0.2: Pulling from mydlq/springboot
743f2d6c1f65: Already exists
b83e581826a6: Already exists
04305660f45e: Already exists
bbe7020b5561: Already exists
d7e364f0d94a: Pull complete
8d688ada35b1: Pull complete
Digest: sha256:7c13c40fa92ec2fdc3a8dfdd3232be1be9c1a1a99bf123743ff2a43907ee03dc
Status: Downloaded newer image for registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2
real 0m23.214s
user 0m0.053s
sys 0m0.097s
本地以及缓存前四层,只拉取有变化的后两层。这个过程耗时 24s 内完成。
通过这种方式对 SpringBoot 项目构建 Docker 镜像来使用,给我的感受就是只要源码中发生一点点变化,那么 SpringBoot 项目就需要将项目经过 Maven 编译后再经过 Docker 镜像构建,每次都会将一个 70M+ 的应用 Jar 文件存入 Docker 中,有时候明明就改了一个字母,可能又得把整个程序 Jar 重新存入 Docker 镜像中,然后在推送和拉取过程中,每次都得推一个大的镜像或者拉取一个大的镜像来进行传输,感觉非常不方便。
Docker 为了节约存储空间,所以采用了分层存储概念,不同镜像可以共享基础镜像的访问,关于镜像分层可以访问 Docker 官方文档的镜像分层这块知识,里面有对相关内的详细介绍,文档地址如下:
Docker 镜像缓存官方文档:https://docs.docker.com/storage/storagedriver/
在构建镜像的过程中 Docker 将按照 Dockerfile 中指定的顺序逐步执行 Dockerfile 中的指令。随着每条指令的检查,Docker 将在其缓存中查找可重用的现有镜像,而不是创建一个新的(重复)镜像。
Dockerfile 的每一行命令都创建新的一层,包含了这一行命令执行前后文件系统的变化。
为了优化这个过程,Docker 使用了一种缓存机制:只要这一行命令不变,那么结果和上一次是一样的,直接使用上一次的结果即可。
为了充分利用层级缓存,我们必须要理解 Dockerfile 中的命令行是如何工作的,尤其是 RUN,ADD 和 COPY 这几个命令。
SpringBoot 编译成镜像后,底层会是一个系统,如 Ubantu,上一层是依赖的 JDK 层,然后才是 SpringBoot 层。最下面两层是依赖的基础镜像,没有多少优化空间,所以我们优化镜像过程中考虑更多的是如何优化 SpringBoot 层,在此层进行琢磨。
从上面实验中了解到之所以每次编译、推送、拉取过程中较为缓慢,原因就是庞大的镜像文件。了解到 Docker 缓存概念后就就产生一种想法,如果不经常改变的文件缓存起来,将常改动的文件不进行缓存。由于 SpringBoot 项目是经常变换的,那么应该怎么利用缓存机制来实现呢?如果强行利用缓存那么每次打的镜像不都是缓存中的旧的程序内容吗。
所以就考虑一下应用 Jar 包里面都包含了什么文件, Java 的哪些文件是经常变动的,哪些不经常变动,对此,下面将针对 SpringBoot 打的应用 Jar 包进行分析。
显示解压后的列表,查看各个文件夹大小
$ tree -L 3 --si --du
.
├── [ 74M] BOOT-INF
│ ├── [2.1k] classes
│ └── [ 74M] lib
├── [ 649] META-INF
│ ├── [ 552] MANIFEST.MF
│ └── [ 59] maven
└── [ 67] org
└── [ 38] springframework
可以看到最大的文件就是 lib 这个文件夹,打开这个文件夹,里面是一堆相关依赖 Jar,这其中一个 Jar 不大,但是一堆 Jar 组合起来就非常大了,一般 SpringBoot 的项目依赖 Jar 大小维持在 40MB ~ 160MB。
在看看 org 文件夹,里面代码加起来才几百 KB。故此 SpringBoot 程序 Jar 包就是这些 Classes 文件和依赖的 Jar 组成,这些依赖 Jar 总共 74 MB,几乎占了这个应用 Jar 包的全部大小。
如果一个 Jar 包只包含 class 文件,那么这个 Jar 包的大小可能就几百 KB。现在要探究一下,如果将 lib 依赖的 Jar 和 class 分离,设置应用的 Jar 包只包含 class 文件,将 lib 文件夹下的 Jar 文件放在 SpringBoot Jar 的外面。
当我们写一个程序的时候,常常所依赖的 Jar 不会经常变动,变动多的是源代码程序,依赖的 Jar 包非常大而源代码非常小。仔细思考一下,如果在打包成 Docker 镜像的时候将应用依赖的 Jar 包单独设置一层缓存,而应用 Jar 包只包含 Class 文件,这样在 Docker 执行编译、推送、拉取过程中,除了第一次是全部都要执行外,再往后的执行编译、推送、拉取过程中,只会操作改动的那个只包含 Class 的 Jar 文件,就几百 KB,可以说是能够瞬间完成这个过程。所以思考一下,如何将 lib 文件夹下的依赖 Jar 包和应用 Jar 包分离开来。
经过查找很多相关资料,发现 SpringBoot 的 Maven 插件在执行 Maven 编译打 Jar 包时候做了很多事情,如果改变某些插件的打包逻辑,就可以实现在构建应用 Jar 包时,将应用依赖 lib 文件夹下的所有 Jar 都拷贝到应用 Jar 外面,只留下编译好的字节码 class 文件。
经过网上多番查找,找到了一种实现办法,如下,将这几个 Maven 工具引入到项目 pom.xml 中
org.apache.maven.plugins
maven-jar-plugin
true
lib/
org.springframework.boot
spring-boot-maven-plugin
nothing
nothing
org.apache.maven.plugins
maven-dependency-plugin
copy-dependencies
prepare-package
copy-dependencies
${project.build.directory}/lib
执行 Maven 命令打包 Jar
$ mvn clean install
当 Maven 命令执行完成后,查看 target 目录如下图:
可以看到应用 jar 已经变的非常小,且 lib 文件夹已经被放置到 jar 外面,jar 包中不再集成,而是在应用 jar 运行时候指定 lib 地址,使其能够加载。
不管怎么样,我们都需要测试下这个 Jar 文件是否能正常运行,只有正常执行才能说明此方案可行。
执行下面命令,测试 jar 是否能够正常启动:
$ java -jar springboot-helloworld-0.0.1.jar
本人在运行过程中能够看到运行日志,且显示运行成功,表示这种方案是可行的。不过我们改造工作尚未结束,接下来继续进行 Dockerfile 改造工作。
项目 Github 地址:https://github.com/my-dlq/blog-example/tree/master/springboot/springboot-dockerfile
这里修改上面的 Dockerfile 文件,需要新增下面一条指令:
COPY target/lib/ ./lib/
该指令用于将 lib 目录里面的依赖 Jar 复制到镜像中,其它保持和上面 Dockerfile 一致。
FROM openjdk:8u212-b04-jre-slim
VOLUME /tmp
COPY target/lib/ ./lib/
ADD target/*.jar app.jar
RUN sh -c 'touch /app.jar'
ENV JAVA_OPTS="-XX:MaxRAMPercentage=80.0 -Duser.timezone=Asia/Shanghai"
ENV APP_OPTS=""
ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar /app.jar $APP_OPTS" ]
这里新增了一层指令,作用为将 lib 文件夹复制到镜像之中,由于 Docker 缓存机制原因,这层一定要在复制应用 Jar 之前,这样改造后每次只要 lib 文件夹里面的依赖 Jar 不变,就不会新创建层,而是复用缓存。
这里我们开始真正尝试执行编译、推送、拉取来测试其速度如何,执行之前,我们先将服务器上次镜像相关的所有资源都清除掉,这样来减少对测试环境的影响。
(1)、编译速度测试及记录
$ time docker build -t registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1 .
Sending build context to Docker daemon 223.2MB
Step 1/8 : FROM openjdk:8u212-b04-jre-slim
8u212-b04-jre-slim: Pulling from library/openjdk
743f2d6c1f65: Already exists
b83e581826a6: Pull complete
04305660f45e: Pull complete
bbe7020b5561: Pull complete
Digest: sha256:a5bcd678408a5fe94d13e486d500983ee6fa594940cbbe137670fbb90030456c
Status: Downloaded newer image for openjdk:8u212-b04-jre-slim
---> 7c6b62cf60ee
Step 2/8 : VOLUME /tmp
---> Running in 529369acab24
Removing intermediate container 529369acab24
---> ad689d937118
Step 3/8 : COPY target/lib/ ./lib/
---> 029a64c15853
Step 4/8 : ADD target/*.jar app.jar
---> 6265a83a1b90
Step 5/8 : RUN sh -c 'touch /app.jar'
---> Running in 839032a58e6b
Removing intermediate container 839032a58e6b
---> 5d877dc35b2b
Step 6/8 : ENV JAVA_OPTS="-XX:MaxRAMPercentage=80.0 -Duser.timezone=Asia/Shanghai"
---> Running in 4043994c5fed
Removing intermediate container 4043994c5fed
---> 7cf32beb571f
Step 7/8 : ENV APP_OPTS=""
---> Running in b7dcfa10458a
Removing intermediate container b7dcfa10458a
---> b6b332bcf0e6
Step 8/8 : ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar /app.jar $APP_OPTS" ]
---> Running in 539093461b59
Removing intermediate container 539093461b59
---> d4c095c4ffec
Successfully built d4c095c4ffec
Successfully tagged registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1
real 0m22.983s
user 0m0.051s
sys 0m0.540s
(2)、推送速度测试及记录
$ time docker push registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1
The push refers to repository [registry.cn-beijing.aliyuncs.com/mydlq/springboot]
c16749205e05: Pushed
7fef1a146748: Pushed
a3bae74bbdf2: Pushed
9544e87fb8dc: Pushed
feb5d0e1e192: Pushed
8fd22162ddab: Pushed
6270adb5794c: Pushed
0.0.1: digest: sha256:e2f4db740880dbe5338b823112ba9467fedf8b27cd75572611d0d3837c80f157 size: 1789
real 0m30.335s
user 0m0.052s
sys 0m0.059s
(3)、拉取速度测试及记录
$ time docker pull registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1
0.0.1: Pulling from mydlq/springboot
743f2d6c1f65: Already exists
b83e581826a6: Pull complete
04305660f45e: Pull complete
bbe7020b5561: Pull complete
de6c4f15d75b: Pull complete
7066947b7d89: Pull complete
e0742de67c75: Pull complete
Digest: sha256:e2f4db740880dbe5338b823112ba9467fedf8b27cd75572611d0d3837c80f157
Status: Downloaded newer image for registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1
real 0m36.585s
user 0m0.024s
sys 0m0.092s
我们再次执行镜像构建,来测试在构建到 Step 3/8 : COPY target/lib/ ./lib/
时,是否复用缓存中的镜像。
(1)、编译速度测试及记录
$ time docker build -t registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2 .
Sending build context to Docker daemon 223.2MB
Step 1/8 : FROM openjdk:8u212-b04-jre-slim
---> 7c6b62cf60ee
Step 2/8 : VOLUME /tmp
---> Using cache
---> ad689d937118
Step 3/8 : COPY target/lib/ ./lib/
---> Using cache
---> 029a64c15853
Step 4/8 : ADD target/*.jar app.jar
---> 563773953844
Step 5/8 : RUN sh -c 'touch /app.jar'
---> Running in 3b9df57802bd
Removing intermediate container 3b9df57802bd
---> 706a0d47317f
Step 6/8 : ENV JAVA_OPTS="-XX:MaxRAMPercentage=80.0 -Duser.timezone=Asia/Shanghai"
---> Running in defda61452bf
Removing intermediate container defda61452bf
---> 742c7c926374
Step 7/8 : ENV APP_OPTS=""
---> Running in f09b81d054dd
Removing intermediate container f09b81d054dd
---> 929ed5f8b12a
Step 8/8 : ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar /app.jar $APP_OPTS" ]
---> Running in 5dc66a8fc1e6
Removing intermediate container 5dc66a8fc1e6
---> c4942b10992c
Successfully built c4942b10992c
Successfully tagged registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2
real 0m2.524s
user 0m0.051s
sys 0m0.493s
可以看到,这次在第 3 层直接用的缓存,整个编译过程才花了 2.5 秒时间
(2)、推送速度测试及记录
$ time docker push registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2
The push refers to repository [registry.cn-beijing.aliyuncs.com/mydlq/springboot]
d719b9540809: Pushed
d45bf4c5fb92: Pushed
a3bae74bbdf2: Layer already exists
9544e87fb8dc: Layer already exists
feb5d0e1e192: Layer already exists
8fd22162ddab: Layer already exists
6270adb5794c: Layer already exists
0.0.2: digest: sha256:b46d81b153ec64321caaae7ab28da0e362ed7d720a7f0775ea8d1f7bef310d00 size: 1789
real 0m0.168s
user 0m0.016s
sys 0m0.032s
可以看到在 0.2s 内就完成了镜像推送
(3)、拉取速度测试及记录
$ time docker pull registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2
0.0.2: Pulling from mydlq/springboot
743f2d6c1f65: Already exists
b83e581826a6: Already exists
04305660f45e: Already exists
bbe7020b5561: Already exists
de6c4f15d75b: Already exists
1c77cc70cc41: Pull complete
aa5b8cbca568: Pull complete
Digest: sha256:b46d81b153ec64321caaae7ab28da0e362ed7d720a7f0775ea8d1f7bef310d00
Status: Downloaded newer image for registry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2
real 0m1.947s
user 0m0.017s
sys 0m0.042s
可以看到在 2s 内就完成了镜像拉取
由于网络波动和系统变化,所以时间只能当做参考,不过执行编译、推送、拉取过程的确快了不少,大部分用文件都进行了缓存,只有几百 KB 的流量交互自然速度比几十 MB 甚至几百 MB 速度要快很多。
最后说明一下,这种做法只是提供了一种参考,现在的微服务服务 Docker 镜像化以来,维护的是整个镜像而不是一个服务程序,所以关心的是 Docker 镜像能否能正常运行,怎么构建镜像会使构建的镜像更好用。
在生产环境下由于版本变化较慢,不会动不动就更新,所以在生产环境下暂时最好还是按部就班,应用原来 SpringBoot 镜像编译方式以确保安装(除非已大量实例验证该构建方法)。