spring boot服务docker镜像优化之旅

3,124 阅读5分钟

缘由

我们在使用spring boot开发的服务中,一般会选择打包成单体的fatjar来发布服务,这在传统的部署方式下是非常方便的,但是当我们选择使用docker这种容器化的方式来部署应用的时候,却有一点点的不便之处,因为这个单体的jar一般都比较大,每次镜像push到仓库和从仓库拉取都需要比较长的时间。

原因是什么了?

docker的一大特色就是镜像的存储是分层的,参考下面这张官图

我们在Dockerfile中的每一个指令会对应到镜像的每一层,docker在更新镜像时,只会推送变更过的层,当它计算出来这一层的摘要和之前的版本一致时,会复用上一次打包镜像时的缓存,会极大的提高打包镜像以及镜像push/pull操作的速度。

那么问题来了,当我们springboot打包出来的单体jar的时候,每次编译这个jar都会发生变化,对应的存储层也会发生变化,push和pull操作时都需要重新推送,而且这个jar一般都不小,一个典型的应用会在100M左右,对应用部署和发布的速度会有比较大的影响。

稍作思考,很容易就能发现这个肥大的jar文件里面,大部分其实都是固定不变的各种依赖库,我们真正每次编译会变化的业务代码部分其实很小很小,可能也就只有几百KB,只要能将这两部分分离,变成docker镜像中的两层,一定能极大的提升镜像发布的速度

牛刀小试

首先拿来动手尝试的是一个springboot admin的项目,项目的结构是这样的:

使用最常见的打包方式:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
</plugin>

会生成一个32M的jar文件,优化之前的Dockerfile非常简单:

FROM openjdk:11.0.5-stretch as builder

RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
RUN echo 'Asia/Shanghai' >/etc/timezone
VOLUME /tmp
COPY target/*.jar ./app.jar
CMD ["/bin/bash", "-c", "java -jar -server app.jar"]
EXPOSE 8080

可以看到这种方式在构建v2版本的镜像时,会重新copy整个完整的jar

如果要拆开这个单体的jar,有两种方式,一是修改mvn打包的配置,将lib包放在独立的文件夹下,在这里我们考虑到项目众多,尽量减少修改,选择了在Docker打包镜像时,解压打包出来的jar包,将其中的内容分开来copy,修改后的Dockerfile如下:

#采用docker的分阶段构建方式,第一阶段负责解压jar包
FROM openjdk:11.0.5-stretch as builder

RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
RUN echo 'Asia/Shanghai' >/etc/timezone
VOLUME /tmp
WORKDIR /target
ADD target/*.jar app.jar
RUN jar xf app.jar


#这里分别copy解压后的内容
FROM openjdk:11.0.5-stretch
VOLUME /tmp
WORKDIR /app
COPY --from=builder target/BOOT-INF/lib ./lib
COPY --from=builder target/org/ ./org
COPY --from=builder target/META-INF/ ./META-INF
COPY --from=builder target/BOOT-INF/classes ./classes
CMD ["/bin/bash", "-c", "java -cp .:./classes/:./lib/* -server org.springframework.boot.loader.JarLauncher"]

EXPOSE 8080

看看修改后的效果:

在copy lib目录时,是直接using cache的。来看看push的时候效果对比 首先是优化前的push:

可以看到在push v2的时候还是会push一个33MB的层,虽然其实我们一行代码没有修改。

然后是优化后的:

可以看到这一次仅仅只推送了一个13KB的层,推送的速度快了非常多,同理也可以想象的到,我们在拉取镜像更新版本时速度会快很多。

路遇荆棘

在针对springboot-admin这个最简单的项目的优化取得很好的效果之后,就开始准备照搬到其他的项目中,没想到同样的方式怎么折腾都无效,分离之后的lib目录依然会每次需要全量重新push。出问题的项目结构大概是这样的:

一个常见的多模块mvn项目,有common,domain,rest-client,rest-server 这4个子模块,其中rest-server会依赖common和domain这两个子模块,打包出来的jar是在rest-server这个模块中。

究竟是什么鬼了?

终得正果

苦苦思索一番之后,lib目录既然不能复用上一次的cache,那一定是因为里面的内容有变化,遂将jar包解压,进到lib目录,真凶果然在此:

项目自身的3个子模块在每次编译的时候也会做为jar包放到lib目录下,这3个jar包每次编译都会有变化,所以导致这一层的cache失效。

找到问题之后,解决的思路就很简单了,将这种jar单独copy到一个目录下即可,修改后的Dockerfile如下:

FROM openjdk:11.0.5-stretch as builder

RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
RUN echo 'Asia/Shanghai' >/etc/timezone

VOLUME /tmp
WORKDIR /target
ADD target/*.jar app.jar
RUN jar xf app.jar
#创建一个snapshot目录,把snapshot的jar复制过去
RUN mkdir BOOT-INF/snapshot
RUN mv BOOT-INF/lib/*SNAPSHOT.jar BOOT-INF/snapshot/



FROM openjdk:11.0.5-stretch
VOLUME /tmp
WORKDIR /app
COPY --from=builder target/BOOT-INF/lib ./lib
COPY --from=builder target/org/ ./org
COPY --from=builder target/META-INF/ ./META-INF
#单独复制snapshot这一层
COPY --from=builder target/BOOT-INF/snapshot ./snapshot
COPY --from=builder target/BOOT-INF/classes ./classes
CMD ["/bin/bash", "-c", "java -cp .:./classes/:./lib/*:./snapshot/* -server org.springframework.boot.loader.JarLauncher"]

EXPOSE 8089

这样修改之后效果就和上面单模块的项目一样了,至此,基本完成了springboot项目的docker镜像优化,在jenkins的流水线上可以将原来镜像push的时间从1分钟以上优化到10s左右

未来之路

在整个优化的过程中,发现springboot2.3 M1版本已经有针对性的优化方案,增加了LAYERED_JAR的打包格式,未来可期。

具体可参考下文: www.jdon.com/53738

注: 本文中举例的两个项目案例,可在github上找到:github.com/yishh/sprin…