多阶段构建入门:一次真正的镜像瘦身革命
你可能觉得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阶段及其后续阶段会重新构建,前面的基础镜像层仍然缓存。充分利用这一点,可以大幅提高构建速度。
# === Stage 1: Build ===。这样半年后再回来读,一眼就能看懂。
这张图展示了最基础的两阶段构建。源码进入构建阶段,生成产物,然后产物被复制到运行阶段,最终打包成镜像。构建阶段里的golang编译器、node_modules、临时下载的deb包,全都没带进最终镜像。
避坑:我踩过的三个坑
- 跨阶段文件权限问题:builder阶段的用户是root,运行阶段可能用nobody。复制后二进制如果没有执行权限,容器直接挂掉。解决:在
COPY后用RUN chmod +x,或者用--chown参数。 - ARG/ENV跨阶段丢失:每个阶段的
ENV是独立的。如果你想在运行阶段引用构建阶段的版本号,需要在构建阶段写文件,然后复制过去。比如RUN echo $VERSION > /version.txt,然后COPY --from。 - 缓存利用不当:Docker的构建缓存是基于每条指令的。如果你在builder阶段先
COPY .再RUN go build,每次改源码都会触发整个build阶段重跑。优化:先只复制go.mod和go.sum,装依赖,再复制源码。这样依赖缓存可以复用。
嗯,这些坑我都亲自踩过。尤其是第一个,那时候线上容器突然都变成Permission denied,排查了半小时才发现是COPY少了执行权限。
什么时候不该用多阶段构建?
多阶段构建不是万能药。如果你用的是一个已经极小的基础镜像(比如distroless),或者你的应用本身就是解释型语言脚本(比如shell脚本),多阶段构建带来的收益微乎其微。另外,如果你不需要保留任何构建产物,直接docker run一个现成镜像就够,没必要建自己的Dockerfile。
但只要你需要自定义编译、打包、依赖安装,多阶段构建就是你必须掌握的技能。