phauer.com/2019/no-fat-jar-in-docker-image/

在 Docker 容器中存放 fat jar 是一种对存储空间、带宽和时间的浪费。幸运的是,可以利用 Docker 镜像分层和 registry cache 实现增量构建和小型 artifact。例如,可以把新建 artifact 的大小从75MB缩减到只有1MB!最好有一个 Maven 和 Gradle 插件可以把这些搞定。


摘要

通常,一个 fat jar 包含的所有依赖在不同的 release 之间不会改变。但是,这些依赖项会反复拷贝到每个 fat jar 中,浪费了存储空间、带宽和时间。

例如,某个 Spring Boot 应用的 fat jar 大小为72 MB,其中只包含2MB代码。通常,只有代码会发生改变。

幸运的是,可以利用 Docker 镜像分层技术:通过把依赖和资源放在不同的层级可以实现重用,并且实现只对 artifact、release 更新代码。

Jib插件可以方便地实现上述功能,支持 Maven 和 Gradle。无需手动编写 Dockerfile。

问题:Fat Jar中的依赖

Docker 的分层机制功能非常强大。如果所有应用使用相同 base image(例如 openjdk:11.0.4-jre-slim),那么 Docker 会复用 OS 层和 JRE 层。在 Docker registry 中保存内容,由于传输的内容更少(Docker 只传输 registry 中新注册的 layer),可以加快 registry 上传和下载速度。

糟糕的是,许多应用并没有充分利用这种强大的机制,因为它们在 Docker 容器中使用了 fat jar。


每个 release 都会创建一个新的 Docker layer 大小72MB。

假设 Spring Boot 应用打包为一个 fat jar。这个 fat jar 大小为72MB,被添加到 Dockerfile 的最后一行。这意味着每个新 release 会占用72MB存储空间,并且会上传到 registry 然后下载。

现在,仔细看一下72MB的内容:


fat jar 的内容,其中大部分很少更改,但是会被反复拷贝到每个 artifact 中。

一个 fat jar 包含三部分:

依赖项:大部分内容为 library 且很少变化。大多数情况下,创建 release 只修改代码,不涉及依赖项。尽管如此,依赖项仍然会被拷贝到每个 release 中。

资源:基本上与上面的问题类似。尽管资源(像 HTML、CSS、图片、配置文件等)改变的频率比依赖项高,但是不会受到代码修改的影响。资源在每个 release 中也会重复。

代码:虽然代码只占 fat jar 很小一部分(300KB - 2MB),但却是修改最频繁的部分。

因此,通常一个新的 release 代码修改只有几MB。每个 artiface 会重复拷贝所有资源和依赖项,这是对存储空间、带宽和时间的浪费。

如果为每个 commit 创建唯一、可部署的 artifact (使用 git commit hash 作为 artifact 的版本号),浪费会更加严重。对持续交付来说这种方式很有意义,但由于每次 commit 都会额外占用72MB内存,消耗的存储空间越来越大。

有哪些工具可以用来分析 docker 镜像,把 fat jar 在 docker 镜像中的影响可视化?Dive 和docker history。


Dive是一个交互式命令行工具,可以显示是一个交互式命令行工具,可以展示 fat jar layer。

docker history也可以展示 fat jar layer:

~ ❯❯❯ docker history registry.domain.com/neptune:latest
IMAGE CREATED CREATED BY SIZE
44e77fa110e5 2minutes ago /bin/sh -c #(nop) COPY dir:… 65.5MB
...
 8months ago /bin/sh -c set-ex; if[ … 217MB
...
 8months ago /bin/sh -c #(nop) ADD file:… 55.3MB

解决方案:为依赖、资源和代码使用不同 layer

所幸可以利用 Docker 的分层机制,类似操作系统和 JRE 层。为依赖、资源和代码引入不同的 layer 进行拓展,然后根据变化频率对 layer 进行排序。


按照依赖、资源和代码把应用程序拆分成三个不同的 Docker layer。这样 release 只占2MB而不是之前的72MB。

现在,由于可以重复利用资源和依赖,如果创建的 release 只包含代码更改,只需要2MB存储空间。这些资源和依赖已经存储在 registry 中,不需要重复提交。

使用Google Jib插件实现

好消息:不需要为 Java 应用程序手动编写 Dockerfile,使用 Google Jib 即可。Jib 插件让 Java 容器化更容易,支持 Maven 和 Gradle。这篇 Google 博客介绍了 Jib,其中最重要的一个特性:Jib 会扫描 Java 项目并为依赖、资源和代码创建不同的 layer。这种开箱即用的形式很棒。

实现步骤:

1)在 pom.xml 中添加插件依赖:

com.google.cloud.tools
jib-maven-plugin
 1.6.1
openjdk: 11.0.4-jre-slim
domain.com/${project.artifactId}:latest
${git.commit.id}
-server
build-and-push-docker-image
package
build

2)用法

# 执行完整构建,然后把镜像推送到 registry

mvn package

# 只创建并推送镜像

mvn jib:build

# 注意 `jib:build` 没有守护进程,不会在机器上创建镜像

# 直接与 registry 交互使用 `docker pull` 获取已创建的镜像

# 只通过 Docker daemon 创建和推送镜像

mvn jib:dockerBuild

3)好处Dive和码docker history充分展示了优秀的分层结构。


使用 Jib 构建的 docker 镜像中依赖、资源和代码三个不同的 layer

~ ❯❯❯ docker history registry.domain.com/neptune:latest
IMAGE CREATED CREATED BY SIZE COMMENT
a759771eb008 49years ago jib-maven-plugin: 1.6.11.22MB classes
 49years ago jib-maven-plugin: 1.6.1297kB resources
 49years ago jib-maven-plugin: 1.6.164.6MB dependencies
...
 8months ago /bin/sh -c set-ex; .. .217MB
...
 8months ago /bin/sh -c #(nop) ADD.. .55.3MB

Clean-Up(可选)

Clean-up 1)禁用maven-deploy-plugin、maven-install-plugin 和 maven-jar-plugin。不需要执行上述步骤,即使开发人员出于习惯执行 mvn deploy 也不应当执行。

org.apache.maven.plugins
maven-deploy-plugin
 true
org.apache.maven.plugins
maven-install-plugin
 true
org.apache.maven.plugins
maven-jar-plugin
 default-jar
none

Clean-up 2)如果使用 Spring Boot,需要移除 spring-boot-maven-plugin。不需要再创建一个 fat jar。

运行部署相关配置

Jib 支持在 pom.xml 中配置 JVM flag 和程序参数,但是,通常我们不想在构建时设置,而是根据部署环境(本地、QA、生产)进行配置。可以在这里设置 Spring 配置和 JVM 堆大小。

JVM flag:使用 JAVA_TOOL_OPTIONS 环境变量添加 heap size 这样的 JVM flag。

Spring 配置:把部署相关的外部配置文件加载到 Docker 容器,文件的位置作为程序参数传入。也可以使用环境变量:

docker run -p 1309: 1309--net=host
-e JAVA_TOOL_OPTIONS= '-Xms1000M -Xmx1000M'
-v /home/phauer/dev/app/app-config.yml:/app-config.yml
registry.domain.com/app:latest
--spring.config.additional-location=/app-config.yml