大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
构建与优化
这一课你将能做到
- 看懂构建输出里的
CACHED,说出「哪些指令会命中缓存、什么情况下缓存会失效」 - 用多阶段构建把 Spring Boot 镜像从「源码 + JDK + Maven」瘦身到「纯 JRE + jar」,体积砍掉一半以上
- 熟练使用
docker build的-t、-f、--build-arg、--no-cache、--platform参数
为什么要学
同样的 Spring Boot 项目,有人构建要 5 分钟、镜像 800MB;有人构建 30 秒、镜像 250MB。差距来自两点:对层缓存的理解、对镜像内容的取舍。CI 上每次构建多等 4 分钟,一天几十次构建就是几个小时的白等;镜像多 500MB,每次发布就多传 500MB。
这一课解决的问题非常具体:改一行 Java 代码,为什么整个镜像要重建?怎么让 Maven 依赖下载只发生一次?最终的镜像里为什么不需要 Maven 和源码?答案就是本课的两个核心:层缓存与多阶段构建。
先建立图像
构建缓存的判定规则:从上到下逐条指令比对,一旦某层失效,其后所有层全部重建。
# 反例:每次改代码,mvn 依赖全部重新下载
FROM eclipse-temurin:21-jre
COPY . /app ← 任何文件变了,这层失效
RUN cd /app && mvn package ← 上一层失效 → 这层必然重跑(重新下载依赖)
# 正例:依赖声明先 COPY,代码后 COPY
COPY pom.xml . ← pom 没变 → 缓存命中
RUN mvn dependency:go-offline ← 缓存命中,依赖一次都不用重新下
COPY src ./src ← 只有 src 变了 → 仅这层起失效
RUN mvn package ← 重跑,但依赖已缓存,快
多阶段构建的分工:
阶段 1 builder:Maven + JDK + 源码 → 产出 jar (构建完即弃)
阶段 2 runtime:纯 JRE + jar → 最终镜像 (只带走结果)
跟着做
步骤 1:先体验一次缓存命中
用上一课的最小 Dockerfile,连续构建两次:
docker build -t demo-app:1.0 .
docker build -t demo-app:1.0 . # 第二次几乎全部 CACHED,秒级完成
然后重新 mvn clean package(jar 内容变了),再构建:
mvn clean package -DskipTests
docker build -t demo-app:1.1 .
# 输出:FROM/WORKDIR 层 CACHED,COPY target/... 层重新执行
现象确认:只有「内容变化的层」及其后的层重建,FROM 这种基础层永远命中。
步骤 2:改造 Dockerfile,利用依赖缓存(单阶段版)
前提:你在本地/CI 先 mvn package。如果想让镜像内完成编译,用下面多阶段版;此处先改造 COPY 顺序养成习惯:
FROM eclipse-temurin:21-jre
ENV TZ=Asia/Shanghai
WORKDIR /app
COPY target/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
改动很小的项目,反复构建基本只重建 COPY 这一层,其余全缓存。
步骤 3:多阶段构建——镜像里自己完成编译
# ---- 阶段 1:构建 ----
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B # 先拉依赖,pom 不变则永远缓存
COPY src ./src
RUN mvn clean package -DskipTests
# ---- 阶段 2:运行 ----
FROM eclipse-temurin:21-jre
ENV TZ=Asia/Shanghai
WORKDIR /app
COPY --from=builder /build/target/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
构建与验证:
docker build -t demo-app:1.2 .
docker run -d --name demo2 -p 8081:8080 demo-app:1.2
curl http://localhost:8081/actuator/health
步骤 4:对比镜像体积
docker images | findstr demo-app
典型结果:单阶段用 maven 基础镜像的版本约 700MB+;多阶段最终镜像 200~300MB(只有 JRE + jar)。Maven、JDK 编译器、.m2 缓存、源码全部留在了 builder 阶段,不会进入最终镜像。
步骤 5:常用构建参数
docker build -t demo-app:1.3 . # -t 打标签
docker build -f Dockerfile.prod -t demo-app:prod . # -f 指定其他 Dockerfile
docker build --build-arg JAR_VERSION=1.2 -t demo-app:x . # 传构建期变量
docker build --no-cache -t demo-app:fresh . # 全量重建(怀疑缓存脏了时用)
docker build --platform linux/amd64 -t demo-app:amd64 . # 指定目标平台
Dockerfile 里配合 ARG 使用(注意 ARG 与 ENV 的区别:ARG 只在构建期有效,ENV 会带进运行期):
ARG JAR_VERSION=unknown
LABEL version=${JAR_VERSION}
步骤 6:进阶方向——Spring Boot 分层 jar(了解即可)
Spring Boot 3 支持把 fat jar 拆成「依赖层 + 代码层」,让代码变更只重建 KB 级的层:
java -Djarmode=tools -jar app.jar extract --layers --destination extracted
配合 Dockerfile 把各层分别 COPY。中小项目收益有限(依赖层本来就不常变),团队项目规模上来后再引入。本课把它作为方向认知,不作为必做项。
讲清楚要点
-
缓存判定的粒度是指令 + 输入内容:RUN 看指令字符串本身是否变过;COPY/ADD 看被复制文件的内容校验和。父层失效,其后全部失效——所以「变化少的写前面,变化大的写后面」是铁律。
-
多阶段构建的减脂原理:最终镜像只由最后一个阶段生成,
COPY --from=builder只拷贝指定文件。构建工具、源码、缓存全部留在废弃阶段。 -
基础镜像选型梯度(Spring Boot 运行时):
eclipse-temurin:21-jre(约 300MB,稳)→21-jre-alpine(约 180MB,注意 musl)→ 自定义 jlink/jlink 精简运行时(更小,成本高)。先跑稳,再减脂。 -
合并 RUN 减少层数:同层内下载+解压+删除要写进同一条 RUN,否则「下载层」依然留在镜像里:
RUN apt-get update && apt-get install -y curl \ && rm -rf /var/lib/apt/lists/* -
--platform 是跨架构部署刚需:Apple Silicon 开发机构建的 arm64 镜像,推给 amd64 云服务器会报
exec format error。CI 构建一律显式指定--platform linux/amd64,或用 buildx 一次产出多架构镜像。
你可能会懵的地方
-
我明明什么都没改,为什么这次没走缓存? 缓存自上而下判定,任何上游层失效都会传染。常见触发:
COPY . .把日志/临时文件也拷了(内容变了)→ 全链失效。解法:精确 COPY + 完善的.dockerignore。 -
在镜像里构建和在 CI 里构建,选哪个? 镜像内多阶段构建的好处:任何有 Docker 的机器都能一键构建,环境绝对一致;坏处:构建期需要联网拉依赖。CI 构建产物再 COPY 进镜像的方案在流水线成熟的公司更常见。两种都必须会写。
-
--no-cache什么时候用? 怀疑缓存污染(比如依赖仓库发布过同版本号的新包)或安全要求强制拉最新基础镜像时。日常不要用,它让你白付所有下载成本。 -
为什么我的构建上下文有 2GB?
docker build .会把目录整个发给 daemon。项目里有前端 node_modules、日志、数据备份文件都会被算进去。.dockerignore一行logs就能砍掉大半。 -
镜像还能再小吗? 能,但有边际成本:jlink 定制运行时、distroless 基础镜像、GraalVM native image(编译成原生可执行文件,启动毫秒级,但构建复杂、反射兼容要逐个处理)。先确认「小」真的是你的瓶颈再上。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
COPY . . 放在 Dockerfile 开头 | 任何文件变动导致全量重建 | 精确 COPY,产物类放最后 |
| 依赖下载和编译写进同一个 RUN | pom 未变也要重新下载依赖 | 拆成「COPY pom + 拉依赖」「COPY src + package」两段 |
| 单阶段用 JDK/Maven 镜像直接跑应用 | 镜像 700MB+,攻击面大 | 多阶段构建,运行镜像只含 JRE + jar |
| RUN 里下载和删除写两条 | 删除发生在新层,体积不减 | 同一条 RUN 内 && rm -rf |
| Apple Silicon 构建完直接部署 x86 服务器 | 运行报 exec format error | --platform linux/amd64 或 buildx 多架构 |
每次构建都加 --no-cache | 全量下载,CI 时间翻倍 | 只在怀疑缓存污染时使用 |
卡点急救
- 多阶段
COPY --from报找不到文件:检查源路径是 builder 阶段内的绝对路径(/build/target/app.jar),并确认mvn package的finalName与之一致。 - 镜像内 Maven 拉依赖超时:构建环境网络问题。可在 builder 阶段挂国内 settings.xml:
COPY settings.xml /root/.m2/settings.xml(阿里云 Maven 镜像仓库配置)。 - 构建时报
failed to solve: ... exec format error:基础镜像架构与当前机器不符,给 FROM 相关镜像或整个 build 加--platform。 - 缓存永远不生效:确认
docker build传的是同一个上下文目录;BuildKit 下若用--mount=type=cache的缓存被清也会重建。 - 构建产物与预期不符:
docker history 镜像名逐层核对哪条指令产生了意外内容,比盲改 Dockerfile 快。
面试常问
-
Docker 构建缓存的工作机制? 逐层判定:RUN 比对指令文本,COPY/ADD 比对文件内容哈希;某层失效则其后全部重建。优化手段是合理排序(稳定的在前)与最小化 COPY 范围。
-
什么是多阶段构建,解决什么问题? 一个 Dockerfile 定义多个 FROM 阶段,构建阶段包含编译工具链,最终阶段只 COPY 构建产物。解决「编译环境污染运行镜像」的问题,显著减体积与攻击面。
-
如何给 Spring Boot 应用做镜像瘦身? 多阶段构建(去掉 Maven/JDK/源码)→ 运行镜像选 JRE 而非 JDK → 基础镜像选 slim/alpine → 合并 RUN 并同层清理缓存 →
.dockerignore缩小上下文 → 进阶用 Spring Boot 分层 jar 或 jlink。 -
CI 流水线里构建慢怎么排查? 看三处:构建上下文是否过大(.dockerignore);依赖是否反复下载(依赖层未命中缓存);是否用了
--no-cache。远程缓存(registry 缓存后端)可作为进阶方案。
自测三问
- 下面两条指令为什么第一条放前面能加速构建:
COPY pom.xml .与COPY src ./src? - 多阶段构建后镜像从 700MB 降到 250MB,减掉的 450MB 具体是什么东西?
- ARG 和 ENV 有什么区别?
--build-arg传的值会留在最终镜像里吗?
小结一句话
构建优化的全部秘密是「变化少的层放前面 + 只把产物带进最终镜像」——层缓存省时间,多阶段构建省体积,两者共同决定你的 CI 与发布效率。
延伸
- 多阶段构建官方文档
- Spring Boot Docker 分层官方文档
- 下一课预告:镜像仓库——把构建好的镜像推上 Docker Hub / 阿里云 ACR,让任何机器一拉即用。