Docker 多阶段构建实践指南

Docker 多阶段构建(Multi‑stage Build),核心目的:

  1. 把编译构建环境和运行环境分开,最终镜像只保留运行时产物,大幅减小镜像体积,不携带编译器、依赖源码等冗余文件。
  2. 可用于构建简易的 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

常见踩坑

  1. maven:3.8-openjdk-17 底层废弃 openjdk 镜像,禁止生产使用
  2. alpine 版本遇到 JNI、达梦、so 本地库报错 → 切换非 alpine glibc 版本
  3. 不要把 maven 打进最终运行镜像,运行镜像只保留 jre 即可
  4. 官方 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 生产运行

重要坑点

  1. 不要继续使用 library/openjdk:17:上游停止维护,无安全更新
  2. alpine (musl) 遇到 JNI、so 本地库、字体、图形处理报错 → 切换 debian/glibc 版本
  3. JVM 容器内存参数:-XX:+UseContainerSupport JDK17 默认开启,MaxRAMPercentage 控制容器可用堆
  4. 生产尽量使用非 root 用户运行,不要 root 跑 java 应用
  5. 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

配置说明:

  1. type=cache :声明这是一个可读写的持久化缓存目录
  2. id=mvn-cache :缓存的唯一标识符。不指定 id,BuildKit 默认使用 target 的路径作为 ID。显式指定 id=mvn-cache 的好处是:即使你后续修改了 target 路径,或者在不同的 Stage(阶段)中使用,只要 id 相同,它们就会共享同一份缓存数据。
  3. target=/root/.m2/repository:将缓存挂载到容器内的具体路径
  4. dependency:go-offline:Maven 插件目标(Goal)。核心作用是递归解析 pom.xml 中声明的所有依赖(包括传递依赖)并下载到本地仓库。执行成功后,即使断网,后续的 mvn compile 或 mvn package 也能顺利完成(因为所有 jar 包已在本地)。
  5. mvn dependency:go-offline:预下载当前项目 pom.xml 声明的全部依赖(包括插件),存到本地仓库(默认 ~/.m2/repository),实现"离线构建"能力。
  6. -B:即 --batch-mode(批处理模式),会关闭 Maven 输出的进度条([====>] 这类字符)和彩色控制台输出。既能避免生成大量无意义的 ANSI 转义字符,又能保证日志清晰,便于 CI/CD 系统捕获错误。
  7. -DexcludeArtifactIds=domain :系统属性参数,传递给 dependency:go-offline 插件。
    • 作用 :告诉 Maven 在解析依赖时,跳过 artifactIddomain 的依赖。
    • 为什么要加这个参数domain 通常是你的多模块项目中的子模块 (如公共实体层)。这个子模块尚未 被构建并安装到本地仓库,也不存在 于中央仓库。如果不排除它,go-offline 会尝试从远程仓库下载 domain.jar,导致构建报错(无法找到 artifact)。
    • 额外提醒 :如果你的项目还有其他本地子模块(如 commonapi 等),建议也一并加上,例如 -DexcludeArtifactIds=domain,common,infrastructure
    • 本示例中,由于不存在需要提前构建的子模块,因此该配置可以取消。

注意几个坑

  • 复杂项目可能拉不全 :多模块项目、用了特殊插件或 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 仅修改容器状态healthyunhealthy),不会自动重启容器。

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)
  1. JVM OOM:Java 内部抛出java.lang.OutOfMemoryError,可以生成 heapdump;
  2. OOMKilled exit 137:内核杀掉进程,整个进程总内存(堆 + 元空间 + 线程栈 + 直接内存)超过容器 memory limit,此时 JVM 来不及写 dump,和堆参数无关。
2. 百分比参数生效前提
  1. 容器必须设置‑‑memory硬限制;没有内存限制时,百分比参数没有意义
  2. 不能同时写‑XmxMaxRAMPercentage,写了‑Xmx,百分比直接作废。
3. 内存预留逻辑

容器总内存 = JVM 堆 (75%) + 堆外内存 (元空间、线程栈、netty 直接 buffer、jit 代码缓存) + 系统开销,所以一般不建议 MaxRAMPercentage 超过 75‑80。

4. cgroup v1/v2 兼容
  1. cgroup v1:JDK8u191 + 支持;
  2. 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

相关推荐
大牧师3 小时前
MySQL 学习教程
数据库·sql·mysql·docker·node·全栈·后端数据
天外天-亮5 小时前
docker + window 安装
运维·docker·容器
进击的小菜鸡dd5 小时前
docker 容器的挂载映射关系查看
运维·docker·容器
程序员老赵6 小时前
Docker 部署 TeX Live:轻松搭建 LaTeX 论文排版编译平台
前端·docker·latex
陈皮糖..6 小时前
基于 Keepalived 的传统 Web 高可用架构的容器化改造与可观测性升级
运维·docker·性能优化·架构·云计算·prometheus
秦jh_6 小时前
222222
docker
java_logo6 小时前
Docker 部署 go2rtc:轻松搭建摄像头多协议流媒体平台
运维·docker·容器·摄像头·rtsp·轩辕镜像·go2rtc
gs801408 小时前
Docker Desktop 报 Wsl/CommandTimedOut、wsl -l -v 卡死、0x80080005 的完整排查与解决
运维·docker·容器
芷栀夏9 小时前
开源菜谱工具 cook 实践:按食材筛选菜谱、随机推荐,并用 Docker 部署到本地
docker·容器·开源