多阶段构建入门:一次真正的镜像瘦身革命

你可能觉得Docker镜像越小越好,但怎么减?apt-get clean?rm -rf /var/lib/apt/lists/*?我告诉你,这些手段撑死减个几十MB。真正让镜像从1G瘦到100M的,是多阶段构建。

我第一次接触这个概念时,是在一个Go微服务项目里。当时那个镜像光golang编译环境就占了800MB,加上依赖库,最后推到仓库1.2GB。同事说“要不试试多阶段构建?”我半信半疑改了一版,镜像直接缩到120MB。嗯,从那以后我再也没写过单阶段Dockerfile。

什么是多阶段构建?说白了就是“分段干活”

传统Dockerfile只有一FROM,所有事情挤在一个环境里。编译、测试、运行都在一个镜像里,各种编译工具、临时文件全塞进去。多阶段构建允许你在一个Dockerfile里写多个FROM指令,每个FROM是一个独立的构建阶段,你可以把前面的产物复制到后面阶段,而前面的环境完全抛弃。

举个例子,你需要在Go环境里编译二进制,但最终运行只需要那个二进制加一个alpine。多阶段构建就能做到:

# 第一阶段:编译
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp .

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

看到了吗?最终镜像只有alpine+二进制,一丁点多余的Go编译器、依赖源码都没有。

一个小技巧:AS后面的名称可以自定义,COPY --from也可以引用前面的阶段名称。我个人习惯把编译阶段叫builder,测试阶段叫tester,最终阶段叫release,一目了然。

多阶段构建的核心价值:到底省了什么?

我们把单阶段和多阶段做个对比,你就明白了。

维度 单阶段构建 多阶段构建
编译环境 保留在镜像中 第一阶段用完即弃
临时文件 需要手动清理 自动丢弃(阶段隔离)
镜像体积 通常几百MB~几GB 通常几十MB~几百MB
Dockerfile可读性 一个RUN链到底 阶段清晰,职责分离
安全风险 暴露编译工具链 运行镜像极精简

我在项目中遇到过最典型的坑:单阶段里用apt-get install装了一堆编译依赖,然后又用apt-get remove删掉。结果呢?删除不干净,缓存还在,镜像体积还是几百MB。改用多阶段构建后,这些烦恼全没了。

多阶段构建的进阶用法:不止两个阶段

你想想看,既然可以有两个阶段,为什么不能有更多?我常用的模式是三阶段:编译→测试→运行。

# 编译阶段
FROM golang:1.21 AS builder
WORKDIR /src
COPY . .
RUN go build -o myapp .

# 测试阶段
FROM builder AS tester
RUN go test ./...

# 运行阶段
FROM alpine:3.19 AS release
COPY --from=builder /src/myapp /myapp
CMD ["/myapp"]

这样,你可以用--target=tester只构建到测试阶段做CI验证,而发布时只构建到release阶段。既节省了CI时间,又保证了镜像最小化。

注意:COPY --from不仅可以从构建阶段复制文件,还可以从其他镜像复制。我曾经踩过一个坑:COPY --from=some_image:tag /data /data,但顺序写反导致文件覆盖。记住,目标路径是容器内的绝对路径,别搞混。

多阶段构建 vs 传统构建:实战对比

我拿一个Python应用举个例子。

传统单阶段Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]

这个镜像大概500MB,因为python:3.11-slim本身就300多MB,加上pip安装的依赖,体积膨胀。

多阶段构建版

FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM python:3.11-alpine
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY . .
CMD ["python", "app.py"]

注意,这里我把pip安装的包复制到alpine里。alpine只有50MB,总镜像不到200MB。为什么?因为编译器、临时下载的whl包等都被遗留在builder阶段了。

但这里也有个坑:Python的C扩展可能依赖glibc,而alpine是musl libc。你可能会遇到“找不到共享库”的错误。我曾经就被这个坑过,后来改用python:3.11-slim做最终阶段,体积大一点但兼容性更好。所以多阶段构建不是无脑用alpine,要根据实际依赖选基础镜像。

核心理念:多阶段构建的本质是“编译时环境”与“运行时环境”分离。它不改变你的构建逻辑,只改变镜像的组装方式。最终镜像只包含运行所需的必要文件。

多阶段构建的典型设计模式

从实际项目看,我总结了四种常用模式:

  • 编译+运行:C/C++/Rust/Go等静态编译语言,二进制复制到alpine或者scratch。
  • 依赖安装+运行:Python/Ruby/Node等动态语言,在builder阶段装依赖,复制site-packages/node_modules到轻量基础镜像。
  • 构建工具链+测试+运行:CI场景下,用不同阶段做lint、测试、编译、打包,最终产出一个最小镜像。
  • 多平台交叉编译:在builder阶段用交叉编译工具链生成不同架构的二进制,再复制到对应架构的运行时镜像。

说到交叉编译,我记得有一次要把一个AI推理服务部署到ARM的树莓派上。我在x86开发机上用GOOS=linux GOARCH=arm64 go build,然后多阶段构建里直接把编译产物复制到arm64v8/alpine里。完美,镜像才80MB,以前用完整Ubuntu基础镜像做交叉编译,镜像1.5GB,部署都慢。

记住一条黄金法则

COPY --from 是核心命令,它允许你跨阶段复制任意文件。但要注意:被复制的文件必须存在于源阶段,而且路径要绝对正确。我建议你在关键COPY前加一个RUN ls -la调试,避免构建失败。

另外,多阶段构建中的每个阶段都是独立缓存命中的。如果你改了源码,只有builder阶段及其后续阶段会重新构建,前面的基础镜像层仍然缓存。充分利用这一点,可以大幅提高构建速度。

我个人习惯:在Dockerfile顶部加一个注释说明每个阶段的作用,比如# === Stage 1: Build ===。这样半年后再回来读,一眼就能看懂。
多阶段构建核心逻辑 源码目录 构建阶段 (go build / npm install) 二进制/产物 运行阶段 (alpine / slim) 最终镜像 构建阶段的编译工具链、临时文件全部被丢弃,只将产物复制到运行阶段 镜像体积减少 80%~95%

这张图展示了最基础的两阶段构建。源码进入构建阶段,生成产物,然后产物被复制到运行阶段,最终打包成镜像。构建阶段里的golang编译器、node_modules、临时下载的deb包,全都没带进最终镜像。

避坑:我踩过的三个坑

  1. 跨阶段文件权限问题:builder阶段的用户是root,运行阶段可能用nobody。复制后二进制如果没有执行权限,容器直接挂掉。解决:在COPY后用RUN chmod +x,或者用--chown参数。
  2. ARG/ENV跨阶段丢失:每个阶段的ENV是独立的。如果你想在运行阶段引用构建阶段的版本号,需要在构建阶段写文件,然后复制过去。比如RUN echo $VERSION > /version.txt,然后COPY --from。
  3. 缓存利用不当:Docker的构建缓存是基于每条指令的。如果你在builder阶段先COPY .再RUN go build,每次改源码都会触发整个build阶段重跑。优化:先只复制go.mod和go.sum,装依赖,再复制源码。这样依赖缓存可以复用。

嗯,这些坑我都亲自踩过。尤其是第一个,那时候线上容器突然都变成Permission denied,排查了半小时才发现是COPY少了执行权限。

什么时候不该用多阶段构建?

多阶段构建不是万能药。如果你用的是一个已经极小的基础镜像(比如distroless),或者你的应用本身就是解释型语言脚本(比如shell脚本),多阶段构建带来的收益微乎其微。另外,如果你不需要保留任何构建产物,直接docker run一个现成镜像就够,没必要建自己的Dockerfile。

但只要你需要自定义编译、打包、依赖安装,多阶段构建就是你必须掌握的技能。