Spring Boot Fat JAR 容器化指南
背景
传统 Spring Boot 项目通常以 Fat JAR 形式交付,通过 java -jar app.jar 直接运行。随着容器化的普及,越来越多的场景要求以 Docker 镜像形式交付,或在开发测试阶段使用容器环境。但是往往又不希望或者没有资源大范围或者大幅度修改已有基础设施以适配。该文档基于该需求给出一种可行的解决方案。
传统方案及其问题
最直接的容器化方式是将 JAR 整体打入镜像:
dockerfile
FROM eclipse-temurin:8-jre
WORKDIR /app
COPY target/app.jar .
ENTRYPOINT ["java", "-jar", "app.jar"]
该机制存在明显的效率缺陷,由于 Docker 镜像构建采用分层缓存机制,jar 作为一个整体文件导致缓存粒度过粗,内部任何微小的应用代码或配置文件变更,都会导致整个包含数百兆第三方依赖的镜像层缓存失效。这直接导致每次构建需全量传输数据,造成构建周期延长、存储冗余以及网络带宽的无效消耗。且配置文件查看修改映射也极为不便。
基于物理结构的解压与分层原理
Fat JAR 内部结构
为在不修改现有项目构建配置的前提下解决效率问题,可通过分析应用程序的内部物理结构,实施基于多阶段构建的手动解压与分层策略。
自 Spring Boot 1.4 版本起,其可执行归档文件内部采用了标准化的目录结构。核心组件被物理隔离在不同的层级中,结构如下:
app.jar
├── BOOT-INF/
│ ├── classes/ # 应用程序自身的编译字节码与配置文件,该部分体积较小但随开发迭代频繁变更
│ └── lib/ # 所有第三方依赖包,该部分体积最大且变更频率极低
├── META-INF/ # 框架的元数据
└── org/ # 框架的类加载器
利用此结构特征,可在容器构建阶段将归档文件拆解,并依据各目录的变更频率顺序构建镜像层。
构建流程
实现此策略依赖容器的多阶段构建特性,过程分为解压提取与分层组装两个阶段。
在解压提取阶段,引入包含完整 Java 开发工具包的基础镜像作为构建器,利用系统自带的归档管理工具将编译产物解压至指定的工作目录。此步骤与 Spring Boot 自身的运行机制解耦,构建环境无需与应用程序最终的运行版本严格一致,具备标准的解压能力即可。
在分层组装阶段,采用仅包含 Java 运行环境的轻量级基础镜像。根据缓存命中率最大化原则,从解压目录中按变更频率由低至高的顺序将文件复制到最终镜像。第三方依赖最先被复制,随后是引导程序与元数据,最后是应用程序字节码。在指定容器启动命令时,需避开传统的启动方式,改为直接调用 Spring Boot 内部的类加载器。
注意:启动类的路径受框架版本影响
对于 Spring Boot 1.4 至 3.1 版本,需指定为 org.springframework.boot.loader.JarLauncher
对于 Spring Boot 3.2 及更高版本,需指定为 org.springframework.boot.loader.launch.JarLauncher。
完整的容器化构建配置如下所示:
dockerfile
# 阶段 1:解压提取
FROM eclipse-temurin:8-jdk AS builder
ARG JAR_FILE=app.jar
WORKDIR /app
COPY ./target/${JAR_FILE} ./app.jar
RUN mkdir -p exploded && cd exploded && jar -xf ../app.jar
# 阶段 2:分层组装
FROM eclipse-temurin:8-jre
WORKDIR /app
# 依据变更频率由低至高复制文件
COPY --from=builder /app/exploded/BOOT-INF/lib/ ./BOOT-INF/lib/
COPY --from=builder /app/exploded/org/ ./org/
COPY --from=builder /app/exploded/META-INF/ ./META-INF/
COPY --from=builder /app/exploded/BOOT-INF/classes/ ./BOOT-INF/classes/
# 适用于 Spring Boot 1.4 ~ 3.1 版本的启动配置
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
# 适用于 Spring Boot 3.2+ 版本的启动配置需替换为:
# ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
相对传统方案的优点
- 依赖库层可被缓存复用,仅业务代码层需要重新构建和传输
- 配置文件位于
BOOT-INF/classes/层,可单独更新或替换而不影响依赖层 - 构建和推送时间显著缩短
快照依赖的精细化隔离策略
在包含频繁变动快照版本依赖的项目环境中,整体复制依赖目录仍可能引发非预期的缓存失效。为进一步精细化分层,可在解压提取阶段对依赖目录进行二次处理,识别并剥离带有快照依赖至独立目录。在分层组装阶段,依次复制稳定版依赖目录与快照版依赖目录。这种分离操作可确保稳定版依赖层的缓存不被日常联调破坏,示例如下:
dockerfile
# 在 builder 阶段分离快照依赖
RUN mkdir -p exploded/BOOT-INF/lib-snapshot \
&& find exploded/BOOT-INF/lib/ -name "*-SNAPSHOT.jar" -exec mv {} exploded/BOOT-INF/lib-snapshot/ \;
# 在最终镜像阶段依次拷贝
COPY --from=builder /app/exploded/BOOT-INF/lib/ ./BOOT-INF/lib/
COPY --from=builder /app/exploded/BOOT-INF/lib-snapshot/ ./BOOT-INF/lib/
方案对比与适用范围
自 2.3 版本起,Spring Boot 官方框架引入了原生分层支持,通过配置构建插件即可生成包含分层索引的归档文件,并利用特定的系统参数提取出逻辑层。虽然官方方案提供了标准化的集成路径,但上述手动解压分层策略仍具有特定优势。
该策略具有完全的非侵入性不依赖特定 Spring Boot 版本,且无需对应用程序的源代码或项目构建配置文件进行修改。适用于
- 无法升级框架版本
- 无法修改或全面修改现有构建配置
- 处于项目容器化升级过渡期
- fatjar 来源于三方交付无法控制其构建方式
的项目