Docker 多阶段构建

一、多阶段构建是什么

多阶段构建(Multi-stage Build):在单个 Dockerfile 中定义多个 FROM 阶段,依次执行,最终只保留最后一个阶段作为成品镜像,前面所有阶段都会被丢弃。

核心语法模板

# 第一阶段:构建/编译阶段(临时环境)
FROM 编译镜像 AS builder
# 执行编译、打包

# 第二阶段:运行阶段(最终成品镜像)
FROM 运行时镜像
# 从上一阶段复制产物
COPY --from=builder /编译输出路径 /容器路径
# 启动配置

二、解决什么问题

传统单阶段构建痛点

问题 说明
镜像臃肿 编译依赖 JDK、Maven、Git、编译库等全部留在镜像中
攻击面大 源码、编译工具、缓存文件暴露,存在安全隐患
分层杂乱 无法做到"编译环境"和"运行环境"隔离
体积巨大 一个 SpringBoot 镜像可能从 200MB → 多阶段后仅 80MB

多阶段优势

  1. 极致缩小镜像体积(核心收益)
  2. 分离构建环境运行环境,生产镜像只保留运行必需文件
  3. 单文件管理,无需拆分编译脚本 + 运行镜像,流程统一
  4. 天然屏蔽源码、编译工具,提升安全性

适用版本:Docker 17.05+ 原生支持,目前所有生产环境均满足。

三、语法规则

  1. 每个 FROM 开启一个新阶段,可使用 AS 阶段名 命名(方便引用)
  2. 阶段从上到下顺序执行
  3. 使用 COPY --from=阶段名/索引 跨阶段复制文件
  4. 最终镜像 = 最后一个 FROM 阶段,前面阶段仅做临时编译/打包
  5. 阶段支持数字索引:第 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 改用 wgetCMD 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%,同时提升安全性。