6.2 从 jar 到 Docker 镜像:多阶段构建


文档摘要

6.2 从 jar 到 Docker 镜像:多阶段构建 本节摘要:Java 服务的容器镜像优化依赖两个机制:Dockerfile 多阶段构建把编译环境与运行环境分离,jar 分层打包把"变得慢的依赖层"与"变得快的应用层"拆开。两者配合让镜像体积可控、缓存命中最大化。本节给出完整配置与缓存失效原理,并对比 buildpacks 路线。 一个把依赖层每天重传十遍的 Dockerfile 团队的第一版 Dockerfile 长这样: 三个问题。体积:构建工具链、源码、全部构建中间产物全进了最终镜像,一个服务镜像超过 1GB。安全:源码与构建凭据跟着镜像分发。缓存:任何一行代码变更都让 COPY 之后的层全部失效——依赖解析与编译每天重跑十遍,第 5 章调好的构建速度在镜像环节全部吐了出来。

6.2 从 jar 到 Docker 镜像:多阶段构建

本节摘要:Java 服务的容器镜像优化依赖两个机制:Dockerfile 多阶段构建把编译环境与运行环境分离,jar 分层打包把"变得慢的依赖层"与"变得快的应用层"拆开。两者配合让镜像体积可控、缓存命中最大化。本节给出完整配置与缓存失效原理,并对比 buildpacks 路线。

一个把依赖层每天重传十遍的 Dockerfile

团队的第一版 Dockerfile 长这样:

# 反面教材:单阶段构建 FROM 带完整 JDK 的构建镜像 WORKDIR /app COPY . . RUN mvn clean package -DskipTests CMD ["java", "-jar", "target/risk-engine-2.3.0.jar"]

三个问题。体积:构建工具链、源码、全部构建中间产物全进了最终镜像,一个服务镜像超过 1GB。安全:源码与构建凭据跟着镜像分发。缓存:任何一行代码变更都让 COPY 之后的层全部失效——依赖解析与编译每天重跑十遍,第 5 章调好的构建速度在镜像环节全部吐了出来。

多阶段构建:装配台与运行台分离

正面教材分两个阶段:

# 第一阶段:构建(装配台 用完即弃) FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build # 先只拷贝依赖描述文件:这一层只在依赖变化时失效 COPY pom.xml . RUN mvn dependency:go-offline # 再拷贝源码:代码变更只让这一层之后的缓存失效 COPY src ./src RUN mvn clean package -DskipTests # 第二阶段:运行(运行台 只留成品) FROM eclipse-temurin:17-jre WORKDIR /app # 只把构建阶段的成品带过来 源码与工具链留在装配台 COPY --from=builder /build/target/risk-engine-2.3.0.jar app.jar CMD ["java", "-jar", "app.jar"]

缓存收益的关键在 COPY 的顺序设计:pom.xml 单独一层加依赖预下载,改代码时依赖层缓存命中,只重编译;改依赖时才触发依赖层的重建。这正是第 5.2 节"依赖缓存是生命线"在镜像世界的翻版。体积收益来自第二阶段只装运行时(jre 而非 jdk),镜像从 1GB 档降到 300MB 档。

分层打包:镜像层的精细切分

多阶段解决了"装配与运行分离",分层打包进一步解决"应用内部的变化梯度"。第 6.1 节埋的伏笔收线:repackage 的产物内部按"依赖、应用类、资源"组织,layertools 模式可以把它按层解开:

# 解包出分层结构 java -Djarmode=layertools -jar app.jar extract # 产物目录: # dependencies/ 全部第三方依赖 变化频率低 # spring-boot-loader/ 加载器 几乎不变 # snapshot-dependencies/ 快照依赖 变化频率中 # application/ 应用自己的类 每次提交都变

Dockerfile 的第二阶段改为逐层 COPY:

FROM eclipse-temurin:17-jre WORKDIR /app COPY --from=builder /build/dependencies/ . COPY --from=builder /build/spring-boot-loader/ . COPY --from=builder /build/snapshot-dependencies/ . COPY --from=builder /build/application/ . ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

图:两种打包方式的缓存失效对比

图:两种打包方式的缓存失效对比

依赖库上百 MB、应用代码几 MB 的典型服务里,分层带来的推送与拉取节省是数量级的。注意快照依赖单独成层的用意:它的变化频率介于两者之间,混进任何一层都会拖累那一层的命中率。

验证分层效果的手段也交代一下,别凭感觉宣布胜利:

# 本地验证镜像分层与体积 docker history 服务镜像名 # 输出按层列出大小与创建指令 检查依赖层与应用层是否如设计分离 # 验证缓存命中:改一行代码重新构建 docker build . 2>&1 | findstr "CACHED" # 期望输出:依赖层与加载器层显示 CACHED 只有应用层重新执行 # 对比实验:不分层的镜像同场景构建 # 两者的总耗时差 就是分层的净收益

镜像体积的另一个常见问诊点:基础镜像的选择。运行台用带 jre 后缀的变体而非完整 jdk(第 6.2 节开头已做),进一步可考察精简系基础镜像(更小的发行版底座),但每换一次底座要重验一遍字体、时区、原生库这些隐性依赖——精简与稳妥的平衡点,按"体积账单是否真的疼"来决定。

另一条路线:buildpacks

除了手写 Dockerfile,Spring Boot 插件提供 build-image 目标走 buildpacks 路线:一条命令从 jar 直接产镜像,基础镜像、JVM 参数、分层全部由构建包自动决策。取舍表:Dockerfile 路线可控性最强(每层每命令都在手里),适合有专人维护镜像规范的团队;buildpacks 路线零维护(升级基础镜像与最佳实践由上游构建包跟进),适合快速起步与小团队。risk-engine 的选择是 Dockerfile 加分层——银行交付场景对镜像内容有逐层审计要求,可控性优先。

⚠️ 常见坑:镜像里烙进构建时的 SNAPSHOT 依赖还不自知。第 5.5 节的禁快照军规在镜像场景要再加一条:镜像构建输入的 jar 必须来自发布仓的 RELEASE 制品(或本地等价物),构建脚本里对 SNAPSHOT 后缀直接报错拒绝。

💡 关键直觉:把镜像层当作"按变化频率归类的抽屉"来设计——变得越慢的东西放越深的抽屉。依赖、加载器、快照、应用四个抽屉的分法,本质是给"变化"这个变量做梯度分离,缓存命中是它的自然回报。

战报小结

  • 多阶段构建三收益:体积(运行台只装 jre)、安全(源码凭据不进镜像)、缓存(依赖层与代码层解耦);
  • COPY 顺序是缓存设计:依赖描述先行,源码殿后,改代码不重下依赖;
  • 分层打包按变化梯度切层:依赖、加载器、快照、应用四抽屉,推送量只剩变更层;
  • buildpacks 与 Dockerfile 取舍:零维护换可控性,按团队审计需求选边;
  • 镜像输入禁快照:RELEASE 制品是唯一合法输入,SNAPSHOT 进镜像即不可复现事故。

容器交付就绪。下一节处理语言维度的协同:Kotlin 与 Java 在同一个 Maven 工程里混编,编译顺序与版本对齐怎么配。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U