← 全部笔记

Docker · 2 镜像与 Dockerfile

多阶段构建:把镜像变小

构建时装齐工具,交付时只带运行所需的文件。

约 15 分钟 · 截至 2026-11-03,Docker Engine 29.x · 实验与体积未实测

图 1 最终镜像从运行阶段导出,构建工具留在构建阶段。
学完你能做到
  • 能解释两个 FROM 如何隔开构建文件与运行文件。
  • 能写出 COPY --from,指出源阶段、源路径与目标路径。
  • 能构建并运行一组对照镜像,用同一指标比较体积。
  • 能区分 scratch 与 distroless,并检查程序需要的运行依赖。

先分清交付边界

阶段各司其职

阶段(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]

源码生成产物,再跨阶段复制 左栏 build 有工具链、main.go 和编译产物 /out/hello。右栏 runtime 从 scratch 起步,只接收 /hello,最后设置 CMD。两栏各有独立的文件系统。 buildruntime Go 工具链 /src/main.go 编译/out/hello FROM scratch空文件系统 /helloCOPY --from=build 设为CMD /hello导出右栏;左栏不并入最终镜像
  1. FROM golang:1.26 AS build:左栏提供 Go 工具链。
  2. WORKDIR /src 后 COPY main.go .:源码进入左栏的 /src。
  3. 编译生成 /out/hello。先猜:再写 FROM scratch,哪些左栏文件会自动进入右栏?
  4. FROM scratch AS runtime:右栏从空文件系统起步,左栏文件都不会自动出现。
  5. COPY --from=build /out/hello /hello:只把程序复制到右栏。
  6. CMD ["/hello"] 设置默认启动命令。默认构建目标是最后阶段;导出它,再用镜像启动容器。
图 2 跨阶段传递的是指定文件,最终镜像不会自动合并左栏的层。
--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 给镜像起名;末尾的 . 是当前实验目录。

docker pull golang:1.26
docker build -f Dockerfile.single -t ms-hello:single .
docker build -f Dockerfile.multi -t ms-hello:multi .
docker run --rm ms-hello:single
docker run --rm ms-hello:multi

--rm 让容器退出后自动删除。两次运行的预期标准输出相同,均为下面一行(Go 官方教程原文;Docker 运行未实测):

Hello, World!

若构建无法拉取基础镜像,先检查 Docker Hub 的可达性;若运行阶段报找不到程序,核对产物路径、复制路径和 CMD 是否对应。报错也可能来自缺失的动态库,见下一节。

4. 记录同一列的体积

docker version
docker info --format '{{.OSType}}/{{.Architecture}}'
docker run --rm golang:1.26 go version
docker image inspect golang:1.26 --format '{{json .RepoDigests}}'
docker image ls --filter 'reference=ms-hello:*'

将环境、基础镜像摘要(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 SIZEDISK USAGE
ms-hello:singleGo 基础环境 + 源码 + 程序未实测未实测
ms-hello:multi程序 /hello未实测未实测
图 3 多阶段排除了 Go 基础层与源码层;此图只表示层的构成,高度不代表体积或实测比例。

两个容器打印同一行文字,为什么体积会不同?这能否证明宿主机总占用也按同样比例变小?

程序行为相同,交付的文件集合不同。多阶段最终镜像排除了工具链和源码;但宿主机仍可能保存拉取的基础镜像和构建缓存。

  • 指出比较的是最终镜像的文件集合。
  • 区分最终镜像大小与构建缓存、宿主机总占用。
实验后:只移除本例镜像

确认不再需要这两个标签后,可以运行 docker image rm ms-hello:single ms-hello:multi。它不会清掉全部构建缓存;不要为了本实验随手运行全局清理命令。实验文件保留在 $lab_dir 目录,按需手动删除。

更小之前,先保证能跑

按依赖选底座

复制一个文件,不代表这个文件能独立运行。运行依赖是启动或执行时仍要用到的库、解释器、证书及数据。换运行底座之前,要列出这些需求。[4]

图 4 三种底座都要满足运行依赖;scratch 不能替代解释器或动态库。

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 指定目标阶段。该镜像用于调试,会包含工具链。

合上代码,再检索一次

带走三句话

一个依赖动态 C 库的程序,在构建阶段能跑;只复制可执行文件到 scratch 后启动失败。你应先做什么?

普通 COPY 只换了来源位置,不会分析或补齐库。

新阶段仍是空文件系统,阶段数量不会满足依赖。

构建成功只能说明构建环境满足需求。运行阶段还要满足程序的动态库和资源需求;多阶段构建负责挑选交付文件,不负责自动发现所有依赖。

来源

核对日期:2026-11-03。以 Docker 官方文档为主要依据;distroless 与 Go 示例使用项目官方资料。体积留待同机实测。

  1. Docker Docs · Multi-stage builds:阶段、命名、默认目标与 --target;golang:1.26 标签来自此处。
  2. Dockerfile reference · COPY --from:来源与路径解析;FROM、CMD 参见同文档。
  3. Docker Docs · Building best practices:分离构建与运行环境,减少不需要的文件。
  4. Docker Docs · Base images:scratch 的含义、限制与运行依赖。
  5. Docker Docs · containerd image store:压缩内容与解包内容的磁盘占用。image ls 与 image inspect:测量命令。
  6. GoogleContainerTools · distroless README:运行依赖、shell、变体与调试边界。
  7. Docker Docs · Go guide:CGO_ENABLED=0 的构建方式。Go · Get started:Hello, World! 源码与文档原始输出。