Java应用程序的图像优化的故事始于春季博客文章“ 容器中的Spring Boot” 。 它讨论了为Spring Boot应用程序创建docker映像的各个方面,其中包括一个有趣的问题,如减小映像的大小。 对于我们的团队来说,这很重要,原因有几个,所以我们决定将此解决方案应用于我们的应用程序。
由于经常发生这种情况,并非所有事情都是第一次出现,多模块项目存在细微差别,并且试图在CI系统上实现所有这些,因此在本文中,您将找到解决这些问题的解决方案。
优化的目的是减少组装之间的结果图像之间的差异 ,这在连续交付的过程中提供了很好的结果,因此,如果您有兴趣将图像尺寸减小到最小,可以参考中心上的其他 文章 。
如果您不必解释为什么要在万用表启动应用程序中进行某些操作,然后再将其放入映像中,则可以立即继续进行优化方法的说明 。 如果您设法了解Spring博客中的文章,则可以继续解决所发现的问题 。
这是为什么呢,还是肥罐的另一面
默认情况下,Spring Boot生成的jar是一个可执行的jar文件,其中包含应用程序代码及其所有依赖项。
这种方法的优点很明显:使用一个文件很方便,它具有通过java -jar <myapp>.jar
运行所需的所有内容。 Dockerfile是琐碎的而不是有趣的。
缺点是存储效率低下。 在经典的引导应用程序中,代码和库的比例显然不赞成我们的代码。 例如,可以通过start.spring.io生成的具有Web部件和用于处理数据库的库的空应用程序将占用20mb ,其中98%是库。 而且该比率在开发过程中不会有太大变化。
但是,我们不止一次收集应用程序,而且定期在CI服务器上收集该应用程序,然后在一系列环境中进行部署。 因此,以200mb的速度增长10个程序集,以2gb的速度增长100个程序集,只需很少的修改。
可以说,就当前的存储成本而言,这些都是荒谬的数字,您不必花时间进行此类优化,但这全取决于组织的规模以及需要存储其图像的应用程序的数量。 部署条件也会强烈地激发:当注册表和服务器在附近时,即使相差100 mb也不是很明显,但是在分布式系统中,这可能更为重要,尤其是当您需要通过防火墙和不稳定的通道来部署到诸如中国这样的特定国家时到外面的世界。
因此,有了找出的原因,是时候进行优化了。
我们优化装配,或者可以从春季博客中学到什么
本文提供了一个合理的解决方案:我们需要制作多个图层,而不是由COPY my-jar.jar app.jar
生成的单个图层。
一层将包含库,第二层是我们自己的代码。 为此,您需要解压缩jar文件并将内容复制到不同的图像层。
准备jar文件的脚本如下所示:
使用多阶段构建的dockerfile可能看起来像这样
FROM openjdk:8-jdk-alpine as build WORKDIR /wd COPY prepare_for_docker.sh /usr/local/bin/prepare_for_docker COPY target/demo.jar /wd/app.jar RUN prepare_for_docker /wd/app.jar FROM openjdk:8-jdk-alpine COPY --from=build /wd/docker-dist/BOOT-INF/lib /app/lib COPY --from=build /wd/docker-dist/META-INF /app/META-INF COPY --from=build /wd/docker-dist/BOOT-INF/classes /app ENTRYPOINT ["java","-cp","app:app/lib/*","com.example.demo.DemoApplication"]
在第一阶段,我们复制所需的所有内容,运行脚本以解压缩jar文件,在第二阶段,我们将单独的库和代码分层放置。
确保操作性很容易:
- 第一次收集
- 对我们的代码进行任何更改。
- 我们再次启动
docker build
并查看复制整个lib目录时Using cache
的珍贵行
... Step 5/10 : RUN prepare_for_docker app.jar ---> Running in c8e422491eb2 Removing intermediate container c8e422491eb2 ---> c7dcec4ae18a Step 6/10 : FROM openjdk:8-jdk-alpine ---> a3562aa0b991 Step 7/10 : COPY --from=build /wd/docker-dist/BOOT-INF/lib /app/lib ---> Using cache ---> 01b600d7e350 Step 8/10 : COPY --from=build /wd/docker-dist/META-INF /app/META-INF ---> Using cache ---> 5c0c03a3c8f1 Step 9/10 : COPY --from=build /wd/docker-dist/BOOT-INF/classes /app ---> 5ffed6ee5696 Step 10/10 : ENTRYPOINT ["java","-cp","app:app/lib/*","com.example.demo.DemoApplication"] ---> Running in 99957250fe5d Removing intermediate container 99957250fe5d ---> 6735799d9f32 Successfully built 6735799d9f32 Successfully tagged boot2-sample:latest
改进此方法的一个显而易见的方法是使用脚本构建小的基础图像 ,以免将其从一个项目拖到另一个项目。 因此,第一层变得更加简洁。
FROM zeldigas/java-layered-builder as build COPY target/demo.jar app.jar RUN prepare_for_docker app.jar
我们正在最终确定解决方案
如本文开头已经提到的,该解决方案正在运行,但是在操作过程中发现了一些问题,稍后将进行讨论。
并非lib
所有文件lib
同一个库
如果您的项目是多模块的(至少有模块A依赖,模块B依赖于该模块,并组装成一个弹簧胖子罐),并对其应用原始解决方案,那么您将发现不会发生层缓存。 怎么了?
关键在于附加模块:即使您不对模块代码进行任何更改,它们也是图层不断变化的来源。 这是由于创建Maven jar文件的特殊性(使用gradle,情况会好一些,但不确定)。 获取可重现工件的任务不是本文的主题(尽管当然很有趣并且可以实现),所以我们将继续一个相当简单的解决方案。
解压后,我们将lib
的内容分发到2个目录中,将项目模块与其他库分开。 让我们最后确定胖子罐子的解压缩脚本:
结果,该脚本开始支持附加参数的传输(请参见1和2)。 如果传递了其他参数(3),则将每个参数都视为我们将文件(4)移动到单独目录的文件名的前缀。
带有一个附加方案的Dockerfile示例。 shared-module
和版本1.0-SNAPSHOT
FROM openjdk:8-jdk-alpine as build COPY target/demo.jar /wd/app.jar RUN prepare_for_docker /wd/app.jar shared-module-1.0 FROM openjdk:8-jdk-alpine COPY --from=build /wd/docker-dist/BOOT-INF/lib /app/lib COPY --from=build /wd/docker-dist/app-lib /app/lib COPY --from=build /wd/docker-dist/META-INF /app/META-INF COPY --from=build /wd/docker-dist/BOOT-INF/classes /app ENTRYPOINT ["java","-cp","app:app/lib/*","com.example.demo.DemoApplication"]
在CI服务器上运行
在对所有内容进行本地调试并对此结果感到满意之后,我们开始在CI服务器上运行,并且从构建日志中发现没有发生奇迹,或者结果不是恒定的:在某些情况下,执行了缓存,并且下次所有层都是新的。
结果,发现了罪魁祸首-泊坞窗缓存,或者在不同代理程序的情况下不存在(我们的程序集未钉在CI系统的特定代理程序上)。 事实证明,如果docker缓存中没有合适的层,那么将从同一组文件中获得具有不同校验和的层。 您可以通过使用--no-cache
选项运行构建,或通过首先删除映像和所有中间层--no-cache
第二次构建来在本地进行验证。 结果,您将获得一个完全不同的校验和层,从而抵消了之前的所有工作。

