先分清交付边界
阶段各司其职
阶段(stage)是由一条 FROM 开始的一组构建指令。多阶段构建(multi-stage build)在一个 Dockerfile 里使用多个阶段。图 1 的两个阶段分别负责“生成程序”和“准备运行文件”。[1]
编译器把源码转换成可执行文件;这套工具及其配套文件叫工具链。构建阶段需要它。运行阶段只需可执行文件及其依赖。“运行阶段”仍在构建镜像时组装;执行 docker run 才会启动容器。可回顾镜像、容器和仓库的关系。
把第二个 FROM scratch 改成 FROM build,仍然只写一条复制命令,最终镜像还会去掉 Go 工具链吗?先写出你的理由。
不会。FROM build 会继承构建阶段的文件系统,工具链和源码已经在里面。图 1 能隔离它们,关键在于从独立的空底座起步,再挑出产物复制过去。
深入一点:为什么编译后再 rm 不等价?
在后续层删除旧文件,不会抹去早先层里的内容。若工具链来自 FROM golang 的基础层,后面 RUN rm 也不能把这些基础层排除出镜像。多阶段通过选择另一套基础层改变交付内容。把安装和清理放进同一个 RUN 可避免某些临时文件进入该层,但仍应按运行需求选择底座。[3]
跟着一个文件走
只复制产物
AS build 给阶段起名。COPY --from=build /out/hello /hello 从这个阶段取文件,放进当前阶段;它不会复制整套构建环境。[2]
FROM golang:1.26 AS build:左栏提供 Go 工具链。WORKDIR /src后COPY main.go .:源码进入左栏的/src。- 编译生成
/out/hello。先猜:再写 FROM scratch,哪些左栏文件会自动进入右栏? FROM scratch AS runtime:右栏从空文件系统起步,左栏文件都不会自动出现。COPY --from=build /out/hello /hello:只把程序复制到右栏。CMD ["/hello"]设置默认启动命令。默认构建目标是最后阶段;导出它,再用镜像启动容器。
--from=build去哪个阶段找/out/hello源阶段中的路径/hello当前阶段中的目标路径普通 COPY main.go . 从构建上下文取文件,即命令末尾 . 指定的目录。加了 --from 则从指定阶段取文件。源路径从源阶段的根目录解析;即使那里写过 WORKDIR /src,这里也不要把它当成起点。目标相对路径才受当前阶段的 WORKDIR 影响。[2]
构建阶段名叫 compile,产物是 /out/app。补全复制到运行阶段 /app 的指令。
COPY --from= /app
COPY --from=compile /out/app /app。阶段名决定去哪里找,绝对源路径决定取哪个文件;当前阶段只接收选中的文件。
右栏已有 FROM scratch。你把 COPY --from=build /out/hello /hello 误写为 COPY /out/hello /hello,会发生什么?
阶段不会隐式传文件,必须显式写出来源。
COPY 复制指定路径的内容,不会自动继承一个阶段。
省略 --from 后,COPY 的来源恢复为构建上下文。路径开头的斜线不会让它访问 build 阶段或宿主机根目录。
在你的机器上验证 · 约 6 分钟
同程序做对照
保持源码与编译命令一致,只改变交付阶段。这样才能把体积差异归因到“有没有带上构建环境”。下面只需 Docker,宿主机不必安装 Go。
编写环境没有 Docker,以下构建、运行和体积数据均未实测。预期输出引用 Go 官方教程,不能当成本机记录。实验在你的 Linux 容器环境执行;会拉取镜像、写入构建缓存,并创建两个镜像和临时容器。
1. 建立实验目录
在宿主机终端执行。mktemp 创建一个新目录,避免覆盖已有文件。程序只打印一行文字,不访问网络或数据卷。
lab_dir=$(mktemp -d)
cd "$lab_dir"
cat > main.go <<'EOF'
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
EOF
源码与预期文字取自 Go 入门教程。golang:1.26 沿用 Docker 官方多阶段示例的标签,不代表“最新版本”;实际补丁版本需在运行时记录。
2. 写两个 Dockerfile
复制下面两个完整命令块。CGO_ENABLED=0 让本例不依赖 C 库;本例能独立运行的二进制程序适合放进 scratch。-o /out/hello 指定产物路径,./main.go 指定要编译的单个源文件。[7]
单阶段:工具也留下
cat > Dockerfile.single <<'EOF'
FROM golang:1.26
WORKDIR /src
COPY main.go .
RUN CGO_ENABLED=0 go build -o /out/hello ./main.go
CMD ["/out/hello"]
EOF
多阶段:只带程序
cat > Dockerfile.multi <<'EOF'
FROM golang:1.26 AS build
WORKDIR /src
COPY main.go .
RUN CGO_ENABLED=0 go build -o /out/hello ./main.go
FROM scratch AS runtime
COPY --from=build /out/hello /hello
CMD ["/hello"]
EOF
两个 CMD 都用 JSON 数组形式直接启动程序。scratch 没有 shell(解释命令的程序),不能用需要 /bin/sh 的启动方式。
3. 构建并运行
先拉取一次相同基础镜像,再连续构建,减少标签变化带来的干扰。-f 选 Dockerfile,-t 给镜像起名;末尾的 . 是当前实验目录。
--rm 让容器退出后自动删除。两次运行的预期标准输出相同,均为下面一行(Go 官方教程原文;Docker 运行未实测):
Hello, World!
若构建无法拉取基础镜像,先检查 Docker Hub 的可达性;若运行阶段报找不到程序,核对产物路径、复制路径和 CMD 是否对应。报错也可能来自缺失的动态库,见下一节。
4. 记录同一列的体积
将环境、基础镜像摘要(digest,即内容标识)与列表原文一起保存。Docker 29 的面向人类列表包含 IMAGE、ID、DISK USAGE、CONTENT SIZE、EXTRA;输出前有以 WARNING: This output is designed for human readability 开头的提示。本页不伪造完整终端记录。
两行都比较 CONTENT SIZE,再把 DISK USAGE 单独记录。不要拿单阶段的一列和多阶段的另一列相减;也不要把本地磁盘占用直接当成下载流量。containerd 镜像存储会同时保留压缩层与解包内容。[5]
若 CONTENT SIZE 显示 0B,怎么比较?
存储后端与内容元数据会影响列表能提供的信息。若两行没有可用的 CONTENT SIZE,不要用它计算缩小比例。可统一改为记录下面的镜像 .Size 字段(字节),并注明指标。它也不等于宿主机释放的磁盘空间。
docker image inspect ms-hello:single ms-hello:multi --format '{{json .RepoTags}} {{.Size}}'
| 镜像 | 最终文件构成 | CONTENT SIZE | DISK USAGE |
|---|---|---|---|
| ms-hello:single | Go 基础环境 + 源码 + 程序 | 未实测 | 未实测 |
| ms-hello:multi | 程序 /hello | 未实测 | 未实测 |
Go 工具链与环境
源码
编译产物与该步文件变化
仅 /hello
两个容器打印同一行文字,为什么体积会不同?这能否证明宿主机总占用也按同样比例变小?
程序行为相同,交付的文件集合不同。多阶段最终镜像排除了工具链和源码;但宿主机仍可能保存拉取的基础镜像和构建缓存。
- 指出比较的是最终镜像的文件集合。
- 区分最终镜像大小与构建缓存、宿主机总占用。
实验后:只移除本例镜像
确认不再需要这两个标签后,可以运行 docker image rm ms-hello:single ms-hello:multi。它不会清掉全部构建缓存;不要为了本实验随手运行全局清理命令。实验文件保留在 $lab_dir 目录,按需手动删除。
更小之前,先保证能跑
按依赖选底座
复制一个文件,不代表这个文件能独立运行。运行依赖是启动或执行时仍要用到的库、解释器、证书及数据。换运行底座之前,要列出这些需求。[4]
本例只打印文字,适用。它是 Dockerfile 的特殊起点,不能单独 pull 或 run。
按语言与库的需求选变体。常规变体没有 shell 和包管理器;debug 变体另有调试工具。
调试更方便,但带入的文件可能更多。多阶段也能以它作为运行底座。
distroless 是一组精简运行镜像,保留所选变体需要的运行文件。它与 scratch 都可以放在第二个 FROM,但要由程序需求决定。这里先认识边界,不展开生产配置。[6]
把这份清单分成“只在构建时需要”和“运行时仍需要”。程序假设为一个会访问 HTTPS 的已编译服务。
- 编译器
- 源码
- 服务可执行文件
- 验证 HTTPS 所需的 CA 证书
瘦身要排除构建专用文件,也要保留真正的运行依赖。本页的打印程序没有 HTTPS 需求;把它替换成联网服务后,就必须重新检查清单。
深入一点:文件存在,为什么还会启动失败?
动态链接程序会在启动时加载外部库,例如 C 标准库;scratch 不提供这些文件。Python 脚本还需要 Python 解释器。即便程序是静态链接的(运行无需再加载那些外部库),HTTPS 证书、时区数据库等资源仍可能缺失。不要把 CGO_ENABLED=0 当作所有应用的通用修复。
没有 shell 时,docker run --rm ms-hello:multi sh 也无法进入命令行。可以临时构建带工具的调试阶段,或用 docker build --target build -f Dockerfile.multi -t ms-hello:build . 导出构建阶段来排查;--target 指定目标阶段。该镜像用于调试,会包含工具链。
合上代码,再检索一次
带走三句话
- 从独立运行底座起步,用阶段边界排除构建环境。
COPY --from挑文件;源阶段、源路径、目标路径都要对。- 先验证行为与依赖,再用同一列的数据比较最终镜像体积。
一个依赖动态 C 库的程序,在构建阶段能跑;只复制可执行文件到 scratch 后启动失败。你应先做什么?
普通 COPY 只换了来源位置,不会分析或补齐库。
新阶段仍是空文件系统,阶段数量不会满足依赖。
构建成功只能说明构建环境满足需求。运行阶段还要满足程序的动态库和资源需求;多阶段构建负责挑选交付文件,不负责自动发现所有依赖。
来源
核对日期:2026-11-03。以 Docker 官方文档为主要依据;distroless 与 Go 示例使用项目官方资料。体积留待同机实测。
- Docker Docs · Multi-stage builds:阶段、命名、默认目标与 --target;golang:1.26 标签来自此处。
- Dockerfile reference · COPY --from:来源与路径解析;FROM、CMD 参见同文档。
- Docker Docs · Building best practices:分离构建与运行环境,减少不需要的文件。
- Docker Docs · Base images:scratch 的含义、限制与运行依赖。
- Docker Docs · containerd image store:压缩内容与解包内容的磁盘占用。image ls 与 image inspect:测量命令。
- GoogleContainerTools · distroless README:运行依赖、shell、变体与调试边界。
- Docker Docs · Go guide:CGO_ENABLED=0 的构建方式。Go · Get started:Hello, World! 源码与文档原始输出。