1. 为什么我们需要重新审视镜像构建这件事
如果你是一个后端开发或者运维,大概率经历过这样的场景:本地写代码五分钟,构建镜像两小时。尤其是前端项目,node_modules动辄几百兆,加上源码、构建工具链、测试依赖,一个镜像轻轻松松突破 1GB。推送到镜像仓库要等半天,拉到生产环境又要等半天,磁盘空间更是肉眼可见地往下掉。
这个问题的根源不在于 Docker 本身,而在于我们构建镜像的方式。传统做法是把所有东西塞进一个 Dockerfile,从基础镜像开始,装依赖、拷代码、编译、运行,所有层都留在最终镜像里。但真正跑起来的时候,需要的其实只有编译产物和运行时环境,那些编译器、包管理器、源码、缓存文件,统统是累赘。
多阶段构建就是来解决这个问题的。它的核心思路很朴素:把构建过程拆成多个阶段,每个阶段用不同的基础镜像,只把最后一个阶段的产物复制到最终镜像里。听起来简单,但实际操作中有很多细节值得聊。这篇文章我会从设计思路、核心原理、实操步骤、常见坑四个维度,把多阶段构建这件事讲透。无论你是刚接触 Docker 的新手,还是已经写过几十个 Dockerfile 的老手,应该都能从中找到一些之前忽略的东西。
2. 多阶段构建的整体设计与思路拆解
2.1 从单阶段到多阶段:一个真实的痛点演进
先说说单阶段构建为什么不够用。假设你有一个 Go 项目,用单阶段 Dockerfile 写出来大概长这样:基础镜像选golang:1.21,把源码拷进去,go build编译出二进制,然后CMD启动。这个镜像跑起来没问题,但体积大概在 800MB 到 1GB 之间。为什么?因为golang:1.21这个基础镜像本身就包含了完整的 Go 工具链、GCC、各种系统库,这些东西在运行时根本用不到。
有人会说,那我用alpine做基础镜像不就行了?确实,golang:1.21-alpine会小很多,但编译出来的二进制在alpine上跑可能会遇到glibc和musl的兼容问题。而且即便用了alpine,编译工具链还是在镜像里,只是小了一点而已。
多阶段构建的思路完全不同。第一阶段用golang:1.21做编译,第二阶段用scratch或者alpine做运行时,只把编译好的二进制从第一阶段复制到第二阶段。最终镜像里只有二进制和必要的运行时文件,体积可以压到 10MB 以内。这就是多阶段构建最直接的价值:构建环境和运行环境彻底分离。
2.2 多阶段构建的核心机制:阶段隔离与产物复制
多阶段构建的语法核心是FROM ... AS ...和COPY --from=...。每个FROM指令开启一个新的构建阶段,阶段之间默认是完全隔离的,前一个阶段的文件系统对后一个阶段不可见。只有通过COPY --from=<阶段名>才能把指定阶段的文件复制到当前阶段。
这个机制带来的好处是多方面的。第一,最终镜像的体积大幅缩小,因为构建工具链、源码、缓存都不会进入最终镜像。第二,安全性提升,因为攻击面变小了,镜像里没有编译器、没有包管理器、没有 shell(如果用scratch的话),攻击者即使拿到了容器权限,能做的事情也非常有限。第三,构建缓存更高效,因为每个阶段可以独立缓存,修改源码不会导致依赖安装层重新构建。
但这里有一个容易被忽略的点:多阶段构建并不会减少构建时间。相反,因为要拉取多个基础镜像,首次构建可能比单阶段还慢。它的优势在于最终产物的体积和安全性,而不是构建速度。这一点在选型时要想清楚。
2.3 什么场景适合多阶段构建
不是所有项目都适合多阶段构建。如果你的项目本身就是解释型语言,比如 Python 或者 Node.js,运行时需要完整的解释器和依赖包,那么多阶段构建的收益主要体现在把构建工具和测试依赖剥离出去,而不是把整个运行时都去掉。
适合多阶段构建的典型场景包括:编译型语言项目(Go、Rust、C++、Java),前端项目(构建产物是静态文件),以及任何需要在构建时安装大量工具但运行时只需要少量文件的场景。不适合的场景则是那些运行时依赖和构建时依赖高度重合的项目,比如一个纯 Python 脚本,多阶段构建反而会增加复杂度。
提示:判断是否值得用多阶段构建,可以问自己一个问题——最终运行的时候,镜像里有多少文件是真正被用到的?如果这个比例低于 30%,多阶段构建就值得考虑。
3. 核心细节解析与实操要点
3.1 阶段命名与引用:别再用数字索引了
多阶段构建里,阶段可以通过数字索引引用,比如COPY --from=0,也可以通过命名引用,比如COPY --from=builder。我强烈建议用命名方式,原因很简单:数字索引在阶段顺序调整时极易出错,而且可读性差。你看到--from=0根本不知道是哪个阶段,但看到--from=builder就一目了然。
命名阶段的语法是FROM <基础镜像> AS <阶段名>。阶段名建议用有意义的名字,比如builder、deps、runtime、test。如果一个 Dockerfile 里有多个构建阶段,命名能让整个文件的结构清晰很多。
还有一个细节:阶段名只在当前 Dockerfile 内有效,不会影响镜像名称。也就是说,你不需要担心阶段名和镜像名冲突。
3.2 基础镜像选型:scratch、alpine 还是 distroless
最终阶段的基础镜像选型是多阶段构建里最关键的决策之一。常见的选择有三种:scratch、alpine、distroless。
scratch是一个空镜像,里面什么都没有。用scratch做基础镜像,最终镜像里只有你复制进去的文件。优点是极致小,缺点是没有任何调试工具,出了问题只能靠日志。而且如果你的二进制依赖动态链接库,用scratch会直接跑不起来,必须静态编译。
alpine是一个基于musl libc的轻量级发行版,体积大概 5MB。它自带apk包管理器,可以安装一些必要的运行时依赖。但要注意musl和glibc的兼容性问题,某些二进制在alpine上跑会报错。
distroless是某大型科技公司开源的镜像系列,只包含应用和运行时依赖,没有 shell、没有包管理器。它比alpine更安全,但调试也更困难。
| 基础镜像 | 体积 | 调试便利性 | 兼容性 | 适用场景 |
|---|---|---|---|---|
| scratch | 0MB | 极差 | 需静态编译 | Go/Rust 静态二进制 |
| alpine | ~5MB | 一般 | musl 兼容性问题 | 需要少量运行时依赖 |
| distroless | ~20MB | 较差 | 较好 | 生产环境安全优先 |
我的经验是:Go 和 Rust 项目优先用scratch,Java 和 Node.js 项目用alpine或distroless,Python 项目用alpine要谨慎,因为很多 Python 包依赖glibc。
3.3 构建缓存与层复用:让重复构建快起来
多阶段构建的缓存机制和单阶段一样,每一层都会被缓存,只要指令和上下文没变,就会复用缓存。但多阶段构建有一个额外的优化点:阶段之间可以共享缓存。
比如你有两个阶段都用了golang:1.21作为基础镜像,Docker 会复用同一个基础层,不会重复拉取。再比如,如果你在第一个阶段安装了依赖,第二个阶段可以通过COPY --from复制依赖目录,而不是重新安装。
这里有一个实操技巧:把不经常变的部分放在 Dockerfile 前面,经常变的部分放在后面。比如先复制go.mod和go.sum,执行go mod download,再复制源码。这样修改源码时,依赖下载层不会重新构建。
FROM golang:1.21 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /app/main .这个顺序很关键。如果先COPY . .再go mod download,每次改一行代码都会导致依赖重新下载,构建时间直接翻倍。
3.4 跨阶段复制的边界与限制
COPY --from只能复制文件,不能复制环境变量、工作目录、用户设置等元数据。这意味着如果你在构建阶段设置了ENV或者WORKDIR,最终阶段需要重新设置。
另外,COPY --from可以引用外部镜像,不一定非得是当前 Dockerfile 里的阶段。比如COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf是合法的。这个特性在某些场景下很有用,比如你想从官方镜像里提取配置文件。
还有一个限制:COPY --from不能复制目录的父目录。比如你不能写COPY --from=builder /app/.. /,只能复制具体的文件或目录。
注意:跨阶段复制时,文件的所有者和权限会保留。如果构建阶段用的是 root 用户,复制到最终阶段后文件也是 root 所有。如果最终阶段用非 root 用户运行,需要显式
chown。
4. 实操过程与核心环节实现
4.1 一个完整的 Go 项目多阶段构建示例
假设我们有一个简单的 Go Web 服务,项目结构如下:main.go是入口文件,go.mod和go.sum是依赖管理文件。我们要构建一个尽可能小的生产镜像。
第一阶段用golang:1.21-alpine做构建环境。为什么用alpine而不是完整版?因为构建阶段不需要调试工具,alpine足够用,而且拉取更快。设置CGO_ENABLED=0是为了静态编译,这样最终阶段可以用scratch。
FROM golang:1.21-alpine AS builder WORKDIR /build COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /build/server . FROM scratch COPY --from=builder /build/server /server COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ EXPOSE 8080 ENTRYPOINT ["/server"]这里有几个细节值得展开。-ldflags="-s -w"是去掉符号表和调试信息,能让二进制体积再小 20% 左右。复制ca-certificates.crt是因为如果服务需要发起 HTTPS 请求,没有根证书会报错。scratch镜像里没有这个文件,必须从构建阶段复制。
构建命令是docker build -t myapp:latest .。构建完成后用docker images查看,你会发现镜像体积大概在 10MB 到 15MB 之间,相比单阶段的 800MB,压缩了 98%。
4.2 前端项目的多阶段构建:从 node_modules 到 nginx
前端项目是多阶段构建的另一个典型场景。以 React 项目为例,构建阶段需要 Node.js 和node_modules,运行阶段只需要静态文件和 Nginx。
FROM node:18-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --only=production COPY . . RUN npm run build FROM nginx:alpine COPY --from=builder /app/build /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]这里用npm ci而不是npm install,因为ci会严格按照package-lock.json安装,构建结果更可复现。--only=production是跳过开发依赖,但注意有些构建工具是开发依赖,如果npm run build需要它们,就不能加这个参数。实际项目中,构建阶段通常需要完整依赖,所以更常见的做法是不加--only=production,而是在最终阶段只复制build目录。
Nginx 配置里有一个关键点:如果前端是单页应用,需要配置try_files把所有路由指向index.html,否则刷新页面会 404。
server { listen 80; location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } }这个配置我踩过坑。一开始没加try_files,本地开发正常,部署后刷新非根路由直接白屏。排查了半天才想起来是 Nginx 的问题。
4.3 Java 项目的多阶段构建:Maven 与 JRE 的分离
Java 项目的多阶段构建稍微复杂一点,因为编译需要 JDK,运行只需要 JRE。用 Maven 做构建工具的话,Dockerfile 大概是这样:
FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY --from=builder /build/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]mvn dependency:go-offline是为了提前下载依赖,利用 Docker 缓存。这样修改源码时不会重新下载依赖。-DskipTests是跳过测试,因为测试应该在 CI 阶段跑,不应该在镜像构建时跑。
最终阶段用eclipse-temurin:17-jre-alpine,体积大概 150MB,相比完整 JDK 镜像的 400MB 小了很多。如果追求极致,可以用jlink定制 JRE,但复杂度较高,一般项目没必要。
4.4 构建参数与多架构支持
多阶段构建还可以结合ARG和--platform实现多架构构建。比如你要同时构建amd64和arm64的镜像,可以在 Dockerfile 里用ARG TARGETARCH来区分。
FROM golang:1.21-alpine AS builder ARG TARGETARCH WORKDIR /build COPY . . RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -o /build/server .构建时用docker buildx build --platform linux/amd64,linux/arm64 -t myapp:latest .,Docker 会自动为每个架构构建一次,并把结果合并成 manifest list。这个特性在需要同时支持 x86 和 ARM 服务器的场景下非常有用。
但要注意,--platform构建需要buildx插件,而且构建时间会翻倍。如果只是本地开发,没必要开多架构。
5. 常见问题与排查技巧实录
5.1 二进制在 scratch 镜像里跑不起来
这是最常见的问题。现象是容器启动后立刻退出,日志里报no such file or directory或者exec format error。原因通常是二进制依赖了动态链接库,而scratch里没有。
排查方法是本地用ldd命令检查二进制的依赖:
ldd ./server如果输出里有libc.so.6之类的动态库,说明不是静态编译。解决办法是编译时加CGO_ENABLED=0,强制静态编译。如果项目必须用 CGO,那就不能用scratch,得换成alpine并安装对应的库。
还有一个坑是exec format error,这通常是因为编译架构和运行架构不一致。比如在 M1 芯片的 Mac 上编译出arm64二进制,部署到amd64服务器上就会报这个错。解决办法是编译时显式指定GOARCH=amd64。
5.2 跨阶段复制后文件权限不对
现象是容器启动时报permission denied。原因是构建阶段用 root 用户创建的文件,复制到最终阶段后还是 root 所有,但最终阶段可能用非 root 用户运行。
解决办法有两种。一是在最终阶段用COPY --chown指定所有者:
COPY --chown=1000:1000 --from=builder /build/server /server二是在最终阶段用USER root先复制,再USER 1000切换用户。但第一种方式更简洁。
提示:
COPY --chown在旧版本 Docker 里可能不支持,建议用 Docker 20.10 以上版本。
5.3 构建缓存失效导致构建变慢
现象是每次构建都重新下载依赖,明明只改了一行代码。原因通常是COPY指令的顺序不对,把经常变的文件放在了前面。
排查方法是看构建输出,哪一层显示CACHED,哪一层显示RUN。如果RUN go mod download每次都执行,说明前面的COPY go.mod go.sum层失效了。检查一下是不是COPY . .放在了go mod download前面。
还有一个隐蔽的坑是.dockerignore没配置好。如果node_modules或者.git目录被复制进构建上下文,会导致缓存频繁失效。建议在.dockerignore里排除这些目录:
node_modules .git *.log dist5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 容器启动即退出 | 二进制依赖动态库 | ldd ./binary | 静态编译或换 alpine |
| exec format error | 架构不匹配 | file ./binary | 指定 GOARCH |
| permission denied | 文件所有者不对 | ls -l查看权限 | COPY --chown |
| 构建缓存失效 | COPY 顺序不对 | 看构建输出 CACHED | 调整 COPY 顺序 |
| 镜像体积没变小 | 复制了多余文件 | docker history | 检查 COPY --from 路径 |
| HTTPS 请求失败 | 缺少根证书 | 看应用日志 | 复制 ca-certificates |
5.5 几个我踩过的坑和实操心得
第一个坑是scratch镜像里没有时区信息。Go 程序如果用time.Now()获取本地时间,会拿到 UTC 时间。解决办法是从构建阶段复制/usr/share/zoneinfo目录,或者设置TZ环境变量。
第二个坑是alpine镜像里的musl和glibc兼容性问题。某些 Java 版本或者 Node.js 原生模块在alpine上跑会报Error loading shared library。解决办法是换debian-slim基础镜像,或者安装libc6-compat包。
第三个坑是构建阶段和运行阶段的WORKDIR不一致。COPY --from复制文件时,目标路径是相对于当前WORKDIR的。如果最终阶段没设置WORKDIR,文件会复制到根目录。建议在最终阶段显式设置WORKDIR。
第四个坑是ENTRYPOINT和CMD的区别。ENTRYPOINT是容器启动时执行的命令,CMD是默认参数。如果ENTRYPOINT写成["/server"],CMD写成["--port=8080"],实际执行的是/server --port=8080。但如果ENTRYPOINT写成["/server --port=8080"],那就没法通过docker run覆盖参数了。建议用 exec 格式,把命令和参数分开。
6. 多阶段构建的进阶玩法与边界探索
6.1 用测试阶段做构建时验证
多阶段构建不仅可以用来分离构建和运行,还可以插入测试阶段。比如在构建和运行之间加一个test阶段,跑单元测试,测试不通过就不继续构建。
FROM golang:1.21-alpine AS builder WORKDIR /build COPY . . RUN go build -o /build/server . FROM builder AS tester RUN go test ./... FROM scratch COPY --from=builder /build/server /server ENTRYPOINT ["/server"]这个 Dockerfile 里,tester阶段继承了builder,跑测试。如果测试失败,构建就会中断。但要注意,tester阶段默认不会被执行,除非你用docker build --target tester显式指定。如果想让测试成为构建的必经环节,需要把tester放在builder和最终阶段之间,并且最终阶段依赖tester。
6.2 多阶段构建与 CI/CD 的配合
在 CI/CD 流水线里,多阶段构建可以配合--target参数实现分阶段构建。比如 CI 阶段只构建到tester,跑完测试后再构建最终镜像。这样可以避免每次提交都构建完整镜像,节省时间。
docker build --target tester -t myapp:test . docker build --target final -t myapp:latest .还有一个技巧是用--cache-from参数复用远程缓存。在 CI 环境里,本地缓存通常不存在,每次构建都是从头开始。如果能把上一次构建的镜像作为缓存源,构建速度会快很多。
docker pull myapp:latest || true docker build --cache-from myapp:latest -t myapp:latest .这个技巧在团队协作场景下特别有用,能显著减少 CI 构建时间。
6.3 多阶段构建的局限性
多阶段构建不是银弹。它的局限性主要体现在几个方面。第一,调试困难。最终镜像里没有 shell、没有调试工具,出了问题只能靠日志和外部工具。第二,构建时间可能增加。因为要拉取多个基础镜像,首次构建比单阶段慢。第三,Dockerfile 复杂度上升。阶段多了之后,维护成本也会增加。
还有一个容易被忽略的点:多阶段构建并不能减少镜像层数。最终镜像的层数取决于最终阶段的指令数量,而不是阶段总数。所以不要指望用多阶段构建来优化层数。
注意:如果你的项目构建时间本来就很短,比如一个简单的 Python 脚本,多阶段构建带来的收益可能还不如它增加的复杂度。选型时要权衡。
6.4 一个真实项目的构建时间对比
我之前优化过一个 Go 项目的 Dockerfile,从单阶段改成多阶段。单阶段镜像体积 850MB,构建时间 3 分 20 秒。改成多阶段后,镜像体积 12MB,构建时间 2 分 50 秒。构建时间反而短了,因为最终阶段不需要安装运行时依赖,省掉了一些步骤。
但另一个 Node.js 项目的情况不同。单阶段镜像 1.2GB,构建时间 4 分钟。改成多阶段后,镜像 150MB,但构建时间变成了 4 分 30 秒。原因是多阶段需要拉取 Nginx 镜像,而且npm ci在构建阶段跑了两遍(一次在builder,一次在最终阶段复制)。后来优化了COPY顺序,构建时间才降回 3 分 50 秒。
这两个案例说明,多阶段构建的收益因项目而异。关键是要实测,不要凭感觉。
6.5 后续可以继续优化的方向
多阶段构建只是镜像优化的一个环节。如果想进一步压缩体积和提升安全性,还可以考虑几个方向。一是用dive工具分析镜像层,找出哪些文件是多余的。二是用hadolint检查 Dockerfile 的规范性。三是用trivy扫描镜像漏洞。四是考虑用buildkit的--mount=type=cache特性加速依赖下载。
buildkit的缓存挂载是一个很实用的特性,可以让go mod download或者npm ci的缓存在多次构建之间复用,而不需要把缓存层留在镜像里。
RUN --mount=type=cache,target=/go/pkg/mod go mod download这个语法需要 Docker 18.09 以上版本,并且开启BuildKit。实测下来,依赖下载时间能从 30 秒降到 5 秒以内。
我个人在实际操作中的体会是,多阶段构建的价值不仅在于镜像变小,更在于它强迫你思考一个问题:运行的时候到底需要什么?这个思考过程本身就能帮你发现很多可以优化的地方。很多时候我们习惯性地把一堆东西塞进镜像,从来没想过它们是不是真的必要。多阶段构建就是一个契机,让你重新审视镜像里的每一个文件。