Docker 多阶段构建
一、多阶段构建是什么
多阶段构建(Multi-stage Build):在单个 Dockerfile 中定义多个 FROM 阶段,依次执行,最终只保留最后一个阶段作为成品镜像,前面所有阶段都会被丢弃。
核心语法模板
# 第一阶段:构建/编译阶段(临时环境)
FROM 编译镜像 AS builder
# 执行编译、打包
# 第二阶段:运行阶段(最终成品镜像)
FROM 运行时镜像
# 从上一阶段复制产物
COPY --from=builder /编译输出路径 /容器路径
# 启动配置
二、解决什么问题
传统单阶段构建痛点
| 问题 | 说明 |
|---|---|
| 镜像臃肿 | 编译依赖 JDK、Maven、Git、编译库等全部留在镜像中 |
| 攻击面大 | 源码、编译工具、缓存文件暴露,存在安全隐患 |
| 分层杂乱 | 无法做到"编译环境"和"运行环境"隔离 |
| 体积巨大 | 一个 SpringBoot 镜像可能从 200MB → 多阶段后仅 80MB |
多阶段优势
- 极致缩小镜像体积(核心收益)
- 分离构建环境与运行环境,生产镜像只保留运行必需文件
- 单文件管理,无需拆分编译脚本 + 运行镜像,流程统一
- 天然屏蔽源码、编译工具,提升安全性
适用版本:Docker 17.05+ 原生支持,目前所有生产环境均满足。
三、语法规则
- 每个
FROM开启一个新阶段,可使用AS 阶段名命名(方便引用) - 阶段从上到下顺序执行
- 使用
COPY --from=阶段名/索引跨阶段复制文件 - 最终镜像 = 最后一个 FROM 阶段,前面阶段仅做临时编译/打包
- 阶段支持数字索引:第 1 阶段
--from=0、第 2 阶段--from=1,建议命名方式可读性更高
四、核心指令详解
1. 阶段命名 AS
FROM maven:3.8.8-openjdk-17 AS builder
builder自定义阶段名,语义化,替代数字索引,企业规范必用
2. 跨阶段复制 COPY --from=
# 从 builder 阶段复制文件
COPY --from=builder /build/target/demo.jar /app/
# 从数字索引复制(第1阶段)
COPY --from=0 /build/target/demo.jar /app/
# 从外部镜像复制(不依赖本文件构建)
COPY --from=nginx:alpine /usr/sbin/nginx /usr/local/bin/
COPY --from=alpine:3.19 /usr/bin/curl /usr/local/bin/
- 只能复制前序阶段的文件,无法反向复制
- 支持复制目录、单文件、通配符
- 可同时引用外部镜像(不止本文件阶段)
3. 多阶段数量无限制
可写 3/4+ 阶段,例如:
1. 拉取代码 → 2. 编译 → 3. 精简运行环境 → 4. 最终镜像
4. 阶段停止构建(--target 构建特定阶段)
# 只构建到 builder 阶段,不构建最终镜像(调试用)
docker build --target builder -t java-builder:1.0 .
# 只构建最终运行阶段
docker build --target runner -t java-app:1.0 .
适用场景:调试编译阶段是否成功,避免等待完整构建。
5. 并行构建(--parallel)
多阶段构建中,相互独立的阶段可以并行执行(需 BuildKit):
# 启用 BuildKit 并行构建
DOCKER_BUILDKIT=1 docker build --parallel -t app:1.0 .
# 这两个阶段互不依赖,可并行
FROM node:18 AS frontend
# 构建前端
FROM maven:3.8 AS backend
# 构建后端
# 最终阶段合并
FROM nginx:alpine
COPY --from=frontend /dist /usr/share/nginx/html
COPY --from=backend /target/*.jar /app/
五、最佳实践
| 序号 | 规范 | 说明 |
|---|---|---|
| 1 | 阶段拆分原则 | 阶段1:依赖拉取、源码编译、打包(使用完整版镜像);阶段2:运行环境(使用 slim/alpine 极简镜像) |
| 2 | 严禁在最终镜像保留编译工具、源码、缓存 | 编译工具留在 builder 阶段,最终镜像只复制产物 |
| 3 | 固定镜像版本 | 禁止 latest |
| 4 | 利用构建缓存 | 先复制依赖文件 → 安装依赖 → 再复制源码(见下方详解) |
| 5 | 最终镜像非 root 用户运行 | 安全加固 |
| 6 | 配置健康检查 | 生产必备 |
| 7 | 声明日志数据卷 | VOLUME |
| 8 | 所有临时文件在构建阶段自行清理 | rm -rf /root/.m2 等 |
构建缓存最佳实践(Java 示例)
FROM maven:3.8 AS builder
WORKDIR /build
# 正确:先复制 pom.xml,下载依赖(缓存命中率高)
COPY pom.xml .
RUN mvn dependency:go-offline -B
# 再复制源码(源码变动不影响依赖缓存)
COPY src ./src
RUN mvn package -DskipTests
# 错误:源码变动会使整个缓存失效
COPY . /build/
RUN mvn package -DskipTests # 每次源码变动都要重新下载依赖
六、主流场景实战模板
场景1:SpringBoot / Java 项目(最常用,两阶段)
# ========== 阶段1:编译打包阶段 ==========
FROM maven:3.8.8-openjdk-17 AS builder
WORKDIR /build
# 缓存优化:先复制 pom.xml,优先加载依赖
COPY pom.xml .
RUN mvn dependency:go-offline -B
# 复制源码并打包
COPY src ./src
RUN mvn clean package -Dmaven.test.skip=true \
&& rm -rf /root/.m2 /build/src
# ========== 阶段2:运行镜像(最终镜像) ==========
FROM openjdk:17-jre-slim
LABEL maintainer="dev" version="1.0" desc="Java Service"
ENV JAVA_HEAP="-Xms512m -Xmx1024m" \
TZ=Asia/Shanghai
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
VOLUME ["/app/logs"]
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=60s \
CMD curl -f http://127.0.0.1:8080/actuator/health || exit 1
USER appuser
CMD ["sh", "-c", "java $JAVA_HEAP -jar app.jar"]
场景2:前端 Vue/React(Node 编译 + Nginx 运行)
# ========== 阶段1:Node 编译前端 ==========
FROM node:18-alpine AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm install --registry=https://registry.npmmirror.com
COPY . .
RUN npm run build
# ========== 阶段2:Nginx 运行静态资源 ==========
FROM nginx:1.24-alpine
RUN rm -f /etc/nginx/conf.d/default.conf
COPY ./nginx/default.conf /etc/nginx/conf.d/
COPY --from=builder /build/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=20s --timeout=3s --retries=2 \
CMD wget -q -O - http://127.0.0.1/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
场景3:Golang 项目(极致轻量,scratch 空镜像)
Go 编译后是单二进制文件,最终可使用空镜像 scratch(0 系统依赖),镜像体积几 MB。
# 阶段1:Go 编译
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 静态编译(不依赖系统库)
RUN CGO_ENABLED=0 GOOS=linux go build -o app .
# 阶段2:scratch 空镜像(最小化)
FROM scratch
WORKDIR /app
COPY --from=builder /build/app ./
EXPOSE 9000
CMD ["/app"]
scratch:Docker 内置空镜像,无任何系统文件,安全 & 体积最优,仅限静态编译程序。
场景4:Python 项目(多阶段优化依赖)
# 阶段1:构建依赖 & 打包
FROM python:3.11-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -t ./deps
COPY . .
# 阶段2:精简运行环境
FROM python:3.11-slim
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
WORKDIR /app
COPY --from=builder /build/deps /usr/local/lib/python3.11/site-packages/
COPY --from=builder --chown=appuser:appgroup /build/ .
EXPOSE 5000
HEALTHCHECK --interval=25s --timeout=4s \
CMD python -c "import socket;s=socket.socket();s.connect(('127.0.0.1',5000))" || exit 1
USER appuser
CMD ["python", "app.py"]
七、高阶用法
1. 引用外部镜像作为阶段
不自定义阶段,直接拉取公共镜像复制文件:
FROM ubuntu:22.04
# 直接从 alpine 镜像复制 curl 工具
COPY --from=alpine:3.19 /usr/bin/curl /usr/local/bin/
CMD ["curl", "--version"]
2. 三阶段构建(复杂项目)
# 阶段1:拉取代码
FROM git:alpine AS code
WORKDIR /code
RUN git clone https://xxx.git .
# 阶段2:编译
FROM maven:3.8-openjdk-17 AS build
WORKDIR /build
COPY --from=code /code .
RUN mvn package -DskipTests
# 阶段3:最终运行镜像
FROM openjdk:17-jre-slim
COPY --from=build /build/target/*.jar /app.jar
CMD ["java", "-jar", "/app.jar"]
3. 构建参数 ARG 在多阶段传递
ARG 默认仅当前阶段生效,如需全局使用,每个阶段重新定义:
ARG APP_VERSION=1.0
FROM maven:3.8 AS builder
ARG APP_VERSION
RUN echo "Build Version: $APP_VERSION"
FROM openjdk:17-jre-slim
ARG APP_VERSION
LABEL version=$APP_VERSION
4. 使用 --cache-from 复用缓存
在 CI 环境中,利用远程镜像加速构建:
# 拉取上次构建的镜像作为缓存源
docker pull registry.example.com/app:latest || true
# 构建时使用缓存
docker build --cache-from registry.example.com/app:latest -t app:1.0 .
5. 在最终镜像中保留调试工具(测试环境专用)
# 测试环境:保留 curl 用于调试
FROM openjdk:17-jre-slim
RUN apt update && apt install -y curl && rm -rf /var/lib/apt/lists/*
# 生产环境去掉这一层
八、常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 跨阶段复制文件找不到 | 路径错误或文件未生成 | 检查路径大小写,确认前序阶段文件确实生成 |
| 镜像体积还是很大 | 最终阶段误复制了源码/编译工具 | 检查 COPY 路径,不要复制整个 /build 目录 |
| 构建缓存不生效 | 源码文件在依赖文件前复制 | 遵循:依赖文件 → 安装依赖 → 源码 |
| Alpine 镜像时区不对 | 未配置时区 | ENV TZ=Asia/Shanghai |
| 动态链接库缺失(Go/C++) | 使用了 scratch 但有动态链接依赖 | 编译阶段加 CGO_ENABLED=0 静态编译,或用 alpine 替代 scratch |
--from 引用顺序错误 | 引用了尚未执行的阶段 | 只能引用前序阶段,不能引用后面的阶段 |
| 健康检查 curl 命令找不到 | slim/alpine 镜像无 curl | 改用 wget 或 CMD cat /dev/null 的方式 |
九、单阶段 VS 多阶段对比
| 对比项 | 单阶段构建 | 多阶段构建 |
|---|---|---|
| 镜像体积 | 大(包含编译工具) | 极小(仅运行文件) |
| 安全性 | 低(源码/工具暴露) | 高(无编译环境) |
| 维护成本 | 简单 | 单文件统一管理,流程规范 |
| 构建速度 | 较快(无跨阶段复制) | 略慢(多了编译阶段,但可缓存) |
| 适用场景 | 本地测试、临时镜像 | 企业生产、线上集群 |
| 镜像层数 | 多 | 少(最终镜像精简) |
| 源码保护 | 源码留在镜像中 | 源码仅存在 builder 阶段 |
十、镜像体积对比(实测参考)
| 项目类型 | 单阶段镜像大小 | 多阶段镜像大小 | 优化比例 |
|---|---|---|---|
| SpringBoot 2.x | ~200-250 MB | ~80-100 MB | 60% ↓ |
| React/Vue SPA | ~150 MB(含 Node) | ~20 MB(仅 Nginx) | 85% ↓ |
| Golang | ~100 MB(含编译工具) | ~5-10 MB(scratch) | 90% ↓ |
| Python Flask | ~150 MB | ~50-80 MB | 50% ↓ |
十一、多阶段构建检查清单
在提交 Dockerfile 前,逐一确认:
- 是否使用了
AS为每个阶段命名(语义化)? - 最终镜像是否使用了
slim/alpine/scratch基础镜像? - 最终镜像是否只复制了运行必需的产物?
- 最终镜像是否配置了
HEALTHCHECK? - 最终镜像是否切换了非 root 用户(
USER)? - 是否在 builder 阶段清理了缓存(
rm -rf ~/.m2)? - 是否优化了
COPY顺序以利用构建缓存? - 是否固定了所有基础镜像的版本标签(不用
latest)?
十二、总结
多阶段构建 = 编译环境(builder)+ 运行环境(final)分离。通过
COPY --from只将编译产物复制到最终镜像,丢弃所有编译工具和源码。生产环境强制使用多阶段构建,可将镜像体积缩小 50%-90%,同时提升安全性。