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


不要在 Docker 镜像中使用 Fat Jar_java


摘要 


  • 通常,一个 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。


不要在 Docker 镜像中使用 Fat Jar_java_02

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


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


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


不要在 Docker 镜像中使用 Fat Jar_java_03

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


不要在 Docker 镜像中使用 Fat Jar_java_04

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


docker history 也可以展示 fat jar layer:

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


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


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


不要在 Docker 镜像中使用 Fat Jar_java_05

按照依赖、资源和代码把应用程序拆分成三个不同的 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 中添加插件依赖:


<plugin>
   <groupId>com.google.cloud.tools</groupId>
   <artifactId>jib-maven-plugin</artifactId>
   <version>1.6.1</version>
   <configuration>
       <from>
           <image>openjdk:11.0.4-jre-slim</image>
       </from>
       <to>
           <image>domain.com/${project.artifactId}:latest</image>
           <!-- optional: create a tag based on the git commit id (via the git-commit-id plugin): -->
           <tags>
               <tag>$
{git.commit.id}</tag>
           </tags>
       </to>
       <container>
           <jvmFlags>
               <jvmFlag>-server</jvmFlag>
           </jvmFlags>
       </container>
   </configuration>
   <executions>
       <execution>
           <id>build-and-push-docker-image</id>
           <phase>package</phase>
           <goals>
               <goal>build</goal>
           </goals>
       </execution>
   </executions>
</plugin>


2)用法


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

# 只创建并推送镜像
mvn jib:build
# 注意 `jib:build` 没有守护进程,不会在机器上创建镜像
# 直接与 registry 交互使用 `docker pull` 获取已创建的镜像

# 只通过 Docker daemon 创建和推送镜像
mvn jib:dockerBuild


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


不要在 Docker 镜像中使用 Fat Jar_java_06

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


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


Clean-Up(可选) 


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

<plugin>
   <groupId>org.apache.maven.plugins</groupId>
   <artifactId>maven-deploy-plugin</artifactId>
   <configuration>
       <skip>true</skip>
   </configuration>
</plugin>
<plugin>
   <groupId>org.apache.maven.plugins</groupId>
   <artifactId>maven-install-plugin</artifactId>
   <configuration>
       <skip>true</skip>
   </configuration>
</plugin>
<plugin>
   <groupId>org.apache.maven.plugins</groupId>
   <artifactId>maven-jar-plugin</artifactId>
   <!-- 注意,这里不支持 skip flag
   解决方法: 将 default-jar 绑定到一个不存在的 phase 执行-->
   <executions>
       <execution>
           <id>default-jar</id>
           <phase>none</phase>
       </execution>
   </executions>
</plugin>


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