本节目标:把
mvn package的产物变成能按层复用的容器镜像,理解分层顺序、jarmode=tools提取、基础镜像与非 root 运行的取舍,并拿到可直接落地的Dockerfile与.dockerignore。
适用版本:Spring Boot 4.1.x(Java 21)
15.1 镜像构建与分层
前 14 章我们把 book-loan 服务写成了一个能通过集成测试的 fat jar。生产交付的最后一步是把它塞进镜像并推到 registry。很多人在这里的第一反应是「写个三行 Dockerfile,COPY 进去,java -jar 起来」——能跑,但每次改一行业务代码,镜像都要重新推几百 MB,滚动更新的拉取时间会被放大到不可接受。本节解决的就是「怎么让镜像层复用起来」。
从 fat jar 到镜像:为什么不能直接 COPY
spring-boot-maven-plugin 的 repackage 目标把应用打成一个 fat jar:BOOT-INF/classes/ 放你的类,BOOT-INF/lib/ 放几十个第三方依赖,BOOT-INF/lib/ 里的依赖很少变,BOOT-INF/classes/ 几乎每次提交都变。
问题在于镜像层是整块复制的。如果 Dockerfile 写成:
COPY target/book-loan-1.0.0.jar app.jar
那这一个 COPY 指令产生一个层,内容是整个 fat jar。只要你改了一个 .java 文件,这个层的 hash 就变了,Docker 会把它整层重传。依赖那 60 MB 明明没动,却要跟着业务代码一起重推。
Docker 的缓存规则是按指令匹配的:每条 COPY/RUN 的缓存 key 由「父层 + 指令内容 + 被复制文件的校验和」共同决定。所以只要把「很少变的内容」和「经常变的内容」拆成不同的 COPY 指令,前者的层就能在后续构建里命中缓存。这正是分层的全部意义。
Dockerfile 分层顺序:变化慢的放前面
Spring Boot 从 2.3 起就支持把 fat jar 按内容拆成固定的几个逻辑层。顺序是从最稳定到最易变:
| 层 | 内容 | 变化频率 |
|---|---|---|
dependencies | 第三方 release 依赖 | 改 pom.xml 才变 |
spring-boot-loader | Spring Boot 的 loader 类 | 换 Boot 版本才变 |
snapshot-dependencies | SNAPSHOT 依赖 | 每次拉取可能变 |
application | 你的 BOOT-INF/classes 与资源 | 每次提交都变 |
把最稳定的 dependencies 放最底层、最易变的 application 放最顶层,业务代码变更就只会让最顶层失效,其余层全部命中缓存。一个可用的两阶段 Dockerfile 长这样:
# syntax=docker/dockerfile:1
# ---- 阶段一:解包 fat jar,按层拆开 ----
FROM eclipse-temurin:21-jre AS extract
WORKDIR /workspace
COPY target/book-loan-1.0.0.jar application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted
# ---- 阶段二:只装运行时,按"稳定 → 易变"叠层 ----
FROM eclipse-temurin:21-jre
WORKDIR /application
COPY --from=extract /workspace/extracted/dependencies/ ./
COPY --from=extract /workspace/extracted/spring-boot-loader/ ./
COPY --from=extract /workspace/extracted/snapshot-dependencies/ ./
COPY --from=extract /workspace/extracted/application/ ./
USER 1001
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "org.springframework.boot.loader.launch.JarLauncher"]
几个细节值得说明:
extract阶段用同一个基础镜像。它只是为了跑一次解包,用完整 JRE 图省事;也可以用21-jdk或更小的镜像,反正不进入最终镜像。- 每个层一条
COPY,四条COPY各自成一个镜像层,缓存粒度才正确。把四条合并成一条COPY a b c d ./会让它们退化成一层,缓存优势全没了。 ENTRYPOINT直接用JarLauncher,不需要-jar。运行的是解包后的目录布局,JarLauncher会按BOOT-INF/结构装载类。这里用的是 4.x 的包名org.springframework.boot.loader.launch.JarLauncher(3.2 起loader包下新增了launch子包,写镜像脚本时别照抄旧文章里的org.springframework.boot.loader.JarLauncher)。
为什么用两个阶段
有人会问:既然只是解包,为什么不直接在最终镜像里解包?因为解包的前提是先把 fat jar 拷进镜像,而这个 COPY 层会永久留在最终镜像里——等于把整个 fat jar 又背了一份,镜像体积反而更大。两阶段构建让解包发生在中间的 builder 阶段,最终镜像只保留解包后的分层目录,fat jar 本身不进入最终镜像。
如果不想写多阶段,也可以退一步:在 CI 里先解包,再让 Dockerfile 直接 COPY 解包结果。
# CI 里先解包(需要 JDK 与已构建好的 fat jar)
java -Djarmode=tools -jar target/book-loan-1.0.0.jar extract --layers --destination target/extracted
之后把 Dockerfile 里四条 COPY 的源从 --from=extract 改成 target/extracted/... 即可。这种写法的好处是构建上下文只传解包结果、Dockerfile 更简单;代价是流水线多一步,且本地 docker build 前要手动跑一次解包。两种方式产出的层结构完全一致,按团队流水线形态选即可。
用 tools 模式提取分层
上面那句 java -Djarmode=tools -jar application.jar extract --layers 是本节的核心,需要单独说清它的来历,因为这正是 4.x 与 3.x 的分水岭之一。
jarmode 是 fat jar 内置的一个「模式开关」:启动时 JVM 把 -Djarmode 交给 loader,loader 不启动应用,而是执行该模式对应的子命令。历史上提取分层用的是 layertools:
# 3.x(已废弃)——4.1 起这段命令会直接报错
java -Djarmode=layertools -jar app.jar extract
Spring Boot 4.1 移除了 layertools 模式,官方 release notes 的原话是「The deprecated layertools jar mode has been removed in this release」,并提示改用 tools。4.x 的正确写法是:
# 4.x:列出这个 jar 会被拆成哪些层
java -Djarmode=tools -jar application.jar list-layers
# 4.x:把 jar 按层解包到 extracted/
java -Djarmode=tools -jar application.jar extract --layers --destination extracted
extract 支持三个选项:--layers(按层拆分,不带则平铺)、--destination(输出目录)、--launcher(控制是否生成可执行的 launcher)。不确定当前 jar 的层划分时,先跑一次 list-layers,它输出的顺序就是 Dockerfile 里 COPY 应该遵循的顺序。
分层配置本身可以在 pom.xml 里调整。默认配置来自插件内置的 META-INF/spring/layers/default.xml,层顺序是 dependencies → spring-boot-loader → snapshot-dependencies → application:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>
layers 默认就是启用的,绝大多数项目不需要动它。真要定制(比如把某个体积大又稳定的内部依赖单独成层),4.1 新增了从 classpath 加载层配置的能力:把自定义的 <name>.xml 放到 META-INF/spring/layers/ 下并作为插件依赖引入即可。
.dockerignore:别把无关文件送进构建上下文
docker build 会把整个构建上下文(默认是当前目录)打包发给守护进程。项目根目录里的 target/、.git/、.idea/ 动辄几百 MB,白白拖慢每次构建。用 .dockerignore 排掉:
# .dockerignore
target/
!.gitkeep
.git/
.gitignore
.idea/
*.iml
*.log
**/node_modules/
docs/
注意一个反直觉的点:target/ 通常要排除,但我们的 Dockerfile 又需要 target/book-loan-1.0.0.jar。正确做法是在 target/ 里只保留要用的 jar——写 target/* 会把 jar 也排除,所以要么精确排除 target/classes/、target/test-classes/、target/generated-*,要么在 CI 里先 mvn package 再单独 docker build,让构建上下文只包含 jar。两种方式都比「把整个 target 传进去」干净。
基础镜像怎么选
基础镜像决定了镜像体积、C 库(glibc 还是 musl)和可用的排障工具,是一个需要权衡的决策:
| 镜像 | 体积量级 | C 库 | 取舍 |
|---|---|---|---|
eclipse-temurin:21-jre | 中等 | glibc | 官方推荐,兼容性最好,有基本工具 |
eclipse-temurin:21-jre-alpine | 小 | musl | 体积小,但 musl 下 native 库(如某些 JDBC、JNI)易出问题 |
gcr.io/distroless/java21-debian12 | 小 | glibc | 无 shell、无包管理器,攻击面最小,排障只能靠日志 |
paketobuildpacks/builder-noble-java-tiny(Buildpacks 默认) | 由 buildpack 决定 | glibc | 由 buildpack 生成,见下节 |
三条经验:
- 优先选
-jre而不是-jdk。运行时不需要编译器,JDK 镜像白白多出上百 MB。 alpine不是无脑选项。它省体积,但换成 musl 后任何依赖 glibc 的 native 扩展都可能加载失败。除非你确定没有 native 依赖,否则别为了体积冒险。distroless换来的是运维成本。没有sh、没有curl,kubectl exec进去什么都干不了;探针只能走 HTTP 端点(见 15.3),不能写shell脚本。安全要求高的场景值得,普通业务镜像用temurin:21-jre更省心。
非 root 用户运行
容器默认以 root 运行,一旦进程被攻破就是宿主机级风险。Temurin 镜像里默认没有非 root 用户,需要自己建,或用数字 UID:
# 方式一:建一个专用用户(推荐,便于识别)
RUN useradd --system --uid 1001 --create-home appuser
USER appuser
# 方式二:直接用数字 UID(distroless 场景常用)
USER 1001
用数字 UID 的好处是镜像里不必存在对应的 /etc/passwd 条目,distroless 这种没有 useradd 的镜像也能用。配套要注意两点:
- 工作目录要可写(如果应用要写临时文件)。
WORKDIR /application由 root 创建,非 root 用户可能没有写权限;需要写盘时把目录chown给对应用户,或改用/tmp。 - 监听端口。非 root 用户无法绑定 1024 以下的特权端口,所以容器内固定用 8080,映射到宿主机的 80/443 由 ingress 或编排层负责。
Buildpacks 还是 Dockerfile
Spring Boot 提供了 spring-boot:build-image 目标,底层是 Cloud Native Buildpacks(CNB):不写 Dockerfile,由 buildpack 自动探测这是个 Java 应用并生成镜像。
# 不写 Dockerfile,直接出镜像
mvn spring-boot:build-image -Dspring-boot.build-image.imageName=book-loan:1.0.0
它同样会做分层(buildpack 的 Java 层比默认的四层更细),并且默认生成非 root 用户、带 SBOM。默认 builder 在 3.5 起换成了 paketobuildpacks/builder-noble-java-tiny(更小的 jammy/noble 变体)。
两种方式的取舍:
| 维度 | 手写 Dockerfile | Buildpacks |
|---|---|---|
| 可控性 | 完全可控,每层都是你写的 | 由 buildpack 决定,定制靠 env 与 extensions |
| 上手成本 | 要理解分层与工具链 | 一条命令出镜像 |
| 构建依赖 | 只需 Docker | 需要 buildpack 镜像(首次构建要拉) |
| 基础镜像 | 你选 | buildpack 选 |
| 排障 | 分层结构自己清楚 | 层结构由 buildpack 生成,需读日志 |
判断标准:如果你的组织没有统一的镜像规范,用 build-image 省事;如果基础镜像、层结构、安全基线都要自己控制(大多数有平台团队的公司),手写 Dockerfile 更合适。 两者不冲突,很多团队用 Dockerfile 做生产镜像、用 build-image 做本地快速验证。
构建与缓存命中(示例输出)
下面这段是示例输出,不是本机实测——本机 Docker 版本为 29.5.2,可用,但镜像拉取与构建耗时取决于网络与缓存状态,这里展示的是「改了一行业务代码后再次构建」时应当看到的样子:
# 示例输出(非本机实测)
$ docker build -t book-loan:1.0.0 .
[+] Building 4.2s (13/13) FINISHED
=> [extract 2/3] COPY target/book-loan-1.0.0.jar application.jar 0.1s
=> [extract 3/3] RUN java -Djarmode=tools -jar application.jar extract ... 2.4s
=> [stage-1 2/6] COPY --from=extract /workspace/extracted/dependencies/ ./ CACHED
=> [stage-1 3/6] COPY --from=extract /workspace/extracted/spring-boot-loader/ CACHED
=> [stage-1 4/6] COPY --from=extract /workspace/extracted/snapshot-dependencies/ CACHED
=> [stage-1 5/6] COPY --from=extract /workspace/extracted/application/ ./ 0.2s
=> exporting to image 1.0s
判断缓存是否生效的方法很朴素:改一行业务代码,重新 docker build,看输出里带 CACHED 的层是不是那三个稳定层。如果 dependencies 层也重建了,说明你的分层顺序写反了,或某条 COPY 把易变内容混进了稳定层。想量化收益,用 docker images 对比改代码前后的层大小,或看 registry 推送时实际传输的字节数。
常见坑
- 把 fat jar 整块
COPY进去:这是最普遍的问题,直接失去分层能力,每次构建重推整包。 - 照抄 3.x 的
layertools:4.1 已移除,命令会失败。改成-Djarmode=tools ... extract --layers。 ENTRYPOINT用了旧的loader.JarLauncher:4.x 是loader.launch.JarLauncher,路径错了会ClassNotFoundException。- 分层了却没用上:Dockerfile 分了四条
COPY,但.dockerignore或 CI 脚本把 jar 之外的target/也传了进来,构建上下文体积暴涨,构建慢得像是分层没生效。 - 基础镜像用了
alpine又有 native 依赖:表现为启动时UnsatisfiedLinkError,且只在容器里出现、本地 IDE 里正常,很容易误判为「镜像有问题」。 - 以 root 运行:安全扫描(如 Trivy)会报 HIGH,很多集群的 admission policy 也会直接拒绝部署。
小结
镜像分层的核心只有一句话:把变化慢的内容放进下层、变化快的内容放进上层,让缓存最大化命中。Spring Boot 的 fat jar 天然适合按 dependencies / spring-boot-loader / snapshot-dependencies / application 拆分,4.x 用 -Djarmode=tools 提取(layertools 已在 4.1 移除)。基础镜像优先 temurin:21-jre,非 root 用数字 UID 或专用用户,.dockerignore 把构建上下文压到只含 jar。Buildpacks 与手写 Dockerfile 各有适用面,控制权要求高时选手写。
镜像能构建出来,下一个问题是:怎么把它稳定地调度到集群上,并在资源、探针、配置注入、滚动更新上不踩坑。这正是下一节的主题。
阅读导航:上一节:14.3 断点续传与分片上传 · 下一节:15.2 Kubernetes 部署要点 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。