技术文章第一时间送达!
http://www.mydlq.club/article/16/
PS:已经在生产实践中验证,解决在生产环境下,网速带宽小,每次推拉镜像影响线上服务问题,按本文方式构建镜像,除了第一次拉取、推送、构建镜像慢,第二、三…次都是几百K大小传输,速度非常快,构建、打包、推送几秒内完成。
前言:
以前的 SpringCloud 微服务时代以 “Jar包” 为服务的基础,每个服务都打成 Jar 供服务间相互关联与调用。而 现在随着 Kubernetes 流行,已经变迁到一个镜像一个服务,依靠 Kubernetes 对镜像的统一编排进行对服务进行统一管理。在对 Kubernetes 微服务实践过程中,接触最多的肯定莫过于 Docker 镜像。由于本人使用的编程语言是 Java,所以对 Java SpringBoot 项目接触比较多,所以比较关心如何更好的通过 Dockerfile 编译 Docker 的镜像。
Kubernetes 微服务简单说就是一群镜像间的排列组合与相互间调的关系,故而如何编译镜像会使服务性能更优,使镜像构建、推送、拉取速度更快,使其占用网络资源更少这里优化,更易使用成为了一个重中之重的事情,也是一个非常值得琢磨的问题。
这里我将对 SpringBoot 项目打包 Docker 镜像如何写 Dockerfile 的探究进行简单叙述。
系统环境:
Docker 版本:18.09.3
Open JDK 基础镜像版本:openjdk:8u212-b04-jre-slim
测试用的镜像仓库:阿里云 Docker Hub
项目 Github:https://github.com/my-dlq/blog-example/tree/master/springboot/springboot-dockerfile
一、探究常规 Springboot 如何编译 Docker 镜像
这里将用常规 SpringBoot 编译 Docker 镜像的 Dockerfile 写法,感受下这种方式编译的镜像用起来如何。
1、准备编译镜像的 SpringBoot 项目
这里准备一个经过 Maven 编译后的普通的 springboot 项目来进行 Docker 镜像构建,项目内容如下图所示,可以看到要用到的就是里面的应用程序的 Jar 文件,将其存入镜像内完成镜像构建任务。
jar 文件大小:70.86mb
2、准备 Dockerfile 文件
构建 Docker 镜像需要提前准备 Dockerfile 文件,这个 Dockerfile 文件中的内容为构建 Docker 镜像执行的指令。
下面是一个常用的 SpringBoot 构建 Docker 镜像的 Dockerfile,将它放入 Java 源码目录(target 的上级目录),确保下面设置的 Dockerfile 脚本中设置的路径和 target 路径对应。
FROMopenjdk:8u212-b04-jre-slimVOLUME/tmpADDtarget/*.jarapp.jarRUNsh-c’touch/app.jar’ENVJAVA_OPTS=”-Duser.timezone=Asia/Shanghai”ENVAPP_OPTS=””ENTRYPOINT[“sh”,”-c”,”java$JAVA_OPTS-Djava.security.egd=file:/dev/./urandom-jar/app.jar$APP_OPTS”]3、构建 Docker 镜像
通过 Docker build 命令构建 Docker 镜像,观察编译的时间。
由于后续需要将镜像推送到 Aliyun Docker 仓库,所以镜像前缀用了 Aliyun。
time:此参数会显示执行过程经过的时间
$timedockerbuild-tregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1.
构建过程
SendingbuildcontexttoDockerdaemon148.7MBStep1/7:FROMopenjdk:8u212-b04-jre-slim8u212-b04-jre-slim:Pullingfromlibrary/openjdk743f2d6c1f65:Alreadyexistsb83e581826a6:Pullcomplete04305660f45e:Pullcompletebbe7020b5561:PullcompleteDigest:sha256:a5bcd678408a5fe94d13e486d500983ee6fa594940cbbe137670fbb90030456cStatus:Downloadednewerimageforopenjdk:8u212-b04-jre-slim—>7c6b62cf60eeStep2/7:VOLUME/tmp—>Runningin13a67ab65d2bRemovingintermediatecontainer13a67ab65d2b—>52011f49ddefStep3/7:ADDtarget/*.jarapp.jar—>26aa41a404fdStep4/7:RUNsh-c’touch/app.jar’—>Runningin722e7e44e04dRemovingintermediatecontainer722e7e44e04d—>7baedb10ec62Step5/7:ENVJAVA_OPTS=”-Duser.timezone=Asia/Shanghai”—>Runningin2681d0c5edacRemovingintermediatecontainer2681d0c5edac—>5ef4a794b992Step6/7:ENVAPP_OPTS=””—>Runningin5c8924a2a49dRemovingintermediatecontainer5c8924a2a49d—>fba87c19053aStep7/7:ENTRYPOINT[“sh”,”-c”,”java$JAVA_OPTS-Djava.security.egd=file:/dev/./urandom-jar/app.jar$APP_OPTS”]—>Runninginc4cf97009b3cRemovingintermediatecontainerc4cf97009b3c—>d5f30cdfeb81Successfullybuiltd5f30cdfeb81Successfullytaggedregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1real0m13.778suser0m0.078ssys0m0.153s
看到这次编译在 14s 内完成。
4、将镜像推送到镜像仓库
将镜像推送到 Aliyun 仓库,然后查看并记录推送时间
$timedockerpushregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1
执行过程
Thepushreferstorepository[registry.cn-beijing.aliyuncs.com/mydlq/springboot]cc1a2376d7c0:Pushed2b940d07e9e7:Pushed9544e87fb8dc:Pushedfeb5d0e1e192:Pushed8fd22162ddab:Pushed6270adb5794c:Pushed0.0.1:digest:sha256:dc60d304383b1441941ca4e9abc08db775d7be57ccb7c534c929b34ff064a62fsize:1583real0m24.335suser0m0.052ssys0m0.059s
看到这次在 25s 内完成。扩展:面试官:你简历中写用过docker,能说说容器和镜像的区别吗?
5、拉取镜像
这里切换到另一台服务器上进行镜像拉取操作,观察镜像拉取时间。
$timedockerpullregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1
拉取过程
0.0.1:Pullingfrommydlq/springboot743f2d6c1f65:Alreadyexistsb83e581826a6:Pullcomplete04305660f45e:Pullcompletebbe7020b5561:Pullcomplete4847672cbfa5:Pullcompleteb60476972fc4:PullcompleteDigest:sha256:dc60d304383b1441941ca4e9abc08db775d7be57ccb7c534c929b34ff064a62fStatus:Downloadednewerimageforregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1real0m27.528suser0m0.033ssys0m0.192s
看到这次拉取总共用时 28s 内完成。
6、修改 Java 源码重新打包 Jar 后再次尝试
这里将源码的 JAVA 文件内容修改,然后重新打 Jar 包,这样再次尝试编译、推送、拉取过程,由于 Docker 在执行构建时会采用分层缓存,所以这是一个执行较快过程。
(1)、编译
$timedockerbuild-tregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2.SendingbuildcontexttoDockerdaemon148.7MBStep1/7:FROMopenjdk:8u212-b04-jre-slim—>7c6b62cf60eeStep2/7:VOLUME/tmp—>Usingcache—>52011f49ddefStep3/7:ADDtarget/*.jarapp.jar—>c67160dd2a23Step4/7:RUNsh-c’touch/app.jar’—>Runningin474900d843a2Removingintermediatecontainer474900d843a2—>3ce9a8bb2600Step5/7:ENVJAVA_OPTS=”-Duser.timezone=Asia/Shanghai”—>Runninginf48620b1ad36Removingintermediatecontainerf48620b1ad36—>0478f8f14e5bStep6/7:ENVAPP_OPTS=””—>Runningin98485fb15fc8Removingintermediatecontainer98485fb15fc8—>0b567c848027Step7/7:ENTRYPOINT[“sh”,”-c”,”java$JAVA_OPTS-Djava.security.egd=file:/dev/./urandom-jar/app.jar$APP_OPTS”]—>Runningine32242fc6efeRemovingintermediatecontainere32242fc6efe—>7b223b23ebfdSuccessfullybuilt7b223b23ebfdSuccessfullytaggedregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2real0m3.190suser0m0.039ssys0m0.403s
可以看到在编译镜像过程中,前1、2层用的缓存,所以速度非常快。总编译过程耗时 4s 内完成。
(2)、推送
$timedockerpushregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2Thepushreferstorepository[registry.cn-beijing.aliyuncs.com/mydlq/springboot]d66a2fec30b5:Pushedf4da2c7581aa:Pushed9544e87fb8dc:Layeralreadyexistsfeb5d0e1e192:Layeralreadyexists8fd22162ddab:Layeralreadyexists6270adb5794c:Layeralreadyexistsreal0m20.816suser0m0.024ssys0m0.081s
可以看到只推送了前两层,其它四次由于远程仓库未变化,所以没有推送。整个推送过程耗时 21s 内完成。
(3)、拉取
$timedockerpullregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.20.0.2:Pullingfrommydlq/springboot743f2d6c1f65:Alreadyexistsb83e581826a6:Alreadyexists04305660f45e:Alreadyexistsbbe7020b5561:Alreadyexistsd7e364f0d94a:Pullcomplete8d688ada35b1:PullcompleteDigest:sha256:7c13c40fa92ec2fdc3a8dfdd3232be1be9c1a1a99bf123743ff2a43907ee03dcStatus:Downloadednewerimageforregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2real0m23.214suser0m0.053ssys0m0.097s
本地以及缓存前四层,只拉取有变化的后两层。这个过程耗时 24s 内完成。
7、使用镜像过程中的感受
通过这种方式对 SpringBoot 项目构建 Docker 镜像来使用,给我的感受就是只要源码中发生一点点变化,那么 SpringBoot 项目就需要将项目经过 Maven 编译后再经过 Docker 镜像构建,每次都会将一个 70M 的应用 Jar 文件存入 Docker 中,有时候明明就改了一个字母,可能又得把整个程序 Jar 重新存入 Docker 镜像中,然后在推送和拉取过程中,每次都得推一个大的镜像或者拉取一个大的镜像来进行传输,感觉非常不方便。
二、了解 Docker 分层及缓存机制1、Docker 分层缓存简介
Docker 为了节约存储空间,所以采用了分层存储概念。共享数据会对镜像和容器进行分层,不同镜像可以共享相同数据,并且在镜像上为容器分配一个 RW 层来加快容器的启动顺序。
在构建镜像的过程中 Docker 将按照 Dockerfile 中指定的顺序逐步执行 Dockerfile 中的指令。随着每条指令的检查,Docker 将在其缓存中查找可重用的现有镜像,而不是创建一个新的(重复)镜像。
Dockerfile 的每一行命令都创建新的一层,包含了这一行命令执行前后文件系统的变化。为了优化这个过程,Docker 使用了一种缓存机制:只要这一行命令不变,那么结果和上一次是一样的,直接使用上一次的结果即可。扩展:终于有人把 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 包臃肿
从上面实验中了解到之所以每次编译、推送、拉取过程中较为缓慢,原因就是庞大的镜像文件。了解到 Docker 缓存概念后就就产生一种想法,如果不经常改变的文件缓存起来,将常改动的文件不进行缓存。扩展:SpringBoot缓存应用实践
由于 SpringBoot 项目是经常变换的,那么应该怎么利用缓存机制来实现呢?如果强行利用缓存那么每次打的镜像不都是缓存中的旧的程序内容吗。
所以就考虑一下应用 Jar 包里面都包含了什么文件, Java 的哪些文件是经常变动的,哪些不经常变动,对此,下面将针对 SpringBoot 打的应用 Jar 包进行分析。
1、解压 Jar 包查看内容
显示解压后的列表,查看各个文件夹大小
$tree-L3–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。
2、解决臃肿的新思路
如果一个 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 包分离开来。
3、如何解决 lib 和 class 文件分离
经过查找很多相关资料,发现 SpringBoot 的 Maven 插件在执行 Maven 编译打 Jar 包时候做了很多事情,如果改变某些插件的打包逻辑,致使打应用 Jar 时候将 lib 文件夹下所有的 Jar 包都拷贝到应用 Jar 外面,只留下编译好的字节码文件。
将这几个 Maven 工具引入到项目 pom.xml 中
执行 Maven 命令打包 Jar
$mvncleaninstall
当 Maven 命令执行完成后,查看 target 目录如下图:
然后测试下这个 Jar 文件是否能正常运行
$java-jarspringboot-helloworld-0.0.1.jar
然后看到运行日志,OK!下面将继续进行 Dockerfile 改造工作。
四、聊聊如何改造 Springboot 编译 Docker 镜像
1、修改 Dockerfile 文件
这里修改上面的 Dockerfile 文件,需要新增一层指令用于将 lib 目录里面的依赖 Jar 复制到镜像中,其它保持和上面 Dockerfile 一致。
FROMopenjdk:8u212-b04-jre-slimVOLUME/tmpCOPYtarget/lib/./lib/ADDtarget/*.jarapp.jarRUNsh-c’touch/app.jar’ENVJAVA_OPTS=”-Duser.timezone=Asia/Shanghai”ENVAPP_OPTS=””ENTRYPOINT[“sh”,”-c”,”java$JAVA_OPTS-Djava.security.egd=file:/dev/./urandom-jar/app.jar$APP_OPTS”]
这里新增了一层指令,作用为将 lib 文件夹复制到镜像之中,由于 Docker 缓存机制原因,这层一定要在复制应用 Jar 之前,这样改造后每次只要 lib/ 文件夹里面的依赖 Jar 不变,就不会新创建层,而是复用缓存。
2、改造 Docker 镜像后的首次编译、推送、拉取
在执行编译、推送、拉取之前,先将服务器上次镜像相关的所有资源都清除掉,然后再执行。
(1)、编译
$timedockerbuild-tregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1.SendingbuildcontexttoDockerdaemon223.2MBStep1/8:FROMopenjdk:8u212-b04-jre-slim8u212-b04-jre-slim:Pullingfromlibrary/openjdk743f2d6c1f65:Alreadyexistsb83e581826a6:Pullcomplete04305660f45e:Pullcompletebbe7020b5561:PullcompleteDigest:sha256:a5bcd678408a5fe94d13e486d500983ee6fa594940cbbe137670fbb90030456cStatus:Downloadednewerimageforopenjdk:8u212-b04-jre-slim—>7c6b62cf60eeStep2/8:VOLUME/tmp—>Runningin529369acab24Removingintermediatecontainer529369acab24—>ad689d937118Step3/8:COPYtarget/lib/./lib/—>029a64c15853Step4/8:ADDtarget/*.jarapp.jar—>6265a83a1b90Step5/8:RUNsh-c’touch/app.jar’—>Runningin839032a58e6bRemovingintermediatecontainer839032a58e6b—>5d877dc35b2bStep6/8:ENVJAVA_OPTS=”-Duser.timezone=Asia/Shanghai”—>Runningin4043994c5fedRemovingintermediatecontainer4043994c5fed—>7cf32beb571fStep7/8:ENVAPP_OPTS=””—>Runninginb7dcfa10458aRemovingintermediatecontainerb7dcfa10458a—>b6b332bcf0e6Step8/8:ENTRYPOINT[“sh”,”-c”,”java$JAVA_OPTS-Djava.security.egd=file:/dev/./urandom-jar/app.jar$APP_OPTS”]—>Runningin539093461b59Removingintermediatecontainer539093461b59—>d4c095c4ffecSuccessfullybuiltd4c095c4ffecSuccessfullytaggedregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1real0m22.983suser0m0.051ssys0m0.540s
(2)、推送
$timedockerpushregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1Thepushreferstorepository[registry.cn-beijing.aliyuncs.com/mydlq/springboot]c16749205e05:Pushed7fef1a146748:Pusheda3bae74bbdf2:Pushed9544e87fb8dc:Pushedfeb5d0e1e192:Pushed8fd22162ddab:Pushed6270adb5794c:Pushed0.0.1:digest:sha256:e2f4db740880dbe5338b823112ba9467fedf8b27cd75572611d0d3837c80f157size:1789real0m30.335suser0m0.052ssys0m0.059s
(3)、拉取
$timedockerpullregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.10.0.1:Pullingfrommydlq/springboot743f2d6c1f65:Alreadyexistsb83e581826a6:Pullcomplete04305660f45e:Pullcompletebbe7020b5561:Pullcompletede6c4f15d75b:Pullcomplete7066947b7d89:Pullcompletee0742de67c75:PullcompleteDigest:sha256:e2f4db740880dbe5338b823112ba9467fedf8b27cd75572611d0d3837c80f157Status:Downloadednewerimageforregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.1real0m36.585suser0m0.024ssys0m0.092s3、再次编译、推送、拉取
(1)、编译
$timedockerbuild-tregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2.SendingbuildcontexttoDockerdaemon223.2MBStep1/8:FROMopenjdk:8u212-b04-jre-slim—>7c6b62cf60eeStep2/8:VOLUME/tmp—>Usingcache—>ad689d937118Step3/8:COPYtarget/lib/./lib/—>Usingcache—>029a64c15853Step4/8:ADDtarget/*.jarapp.jar—>563773953844Step5/8:RUNsh-c’touch/app.jar’—>Runningin3b9df57802bdRemovingintermediatecontainer3b9df57802bd—>706a0d47317fStep6/8:ENVJAVA_OPTS=”-Duser.timezone=Asia/Shanghai”—>Runningindefda61452bfRemovingintermediatecontainerdefda61452bf—>742c7c926374Step7/8:ENVAPP_OPTS=””—>Runninginf09b81d054ddRemovingintermediatecontainerf09b81d054dd—>929ed5f8b12aStep8/8:ENTRYPOINT[“sh”,”-c”,”java$JAVA_OPTS-Djava.security.egd=file:/dev/./urandom-jar/app.jar$APP_OPTS”]—>Runningin5dc66a8fc1e6Removingintermediatecontainer5dc66a8fc1e6—>c4942b10992cSuccessfullybuiltc4942b10992cSuccessfullytaggedregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2real0m2.524suser0m0.051ssys0m0.493s
可以看到,这次在第 3 层直接用的缓存,整个编译过程才花了 2.5 秒时间
(2)、推送
$timedockerpushregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2Thepushreferstorepository[registry.cn-beijing.aliyuncs.com/mydlq/springboot]d719b9540809:Pushedd45bf4c5fb92:Pusheda3bae74bbdf2:Layeralreadyexists9544e87fb8dc:Layeralreadyexistsfeb5d0e1e192:Layeralreadyexists8fd22162ddab:Layeralreadyexists6270adb5794c:Layeralreadyexists0.0.2:digest:sha256:b46d81b153ec64321caaae7ab28da0e362ed7d720a7f0775ea8d1f7bef310d00size:1789real0m0.168suser0m0.016ssys0m0.032s
可以看到在 0.2s 内就完成了镜像推送
(3)、拉取
$timedockerpullregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.20.0.2:Pullingfrommydlq/springboot743f2d6c1f65:Alreadyexistsb83e581826a6:Alreadyexists04305660f45e:Alreadyexistsbbe7020b5561:Alreadyexistsde6c4f15d75b:Alreadyexists1c77cc70cc41:Pullcompleteaa5b8cbca568:PullcompleteDigest:sha256:b46d81b153ec64321caaae7ab28da0e362ed7d720a7f0775ea8d1f7bef310d00Status:Downloadednewerimageforregistry.cn-beijing.aliyuncs.com/mydlq/springboot:0.0.2real0m1.947suser0m0.017ssys0m0.042s
可以看到在 2s 内就完成了镜像拉取
五、最后总结
由于网络波动和系统变化,所以时间只能当做参考,不过执行编译、推送、拉取过程的确快了不少,大部分用文件都进行了缓存,只有几百 KB 的流量交互自然速度比几十 MB 甚至几百 MB 速度要快很多。
最后说明一下,这种做法只是提供了一种参考,现在的微服务服务 Docker 镜像化以来,维护的是整个镜像而不是一个服务程序,所以关心的是 Docker 镜像能否能正常运行,怎么构建镜像会使构建的镜像更好用。
在生产环境下由于版本变化较慢,不会动不动就更新,所以在生产环境下暂时最好还是按部就班,应用原来 SpringBoot 镜像编译方式以确保安装(除非已大量实例验证该构建方法)。
END
Java面试题专栏
【41期】盘点那些必问的数据结构算法题之链表【42期】盘点那些必问的数据结构算法题之二叉堆【43期】盘点那些必问的数据结构算法题之二叉树基础【44期】盘点那些必问的数据结构算法题之二分查找算法【45期】盘点那些必问的数据结构算法题之基础排序【46期】盘点那些必问的数据结构算法题之快速排序【47期】六大类二叉树面试题汇总解答【48期】盘点Netty面试常问考点:什么是 Netty 的零拷贝?【49期】面试官:SpringMVC的控制器是单例的吗?【50期】基础考察:ClassNotFoundException 和 NoClassDefFoundError 有什么区别