从 Fat Jar 到分层镜像,彻底告别大体积与慢构建
引言:你的 Docker 镜像有多“胖”?
在微服务和云原生时代,Docker 已经成为 Java 应用部署的事实标准。想象一下这样的场景:你刚修改了一行业务代码,提交后触发了 CI/CD 流程,然后等待镜像构建完成——结果因为整包 JAR 文件重新拷贝,Docker 缓存在这一层完全失效,不得不重新下载所有依赖,整个构建过程耗时整整 6 分钟。类似的问题频发,背后的核心原因往往只有一个:镜像层结构设计不合理。
容器化确实解决了环境一致性问题,但它也带来了新的挑战。其中最关键的一条经验法则是:镜像层就是缓存单元。Docker 镜像是一个分层堆叠的结构,每一层都代表文件系统的一次变更。当某一层的内容发生变化时,从该层往下的所有缓存全部失效。所以分层的核心目标很明确——把那些不常变化的内容(如第三方依赖)和经常变化的内容(如业务代码)分开存放,这样每次代码修改就只需要重建和应用最顶层,下层缓存可以被复用。遗憾的是,直接把 Spring Boot 的 Fat Jar 扔进 Docker,会把依赖和代码全部塞进一个层里,完全绕开了这一核心优化思路。
本文将结合 Spring Boot 3.4 的最新特性,从分层提取、Dockerfile 编写到最终的镜像瘦身,系统讲解生产级 Docker 镜像的构建策略。后续还将对比 Buildpacks、Jib 等自动化方案,并基于 《WSL2 + Docker Desktop:Windows 下的完美 Java 开发环境》中搭建的环境,用 Docker Compose 进行验证。
一、从传统方式开始:Fat Jar 镜像的痛点
先来看一个典型的“反面教材”。这是一个最基础的 Dockerfile,它直接把 Spring Boot 的 Fat Jar 复制进镜像并启动:
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY target/myapp.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
这段配置看起来简洁,但它存在两个严重的问题。
第一,运行时的额外开销。 每次执行 java -jar app.jar 时,JVM 都需要在运行时动态解压 JAR 文件中的嵌套结构才能加载类。在容器环境中,这个开销会变得更加明显。
第二,也是最致命的问题——构建缓存完全失效。 即便你只修改了一行业务代码,重新打包后整个 JAR 文件的 Hash 都会发生变化,COPY target/myapp.jar app.jar 这一层自然也会判定为“已变更”。这会导致后续从该层往下的所有缓存全部失效,镜像中的每一层都必须重新构建、重新推送,CI/CD 效率大幅下降。
Docker 镜像的构建是基于缓存的:如果某层的指令和内容都没有发生变化,Docker 会直接复用上一次构建的结果。利用这一点,可以将不同变更频率的内容放在不同的 COPY 指令中执行:依赖很少变化,放在靠前的层,优先命中缓存;业务代码频繁变化,放在靠后的层,只重新构建这一小部分。
二、Spring Boot 分层 JAR:核心原理
为了从根本上解决上述问题,Spring Boot 在 2.3 版本引入了分层 JAR(Layered JAR)机制,并在随后的版本中不断完善,到 Spring Boot 2.4 后分层功能已默认开启。当你在 Maven 或 Gradle 插件中启用了分层配置后,生成的 JAR 文件内部会新增一个名为 BOOT-INF/layers.idx 的索引文件。这个索引文件按变更频率将 JAR 内的内容归入了四个逻辑层:
| 层名 | 包含内容 | 变更频率 | 推荐顺序 |
|---|---|---|---|
| dependencies | 正式发布版的第三方依赖库(版本号不含 SNAPSHOT) | 很低 | 第一层 |
| spring-boot-loader | Spring Boot 的类加载器代码(位于 org/springframework/boot/loader 下) | 几乎不变 | 第二层 |
| snapshot-dependencies | SNAPSHOT 版本的依赖(开发中的内部模块) | 偶尔变化 | 第三层 |
| application | 开发者自己写的业务代码和静态资源文件 | 频繁变化 | 第四层 |
一个典型的 layers.idx 文件内容大致如下:
- "dependencies":
- BOOT-INF/lib/library1.jar
- BOOT-INF/lib/library2.jar
- "spring-boot-loader":
- org/springframework/boot/loader/launch/JarLauncher.class
- "snapshot-dependencies":
- BOOT-INF/lib/library3-SNAPSHOT.jar
- "application":
- BOOT-INF/classes/com/example/MyController.class
这个顺序安排是有讲究的。内容越不常变化的层应该越靠前——一旦前面的层被命中缓存,所有后续层都可以复用。当只有业务代码发生变化时,只需重新构建顶层的 application,其余三层都能直接从缓存中提取。
三、实战:编写分层优化的 Dockerfile
理解了分层索引的原理后,接下来的关键问题就是:如何在实际构建过程中把 JAR 里不同的逻辑层提取出来,变成 Docker 镜像中独立的物理层。这需要用到一个关键的 Spring Boot 工具模式——jarmode。
3.1 提取分层文件
启用分层功能构建出的 JAR 文件会额外引入 spring-boot-jarmode-tools 依赖。当你在 JAR 启动时指定 -Djarmode=tools,应用将不再以正常业务模式启动,而是进入一种特殊的工具模式,专门用于分层提取等管理操作。
执行以下命令可以将 JAR 中的内容按层提取到 extracted 目录中:
# 从 JAR 中提取分层内容(Java 版本需 ≥ 17)
java -Djarmode=tools -jar target/myapp.jar extract --layers --destination extracted
提取完成后,目录结构如下:
extracted/
├── dependencies/ # 正式版第三方依赖
├── spring-boot-loader/ # Spring Boot 加载器
├── snapshot-dependencies/# SNAPSHOT 依赖(如有)
└── application/ # 业务代码和资源
其中 dependencies 和 snapshot-dependencies 子目录包含的都是第三方依赖 JAR 文件,占比通常可达 70%~80%。如果把这些内容提前放到镜像的前置层,后续每次构建时,只要 pom.xml 没有新增或升级依赖,这两个目录的底层文件内容就不会发生任何变化,COPY 指令也能成功命中 Docker 缓存。
3.2 生产级 Dockerfile 模板
完整的分层优化版 Dockerfile 采用多阶段构建(Multi-stage Builds),分为构建阶段(Builder)和运行阶段(Runtime),最终镜像中只保留运行时所需的文件,绝不包含构建工具和临时文件:
# 构建阶段:提取分层文件
FROM eclipse-temurin:21-jre-jammy AS builder
WORKDIR /builder
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted
# 运行阶段:组装最终镜像
FROM eclipse-temurin:21-jre-jammy
WORKDIR /application
# 按变更频率依次复制各层,确保缓存利用率最大化
COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./
# 创建非 root 用户并切换(生产环境必须)
RUN groupadd -r spring && useradd -r -g spring spring
USER spring:spring
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "application.jar"]
为什么推荐
eclipse-temurin:21-jre-jammy作为基础镜像?
- 非 root 运行:该镜像内建了非 root 用户的配置机制,且包管理器
apt可在需要时方便地安装调试工具。- 体积控制:JRE 镜像远比完整 JDK 镜像小,且 Jammy(Ubuntu 22.04 LTS)变种在稳定性和镜像体积之间取得了较好的平衡,便于接入各类监控和日志采集工具。
构建镜像时,如果 JAR 文件不在默认的 target 路径下,可以通过 --build-arg 来指定:
docker build --build-arg JAR_FILE=build/libs/*.jar -t myapp:latest .
四、进一步瘦身:JRE 裁剪与 Alpine 选择
如果想在保持分层缓存策略的同时进一步减小镜像体积,可以考虑替换基础镜像。
方案一:更换为 Eclipse Temurin JRE 的 Alpine 变种
FROM eclipse-temurin:21-jre-alpine
Alpine Linux 的体积非常小,基础镜像通常只有 50 MB 左右,相比标准镜像能节省几百 MB 的空间。不过需要注意两点:一是 Alpine 使用 musl libc 而非 glibc,某些原生依赖可能无法正常工作;二是需要单独安装 bash、curl 等常用工具。
方案二:使用 jlink 构建自定义 JRE
Spring Boot 3.x 支持与 JDK 的 jlink 工具配合使用,只把运行应用必需的那些 JDK 模块打包进镜像中,从而生成一个高度裁剪的、完全符合应用依赖关系的 JRE 运行镜像。
五、更现代化的选择:Buildpacks 与 Jib
如果你希望进一步减少手工维护 Dockerfile 的工作量,同时自动享受分层构建的优化效果,Buildpacks 和 Jib 是两个非常值得考虑的工具。两者各自遵循着一套标准化的镜像构建规范,让开发者彻底从手动编写 COPY 指令和维护层顺序的细节中解放出来。
5.1 Spring Boot 原生 Buildpacks(默认方案)
从 Spring Boot 2.3 开始,Maven 和 Gradle 插件都已内建了 Buildpacks 支持。只需执行一条命令,插件就会自动完成应用检测、依赖解析和镜像构建:
./mvnw spring-boot:build-image
Buildpacks 会自动完成以下优化:
- 根据应用的字节码检测并选择合适的 JRE 版本;
- 生成自动分层的镜像结构,其分层效果与手工分层式 Dockerfile 类似,无需人工干预;
- 支持类数据共享(CDS)和提前编译(AOT)优化启动速度;
- 默认以非 root 用户运行容器进程,更加安全。
如果需要启用 CDS 优化,在构建命令中加入环境变量即可:
./mvnw spring-boot:build-image -Dspring-boot.build-image.env.BP_JVM_CDS_ENABLED=true
5.2 Google Jib(Java 开发者的专属方案)
Jib 是 Google 开源的一款 Java 容器化工具,以插件的形式与 Maven/Gradle 深度集成。它的最大优势在于:不需要本地安装 Docker 守护进程,在 CI 环境中尤为便利。其镜像构建完全基于 JAR 分析来完成智能分层,开发者几乎不需要写任何 Dockerfile,配置起来也相当简单。
在 pom.xml 中引入 Jib 插件后,通过以下命令即可构建镜像并推送到远程仓库:
./mvnw compile jib:build
Jib 也是遵循 Buildpacks 分层规范的一种实现,同样具备高效的缓存复用能力。
关于 CDS 优化:无论是通过手工 Dockerfile 还是 Buildpacks 构建,Spring Boot 都推荐开启 CDS 来加速容器启动。开启 CDS 后,JVM 会先进行一次训练运行,将类元数据存入共享归档文件;此后每次启动时直接从归档文件中读取类数据,避免重复加载。这在动态扩缩容频繁的云原生环境中尤其有用。
六、生产环境避坑指南
在实际生产环境中落地分层镜像时,以下几个问题是最容易踩坑的,务必多加留意。
1. 文件权限问题(最常见) :部分基础镜像默认以 root 用户运行,构建过程中若先创建目录或写入文件,后续切换到非 root 用户后可能会遇到权限不足导致的启动失败。务必参考前面 Dockerfile 中的示例,在 ENTRYPOINT 之前就创建专用用户并完成权限收敛。
2. JAR 路径变化导致缓存失效:如果你的构建产物路径在不同分支中存在差异(例如 Maven 默认 target/*.jar,Gradle 则默认 build/libs/*.jar),建议通过 ARG 参数化 JAR 文件的源路径,避免因路径不一致而错过缓存复用。
3. dependencies 层膨胀问题:如果你的应用依赖了上百个第三方包,dependencies 层可能会变得很大,但这并不意味着前面的优化无效。实际上这一层变化频率非常低,只需占用一次网络传输,后续更新都能命中本地缓存,完全是合理的取舍。
4. 基础镜像的安全扫描:生产环境建议选择官方提供并定期更新的基础镜像。Eclipse Temurin、Amazon Corretto、Azul Zulu 等主流发行版都会定期修复高危 CVE 漏洞,并附带可信的数字签名。
七、三种构建方案总结
| 方案 | 分层优化 | 是否需要 Dockerfile | 是否需要本地 Docker | CI/CD 友好度 | 适用场景 |
|---|---|---|---|---|---|
| 手工分层 Dockerfile | ✅ 完全手动控制 | 是 | 是 | 一般 | 需要对每一层实现精细定制控制的场景 |
| Spring Boot Buildpacks | ✅ 自动分层 | 否 | 是 | 高 | 追求标准化和最佳实践的新项目,作为官方推荐方案 |
| Google Jib | ✅ 智能分层 | 否 | 否(纯 Java 实现) | 很高 | 无 Docker 环境的 CI 流水线,或者需要极简集成 |
系列拓展阅读
本文属于 MACS Dev Hub“现代架构与编码解决方案”系列中容器化部署体系的核心一环。如果你对 Java 应用的完整工程化流程感兴趣,推荐继续阅读:
- GitHub Actions 构建 Java 项目并推送 Docker 镜像——将本文构建的镜像接入 CI/CD 流水线,实现代码提交后的自动构建与推送
- Java 应用接入 Prometheus + Grafana 全记录——为容器化后的 Java 应用配置完整的监控告警体系
本系列后续还将深入探讨 Java 应用接入 OpenTelemetry 全链路追踪,敬请期待。
本文为 MACS Dev Hub 原创,如需转载请联系授权。