有几种方法可以解决此问题:
- 如果您的CI系统开箱即用支持此功能(例如, 计划部分中的 Circle CI 对装配期间的共享缓存具有内置支持)
- 在代理之间使用docker缓存对部分进行改组
- 利用内置的缓存管理泊坞窗(
--cache-from
)
我们走了第三条路,因为在我们的情况下这是最简单的。 该选项允许您告知docker守护程序应考虑的映像,并在组装期间尝试用于缓存。 您可以根据需要指定任意数量的图像,主要是它们位于文件系统上。 如果指定的图像不存在,它将被简单地忽略,因此您需要在构建之前将其拉出。
使用此方法的容器组装如下所示:
set -e version=...
我们尝试只重用最近映像中的层,这通常就足够了,但是没有人会费心地提出更复杂的逻辑并退回几个版本或依赖vcs commits的ID。
我们将这种方法适应您CI的功能,并通过库获得可靠的层重用。
合计
该解决方案显示出良好的结果,尤其是在开发阶段活跃且CD管道经过调整的项目中使用时。 下图显示了对其中一个应用程序进行优化的结果。 可以清楚地看到,从70年代的装配开始,线性增长已变为痉挛性的(60年代的失败与构建代理的调试工作紧密相关)。 之后的排放与更新基础图像(高)和库(较低)有关

在我们的案例中,存储优化是一个令人愉快的事情,但却是次要的收获。 新版本在几个地区的旧版本上的部署加速令人愉悦。
应该注意的是,该技术与旨在减少单个图像大小的其他方法(高山和其他轻量级基本图像,应用程序的自定义运行时)完全兼容。 最主要的是在缓存方面遵循组装图像的一般规则,并确保结果可重现。