4、多阶段构建(上):多阶段构建的概念、基本语法、第一个多阶段Dockerfile

聊到 Docker 镜像优化,有个话题绕不开——多阶段构建。

我记得刚接触 Docker 那会儿,写 Dockerfile 特别随意。一个 FROM,装一堆依赖,编译、打包、运行全塞一起。镜像动不动就 1GB+,传到仓库得喝杯咖啡等。后来项目上线,镜像体积成了痛点,我才开始认真研究怎么瘦身。

多阶段构建,说白了就是:用多个 FROM 语句,分阶段干活,最后只把需要的东西拷贝到最终镜像里。

为什么需要多阶段构建?

先看一个典型场景:你要编译一个 Go 程序。

传统做法是这样:

FROM golang:1.20
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]

这个镜像有多大?golang:1.20 基础镜像本身就 800MB 左右,加上编译工具链、依赖包,最终镜像轻松上 GB。

但你的程序运行起来,真的需要 Go 编译器吗?不需要。你只需要那个编译好的二进制文件。

这就是问题所在——构建环境和运行环境混在一起,导致镜像臃肿。

核心矛盾:构建时需要大量工具和依赖,运行时只需要产物。

我在项目中遇到过类似情况。有一次部署一个 Java 微服务,基础镜像用了 openjdk:11-jdk,里面带了 javac、jar 等一堆编译工具。实际上运行时只需要 JRE。后来改成多阶段构建,镜像从 600MB 降到了 180MB。部署速度明显提升,磁盘占用也少了很多。

多阶段构建的基本语法

语法其实很简单。就是多个 FROM 语句,每个 FROM 开启一个新阶段。

# 第一阶段:构建阶段
FROM golang:1.20 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp

# 第二阶段:运行阶段
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]

这里有几个关键点:

  • AS builder:给第一阶段起个名字,方便后面引用
  • --from=builder:从 builder 阶段拷贝文件,而不是从宿主机
  • 最终镜像只包含第二阶段的内容:第一阶段的所有中间产物都被丢弃

嗯,这里要注意:--from 也可以指定阶段编号。比如 --from=0 表示从第一个阶段拷贝。但我个人习惯用命名,可读性更好。

第一个多阶段 Dockerfile

我们来写一个完整的例子。假设你有一个 Node.js 应用,需要先安装依赖、构建前端资源,然后运行。

# 第一阶段:安装依赖并构建
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# 第二阶段:运行
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json .
EXPOSE 3000
CMD ["node", "dist/server.js"]

这个 Dockerfile 做了什么事?

  1. 第一阶段用完整镜像安装依赖、构建代码
  2. 第二阶段只拷贝构建产物和运行时依赖
  3. 最终镜像不包含源码、构建工具、临时文件

你想想看,如果不用多阶段构建,你得手动清理 node_modules 里的 devDependencies,还得删掉源码。麻烦不说,还容易漏掉东西。

小技巧:第一阶段可以用 npm ci 而不是 npm install,前者更严格、更快,适合 CI/CD 环境。

多阶段构建的底层逻辑

为什么多阶段构建能减小体积?说白了就是分层复用。

Docker 镜像是由一层层只读文件系统叠加而成的。每个 RUN、COPY 指令都会生成新层。多阶段构建的精髓在于:前一阶段的层在最终镜像中不存在。

我用一张图来说明:

多阶段构建流程示意图 第一阶段:构建阶段 FROM golang:1.20 (800MB) COPY 源码 + 依赖 RUN go build (产物) 中间文件、缓存等 COPY --from=builder 第二阶段:运行阶段 FROM alpine:3.18 (5MB) COPY 编译好的二进制 运行时配置 最终镜像 ≈ 20MB ✕ 这些层全部丢弃 ✓ 只保留这些层

从图上可以清楚看到:第一阶段虽然体积大,但最终只把产物拷贝到第二阶段。第一阶段的 Go 编译器、源码、缓存等全部丢弃。最终镜像只有 alpine + 二进制文件,体积从 800MB 降到 20MB 左右。

注意:多阶段构建不是银弹。如果你的应用运行时也需要编译工具(比如某些 Python 包需要动态编译),那第二阶段可能还得保留部分构建工具。我曾经踩过这个坑,一个科学计算镜像,第二阶段没装 gcc,结果 pip install 时直接报错。

常见问题

Q:多阶段构建会减慢构建速度吗?

第一次构建确实会慢一些,因为要跑两个阶段。但 Docker 会缓存每一层,后续构建如果源码没变,直接走缓存,速度很快。

Q:可以有三个或更多阶段吗?

当然可以。我见过一个项目用了四个阶段:安装工具 → 编译 → 测试 → 运行。每个阶段只做一件事,清晰明了。

Q:--from 能跨 Dockerfile 吗?

不能。--from 只能引用同一个 Dockerfile 内的阶段。如果想引用外部镜像,可以用 --from=nginx:alpine 这种形式,但那是另一个话题了。

小结

多阶段构建的核心就三点:

  • 多个 FROM:每个 FROM 是一个独立阶段
  • 按需拷贝:用 --from 只拿需要的文件
  • 丢弃中间层:最终镜像只保留最后一个阶段

我个人觉得,多阶段构建是 Docker 镜像优化的入门必修课。它不复杂,但效果立竿见影。下次你写 Dockerfile 时,不妨试试把构建和运行分开,看看镜像能瘦多少。

一句话总结:构建时用大镜像,运行时用小镜像,中间用 COPY --from 搭桥。

可可精品资料库