多阶段构建通过隔离构建阶段与运行阶段,只把最终生成的二进制文件复制到精简基础镜像中,从而实现更少的镜像层数(例如 alpine + COPY 仅 2 层);其核心原理不是“删除”中间层,而是直接跳过这些中间层,且 COPY --from 不会把源阶段的层数一并带入最终镜像。

多阶段构建的设计初衷并不只是“减少镜像层数”,而是为了优化 Docker 镜像臃肿、体积偏大以及安全风险较高等问题。不过在实际使用中,它的确能够明显压缩最终镜像层级。关键原因在于:最终镜像只保留运行所必需的文件,所有构建过程中的中间产物都不会进入运行阶段。
理解镜像层的本质:无法真正删除的快照
Docker 镜像的层数,并不是简单按照 RUN 指令数量累加出来的,而是由每条指令(如 FROM、RUN、COPY 等)生成的文件系统快照决定。即便你在单阶段构建中使用了RUN rm -rf /tmp删除临时文件,对应的那一层实际上依旧存在,只是被标记为“已删除”,底层空间并不会因此真正释放。
多阶段构建的优势就在于巧妙绕开了这个问题:它不是在已有层里尝试“清理掉”内容,而是从最终镜像中彻底舍弃这些层。构建阶段中的所有内容,例如 Go 编译器、.go 源码、mod 缓存以及中间对象文件等,都只存在于 builder 阶段;运行阶段则重新开始,仅通过 COPY 引入最终可执行二进制。
让运行阶段真正实现“少层”的实用方法
最终镜像层数 = 运行阶段基础镜像层数 + 你额外添加的层数。因此,控制基础镜像和运行阶段指令数量非常关键:
- 优先选择 alpine:3.20,而不是盲目使用 scratch:scratch 虽然可理解为零层起步,但没有 shell,也缺少调试能力;alpine:3.20 自身通常只有 1 层,再加上 COPY 二进制文件就是 2 层,体积足够小,同时更适合实际部署与排查问题
- COPY --from 不会累加源阶段层数:它只负责复制文件结果,不会把 builder 阶段中原有的 10 层、20 层一起复制过来,这也是多阶段构建能够减少最终镜像层数的关键机制
- 运行阶段尽量避免多余指令:不要在 FROM alpine 之后继续写 RUN apk add、RUN chmod(除非确实需要)、或多次切换 WORKDIR——因为每条指令通常都会形成新的镜像层。尽可能精简为 COPY 和 CMD,结构最清晰也最利于优化
这些常见问题要特别注意
很多人发现“镜像层数没有降下来”,其实并不是多阶段构建无效,而是因为构建失败或写法错误,导致运行阶段实际上退化成了单阶段构建:
- COPY --from=builder 误写成 --from=build:如果阶段名称拼写错误,Docker 无法找到对应源阶段,会报出 unknown stage name,进而可能导致构建流程异常,甚至退回隐式单阶段思路
- 复制路径写错:例如 COPY --from=builder /app/main /app,但 builder 阶段真正输出的是 /workspace/main,此时构建会提示 /app not found,导致 COPY 失败,后续步骤也可能一并报错或中断
- 使用 --from=0 之类的数字索引:虽然语法可用,但只要前面的 FROM 顺序调整,阶段编号就会变化,后续维护成本高,也更容易出现误引用问题
Go 项目常见且安全的写法(2 层运行镜像)
下面这种结构,通常可以稳定生成仅含 1 层基础镜像 + 1 层二进制文件的最终镜像:
# 构建阶段:缓存 go mod,编译静态二进制 FROM golang:1.22-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -a -ldflags '-extldflags "-static"' -o /app/server .运行阶段:纯 Alpine,只 COPY 二进制
FROM alpine:3.20 WORKDIR /root COPY --from=builder /app/server . CMD ["./server"]
这种写法中没有 RUN、没有 apk 安装、也没有额外的文件处理操作——最终只保留基础镜像层和一个 COPY 层,因此总共只有两层。如果已经确认程序是纯静态链接,并且线上环境不需要调试能力,那么把 alpine:3.20 替换为 scratch,还可以进一步实现真正意义上的零层运行镜像。
