Docker 多阶段构建(Multi‑stage Build),核心目的:
- 把编译构建环境和运行环境分开,最终镜像只保留运行时产物,大幅减小镜像体积,不携带编译器、依赖源码等冗余文件。
- 可用于构建简易的 CI 流水线,适合个人或中小企业,避免搭建笨重的 CI/CD 平台。
以 SpringBoot3 项目为例说明。
一、多阶段构建 Dockerfile 示例
docker
#
# 前提:
# 执行 build 前,将需要构建模块的 Dockerfile 移到顶层目录,即上一层目录
#
# ====================== 构建阶段:Maven编译打包 Java17 ======================
FROM maven:3.9.9-eclipse-temurin-17 AS builder
WORKDIR /build
# 关键优化:先复制所有pom.xml文件
# 这样做是为了充分利用Docker的构建缓存,只有在依赖变更时才重新下载
COPY pom.xml .
COPY springboot3-gateway/pom.xml ./springboot3-gateway/
# BuildKit cache挂载maven仓库缓存依赖
# -DexcludeArtifactIds=domain 排除模块间的依赖,因为它需要在本地构建
RUN --mount=type=cache,id=mvn-cache,target=/root/.m2/repository \
mvn dependency:go-offline -B -DexcludeArtifactIds=domain
# 把构建上下文(即执行 docker build 时指定的目录)里的所有文件和子目录,复制到镜像里
# CI环境跳过测试
COPY . .
RUN cd springboot3-gateway/ && mvn clean package -DskipTests -B
# ====================== 运行阶段:JRE17 轻量运行环境 ======================
FROM eclipse-temurin:17-jre-alpine
# 设置东八区上海时区
RUN apk add --no-cache tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone && \
apk del tzdata
WORKDIR /app
# 创建非root应用用户 uid=1001 gid=1001,安全最佳实践
RUN addgroup -g 1001 appgroup && \
adduser -D -u 1001 -G appgroup appuser
# 复制jar包,归属appuser
COPY --from=builder --chown=appuser:appgroup /build/springboot3-gateway/target/*.jar springboot3-gateway.jar
# 切换普通用户运行
USER appuser
EXPOSE 8081
# 健康检查:依赖SpringBoot Actuator /actuator/health
# interval:检查间隔;timeout:单次超时;retries:失败重试;start-period:启动宽限期(SpringBoot启动慢给60s)
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=60s \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8081/actuator/health || exit 1
# JVM生产参数,容器内存感知,最大使用容器75%内存
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:InitialRAMPercentage=50.0", \
"-jar", \
"springboot3-gateway.jar"]
二、.dockerignore 配置示例
.dockerignore 是 Docker 构建镜像时用来排除不需要的文件和目录的配置文件,相当于构建过程的"过滤清单",与 Dockerfile 同目录放置。
若只是打包运行镜像,作者习惯将制品放在干净的 docker 目录下,不含与运行无关的文件,因此不需编写 .dockerignore 文件。
若是多阶段构建,建议配置 .dockerignore 文件。
文件内容示例:
bash
# Java编译产物
target/
!target/*.jar
# git
.git
.gitignore
# IDE
.idea/
.vscode/
*.iml
# mvn wrapper
.mvn
mvnw
mvnw.cmd
# 本地配置、日志
*.log
application-local.yml
application-dev.yml
.env
三、基础镜像选择
1、构建阶段镜像选择
| 镜像 tag | Maven | JDK | OS | libc |
|---|---|---|---|---|
| maven:3.9-eclipse-temurin-17 | 3.9.x | Temurin17 | Debian | glibc ✅兼容性最好 |
| maven:3.9-eclipse-temurin-17-alpine | 3.9.x | Temurin17 | Alpine | musl |
常见踩坑
maven:3.8-openjdk-17底层废弃 openjdk 镜像,禁止生产使用- alpine 版本遇到 JNI、达梦、so 本地库报错 → 切换非 alpine glibc 版本
- 不要把 maven 打进最终运行镜像,运行镜像只保留 jre 即可
- 官方 maven 镜像自带多架构,amd64/arm64 (Mac M 系列) 都支持
2、运行阶段镜像选择
docker.io/library/openjdk 仓库已经停止维护,最后版本停留在 openjdk17,不再接收安全补丁。
推荐使用 OpenJDK 官方构建的 eclipse-temurin 镜像。
| 镜像标签 | JDK/JRE | OS | libc | 特点 |
|---|---|---|---|---|
| eclipse-temurin:17-jdk-alpine | JDK 完整 | Alpine | musl | 体积小,编译用,部分 native 库会报错 |
| eclipse-temurin:17-jre-alpine | JRE 运行时 | Alpine | musl | 最小运行镜像 |
| eclipse-temurin:17-jdk | JDK 完整 | Debian | glibc | 兼容性最强 |
| eclipse-temurin:17-jre | JRE 运行时 | Debian | glibc | 生产运行 |
重要坑点
- 不要继续使用 library/openjdk:17:上游停止维护,无安全更新
- alpine (musl) 遇到 JNI、so 本地库、字体、图形处理报错 → 切换 debian/glibc 版本
- JVM 容器内存参数:
-XX:+UseContainerSupportJDK17 默认开启,MaxRAMPercentage控制容器可用堆 - 生产尽量使用非 root 用户运行,不要 root 跑 java 应用
- arm64 服务器(树莓派、苹果 M 系列)eclipse‑temurin 原生支持多架构,无需额外处理
四、Dockerfile 主要配置说明
1、缓存 maven 依赖,提高构建效率
重要:docker build 构建阶段不能使用 -v /host:/container (-v 只属于 docker run 运行时),但是支持以下三种 mount 类型。
| mount 类型 | 核心作用 | 适用场景 | 关键限制 | 风险点 |
|---|---|---|---|---|
type=cache |
临时缓存目录,跨多次docker build复用数据;数据不会打包进镜像 |
1.Maven/npm/go mod 包管理器缓存 2. 编译中间产物缓存 3. 本地开发、CI 构建加速,减少重复下载依赖 | 1. 缓存存储在 BuildKit 内部存储,不在宿主机可见路径; 2. 不同构建环境缓存隔离,普通 CI(无 buildx 缓存导出)每次流水线会丢失缓存; 3. 同 Dockerfile 多条 RUN 要共享同一份缓存,需要指定相同id; 4. 不能用于传递业务产物,产物仍需要 COPY 输出; 5. 不支持修改宿主机文件。 |
1. 缓存膨胀:长期反复构建 BuildKit 缓存会占用磁盘,需要docker buildx prune清理; 2.CI 不配置cache‑to/cache‑from则缓存无效,容易误以为会加速流水线; 3. 缓存污染:如果构建脚本写入脏数据,会被复用到下一次构建。 |
type=bind |
将宿主机文件 / 目录绑定挂载到构建容器,支持 readonly 只读 | 1. 本地调试构建,注入构建上下文外的宿主机配置文件; 2. 本地开发读取宿主机证书、本地私有 jar; ⚠️禁止用于正式 CI 流水线 | 1. 构建环境必须能访问宿主机 source 路径; 2.CI 流水线环境(gitlab runner/github actions)通常无法访问宿主机目录,镜像失去可移植性; 3. 默认可读写,建议强制加readonly; 4. 只能在单条 RUN 可见,不能把 bind 挂载里的文件直接打进镜像,需要 RUN 内拷贝到镜像目录。 |
1. 移植性破坏:本地能构建成功,放到 CI 直接构建失败; 2. 泄露风险:挂载宿主机敏感目录,构建容器可读取宿主机机密文件; 3. 文件权限、属主问题,宿主机 uid/gid 映射异常; 4. 不要把业务源码用 bind 挂载,应该使用标准 COPY。 |
type=secret |
在构建时注入秘钥(密码、token、私钥),秘钥全程不会写入镜像层、docker history | 1.Maven 私有仓库密码、npm 私有源 token; 2. 构建阶段访问私有 git 仓库的密钥; 3. 任何不能打进镜像的构建期敏感凭证 | 1. 秘钥来源只能是:宿主机文件 source= 或者 docker build --secret id=xxx,src=/xxx传入; 2. 仅在当前 RUN 指令内可见; 3. 不能在 Dockerfile 硬编码秘钥内容; 4. 传统 docker build 不支持,必须 BuildKit; 5. 容器内是临时文件,构建结束自动销毁。 |
1. 人为失误:在 RUN 脚本中主动cat /run/secrets/xxx > /app/my.key,手动把秘钥写入镜像,造成泄露; 2. 不要把 secret 用于运行时,运行时秘钥使用环境变量 /volume/k8s secret; 3. 旧版本 docker 不兼容。 |
1)cache 挂载缓存依赖
多阶段构建场景下,建议采用 cache 方式缓存依赖。只要 pom.xml 没变,依赖层就能直接命中缓存,构建速度快很多。
示例:
docker
# BuildKit cache挂载maven仓库缓存依赖
# -DexcludeArtifactIds=domain 排除模块间的依赖,因为它需要在本地构建
RUN --mount=type=cache,id=mvn-cache,target=/root/.m2/repository \
mvn dependency:go-offline -B -DexcludeArtifactIds=domain
配置说明:
type=cache:声明这是一个可读写的持久化缓存目录id=mvn-cache:缓存的唯一标识符。不指定id,BuildKit 默认使用target的路径作为 ID。显式指定id=mvn-cache的好处是:即使你后续修改了target路径,或者在不同的 Stage(阶段)中使用,只要id相同,它们就会共享同一份缓存数据。target=/root/.m2/repository:将缓存挂载到容器内的具体路径- dependency:go-offline:Maven 插件目标(Goal)。核心作用是递归解析 pom.xml 中声明的所有依赖(包括传递依赖)并下载到本地仓库。执行成功后,即使断网,后续的 mvn compile 或 mvn package 也能顺利完成(因为所有 jar 包已在本地)。
- mvn dependency:go-offline:预下载当前项目
pom.xml声明的全部依赖(包括插件),存到本地仓库(默认~/.m2/repository),实现"离线构建"能力。 - -B:即
--batch-mode(批处理模式),会关闭 Maven 输出的进度条([====>]这类字符)和彩色控制台输出。既能避免生成大量无意义的 ANSI 转义字符,又能保证日志清晰,便于 CI/CD 系统捕获错误。 -DexcludeArtifactIds=domain:系统属性参数,传递给dependency:go-offline插件。- 作用 :告诉 Maven 在解析依赖时,跳过
artifactId为domain的依赖。 - 为什么要加这个参数 :
domain通常是你的多模块项目中的子模块 (如公共实体层)。这个子模块尚未 被构建并安装到本地仓库,也不存在 于中央仓库。如果不排除它,go-offline会尝试从远程仓库下载domain.jar,导致构建报错(无法找到 artifact)。 - 额外提醒 :如果你的项目还有其他本地子模块(如
common、api等),建议也一并加上,例如-DexcludeArtifactIds=domain,common,infrastructure。 - 本示例中,由于不存在需要提前构建的子模块,因此该配置可以取消。
- 作用 :告诉 Maven 在解析依赖时,跳过
注意几个坑
- 复杂项目可能拉不全 :多模块项目、用了特殊插件或 SNAPSHOT 版本依赖时,
go-offline偶尔会漏掉一些运行时才解析的依赖,不能保证 100% 覆盖所有情况。 mvnw文件缺失 :如果 Dockerfile 里用的是./mvnw dependency:go-offline -B,报错./mvnw: cannot execute: required file not found,一般是mvnw没复制进构建上下文,或者没加执行权限,需要RUN chmod +x mvnw。- 先复制
pom.xml再执行命令 :这是缓存生效的前提,把COPY src放前面会导致依赖层每次重建,缓存就失效了。
2)缓存验证与清理
查看 buildkit 缓存占用,但显示的 ID 是 BuildKit 为每个具体的缓存层(layer) 生成的唯一内部ID,用 mvn-cache 去过滤,找不到任何记录。
bash
$ docker buildx du
ID RECLAIMABLE SIZE LAST ACCESSED
0bf54mvmxrx83f48ae6k0li7j true 0B* 2 hours ago
3zxv30bboe3twdrgb6fb8c8xa true 0B* 12 days ago
525kjoe5ug50baona7ru82fi0 true 0B* 2 hours ago
54xldg4qysimrhjwfmopj9e9b* true 0B 13 days ago
5qsvrxj1pwvkok161k44qidex true 0B* 2 hours ago
过滤缓存类型并查看详情
bash
$ docker buildx du --filter type=exec.cachemount --verbose
ID: w9gyej64x6en4ruomdcpc0jr3
Created at: 2026-09-04 05:51:15.744677029 +0000 UTC
Mutable: true
Reclaimable: true
Shared: false
Size: 122.3MB
Description: cached mount /root/.m2/repository from exec /bin/sh -c mvn dependency:go-offline -B -DexcludeArtifactIds=domain with id "/mvn-cache"
Usage count: 2
Last used: 2 hours ago
Type: exec.cachemount
只删除cache mount缓存,保留镜像层缓存
bash
docker buildx prune --filter type=exec.cachemount
3)多步构建替代方案
多阶段构建,本质是将多个步骤放在一个 Dockerfile 中描述,通过一条命令处理 Dockerfile 中描述的所有命令。因此,也可将多阶段构建分成多步执行,这样就可以通过 -v 的方式挂载源码和maven本地仓库。
构建命令示例:
bash
docker run --rm --name maven-builder \
-v /data/volumes/maven/repository:/root/.m2/repository \
-v /data/workspace/springboot3-example:/springboot3-example \
maven:3.9.9-eclipse-temurin-17 \
sh -c "cd /springboot3-example/springboot3-gateway/ && mvn clean package -DskipTests -B"
- 不需要编写复杂 Dockerfile
- 不会产生 docker 缓存层,可有效避免缓存无限膨胀
- 直接挂载本地 maven 仓库,但跨节点不能复用
2、HEALTHCHECK 健康探测
HEALTHCHECK 是 Docker 1.12 引入的健康检查指令,让 Docker 能主动探测容器内服务是否真正可用,而不只是看进程有没有退出。
但 HEALTHCHECK 仅修改容器状态 (healthy → unhealthy),不会自动重启容器。
HEALTHCHECK 详细用法,及如何利用 HEALTHCHECK 特性实现应用自愈,将另外写一篇博客:如何利用 Docker HEALTHCHECK 特性实现应用自愈。
HEALTHCHECK 配置示例:
docker
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=60s \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/actuator/health || exit 1
3、JVM 参数
1)常用参数
| 参数 | 功能说明 | 默认值 | 生产推荐值 | 约束 / 冲突 | 风险点 |
|---|---|---|---|---|---|
-XX:+UseContainerSupport |
JVM 读取 cgroup 容器内存、CPU 限制,不再读取宿主机整机资源;是后面三个百分比参数生效的前提。 | true (JDK10+) |
显式写+UseContainerSupport(显式声明便于阅读) |
设为‑关闭后,所有 RAMPercentage 参数失效,JVM 读取宿主机内存,容器极易 OOMKilled;cgroup v2 需要 JDK15 + 完整支持。 |
关闭该参数,容器内存限制对 JVM 无效,容易触发系统 OOM Killer (exit 137)。 |
-XX:MaxRAMPercentage=xx.x |
最大堆占容器 cgroup 内存上限的百分比 ,控制 -Xmx 动态值,容器内存变更无需改 JVM 参数。 |
25.0 |
75.0 |
如果配置‑Xmx,本参数完全失效。堆只占一部分内存,剩余留给元空间、线程栈、直接内存、JIT 缓存、系统开销。 |
设置过高(>85),堆外内存 + 堆总和超过容器 limit,被内核 OOMKilled 杀掉进程,JVM 来不及输出 OOM dump。 |
-XX:InitialRAMPercentage=xx.x |
初始堆占容器内存上限百分比 ,控制 -Xms 动态值,JVM 启动初始堆大小。 |
1.5625 |
50.0;长驻服务可与 MaxRAMPercentage 一致 |
如果配置‑Xms,本参数失效。 |
过小:启动后堆需要不断扩容,频繁 GC;过大:启动瞬间占用大量内存。 |
-XX:MinRAMPercentage=xx.x |
仅小内存容器(<~250MB)生效,用于计算最大堆 ;不是最小堆,名字极具迷惑性;大内存容器不生效。 | 50.0 |
一般业务不配置 | 容器内存大于 250MB 时,该参数不参与计算;只有小容器才会用它算 Xmx。 | 容易被误解为设置 Xms,配置错误达不到预期效果;绝大多数 SpringBoot 业务不需要配置。 |
2)其他生产常用参数
| 参数 | 说明 | 推荐值 |
|---|---|---|
-XX:MaxMetaspaceSize=256m |
限制元空间最大,防止类加载无限膨胀吃掉容器内存 | 256m |
-XX:+HeapDumpOnOutOfMemoryError |
JVM 内部 OOM 时自动生成堆 dump | 开启 |
-XX:HeapDumpPath=/tmp/heapdump.hprof |
dump 输出路径,容器内 /tmp 可写 | /tmp/heapdump.hprof |
3)生产环境参数完整示例
bash
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=50.0 \
-XX:MaxMetaspaceSize=256m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/heapdump.hprof
4)关键避坑要点
1. JVM OOM vs OOMKilled(137)
- JVM OOM:Java 内部抛出
java.lang.OutOfMemoryError,可以生成 heapdump; - OOMKilled exit 137:内核杀掉进程,整个进程总内存(堆 + 元空间 + 线程栈 + 直接内存)超过容器 memory limit,此时 JVM 来不及写 dump,和堆参数无关。
2. 百分比参数生效前提
- 容器必须设置
‑‑memory硬限制;没有内存限制时,百分比参数没有意义。 - 不能同时写
‑Xmx与MaxRAMPercentage,写了‑Xmx,百分比直接作废。
3. 内存预留逻辑
容器总内存 = JVM 堆 (75%) + 堆外内存 (元空间、线程栈、netty 直接 buffer、jit 代码缓存) + 系统开销,所以一般不建议 MaxRAMPercentage 超过 75‑80。
4. cgroup v1/v2 兼容
- cgroup v1:JDK8u191 + 支持;
- cgroup v2:JDK15 完整支持,Java17 完全没问题。
5)选型对比:百分比参数 vs 固定‑Xmx
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| MaxRAMPercentage 百分比 | 容器扩缩容不用改 JVM 参数,一套镜像适配 2G/4G/8G 容器 | 需要容器设置 memory limit;小容器需要评估 MinRAMPercentage | Docker/K8s 云原生,镜像多规格复用(我们模板采用) |
| 固定‑Xmx/-Xms | 堆大小完全确定,排查简单 | 容器内存规格变更,JVM 参数必须同步修改 | 物理机、固定规格容器 |
验证命令,容器内查看实际生效堆:
bash
java -XX:+PrintFlagsFinal -version | grep -E "InitialRAMPercentage|MaxRAMPercentage|UseContainerSupport"
五、镜像构建与验证
bash
$ cd /data/workspace/springboot3-example
# 多阶段构建,将需要构建模块的 Dockerfile 移到顶层目录
$ cp docker/Dockerfile-gateway .
# 开启 BuildKit ,cache挂载才能生效
$ DOCKER_BUILDKIT=1
$ docker build -t example/gateway -f Dockerfile-gateway .
检查生成的镜像
bash
$ docker images
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
example/gateway:latest a1e0f6c2fa72 227MB 0B