Docker 基础体系 · 第 6/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Docker 多阶段构建:最小运行时、依赖固定、调试层和体积优化

多阶段构建(multi-stage build)是 Dockerfile 中使用多个 FROM 阶段,并只把必要产物从构建阶段复制到最终阶段的机制。它解决的不是“让每一条 RUN 更短”,而是把编译环境运行环境分离:

  • 编译阶段需要编译器、头文件、包管理器和源代码;
  • 运行阶段通常只需要可执行文件、证书、配置和少量运行时库;
  • 调试阶段可以保留 shell、诊断工具,但不必进入生产镜像。

这几个阶段最终形成一个有向图,而不是一个必须完整发布的线性文件系统。

flowchart LR
    C[构建上下文] --> D[deps 依赖下载]
    D --> B[build 编译]
    B --> R[release 最小运行时]
    B --> G[debug 调试镜像]
    R --> I[生产镜像]
    G --> J[调试镜像]

releasedebug 都可以依赖同一个 build 阶段,但它们的基础镜像和内容不同。构建 release 时,Docker 不需要把 debug 阶段发布为最终镜像;构建 debug 时,则选择另一个目标阶段。


一、多阶段构建到底改变了什么

1. 普通单阶段构建的问题

下面是一个概念上的单阶段 Dockerfile:

FROM golang:1.22-alpine

WORKDIR /src
COPY . .
RUN go build -o /app/server .

CMD ["/app/server"]

最终镜像继承了 golang:1.22-alpine 的全部内容,包括:

  • Go 编译器;
  • /usr/local/go
  • 编译缓存;
  • 包管理器;
  • 构建过程中复制进去的源代码;
  • 可能存在的测试文件、文档和临时文件。

即使最后只运行 /app/server,这些内容仍然存在于最终镜像的文件系统中。

多阶段版本把构建环境截断:

FROM golang:1.22-alpine AS build

WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server .

FROM scratch AS release

COPY --from=build /out/server /server
ENTRYPOINT ["/server"]

第二个 FROM scratch 开始了一个全新的阶段。release 阶段不会继承 build 阶段的文件系统;它只能通过:

COPY --from=build /out/server /server

显式接收编译产物。

这里有一个重要边界:多阶段构建不会自动删除构建阶段产生的层。它是通过选择最终阶段,避免这些构建层进入最终镜像。构建缓存仍然可能保留在构建器中,用于下次构建,但缓存不等于发布镜像内容。

2. 阶段、镜像和容器不是同一个概念

需要区分三个对象:

  1. 构建阶段:Dockerfile 中某个 FROM ... AS name 定义的构建节点;
  2. 镜像:选择某个目标阶段后生成的可分发结果;
  3. 容器:从镜像创建的运行时实例。

例如:

docker build --target build -t demo:build .
docker build --target release -t demo:release .

这会分别把 buildrelease 阶段作为目标生成镜像。demo:build 可以用于检查编译环境,demo:release 才是生产运行时。

如果不指定 --target,默认目标是 Dockerfile 中最后一个阶段。


二、一个完整的 Go 示例

下面的示例使用 Linux 容器,并假设程序是一个不依赖 CGO 的 Go HTTP 服务。

项目结构:

demo/
├── .dockerignore
├── Dockerfile
├── go.mod
└── main.go

go.mod

module example.com/docker-demo

go 1.22

main.go

package main

import (
	"fmt"
	"log"
	"net/http"
	"os"
)

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "hello from a multi-stage image")
	})

	addr := ":" + port
	log.Printf("listening on %s", addr)
	if err := http.ListenAndServe(addr, nil); err != nil {
		log.Fatal(err)
	}
}

.dockerignore

.git
.gitignore
Dockerfile
README*
tmp
dist
*.log

.dockerignore 作用于构建上下文。客户端在发送上下文时会排除匹配的文件,因此它既可以减少上传量,也能防止无意中把 .git、本地构建产物或日志带入构建过程。

它不是安全边界。若构建机本身已经拥有敏感文件,仍然应当从上下文源头避免将其放入构建目录;不要把 .dockerignore 当作秘密管理机制。

1. 基础 Dockerfile

# syntax=docker/dockerfile:1

FROM golang:1.22-alpine AS build

WORKDIR /src

COPY go.mod ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux \
    go build \
    -trimpath \
    -ldflags="-s -w" \
    -o /out/server .

FROM scratch AS release

COPY --from=build /out/server /server
USER 65532:65532
ENTRYPOINT ["/server"]

FROM alpine:3.20 AS debug

RUN apk add --no-cache ca-certificates

COPY --from=build /out/server /server
USER 65532:65532
ENTRYPOINT ["/server"]

构建生产镜像:

docker build --target release -t docker-demo:release .

运行:

docker run --rm -p 8080:8080 docker-demo:release

另一个终端执行:

curl http://127.0.0.1:8080/

预期输出:

hello from a multi-stage image

这里的每一步有明确原因:

  • go mod download 单独成层,使依赖文件不变时可以复用下载缓存;
  • COPY . . 放在依赖下载之后,避免普通源代码修改导致依赖下载层失效;
  • CGO_ENABLED=0 生成不依赖动态 C 库的 Linux 二进制;
  • -trimpath 去除构建路径信息,有助于减少环境路径差异;
  • -ldflags="-s -w" 去除符号表和调试信息,减小二进制,但会降低现场调试能力;
  • scratch 不包含 shell、包管理器或基础用户空间;
  • USER 65532:65532 使用数字 UID/GID,不依赖 scratch 中存在 /etc/passwd
  • debug 阶段使用 Alpine 提供 shell 和基本用户空间,但这些内容不会进入 release

构建调试镜像:

docker build --target debug -t docker-demo:debug .

进入调试容器:

docker run --rm -it --entrypoint /bin/sh docker-demo:debug

此时可以执行:

ls -l /server
id

如果要在容器内启动服务,退出 shell 后也可以直接运行:

docker run --rm -p 8080:8080 docker-demo:debug

2. scratch 不是“更安全”的同义词

scratch 是一个空的基础文件系统。它不包含:

  • /bin/sh
  • CA 证书;
  • 时区数据;
  • /etc/passwd
  • DNS 工具;
  • 动态链接器;
  • 常见诊断命令。

因此它适合真正自包含的静态程序,但不是所有程序都适合。

例如,程序通过 HTTPS 请求外部服务时,通常需要 CA 证书。可以从构建阶段复制证书:

FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates

FROM scratch AS release
COPY --from=build /out/server /server
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 65532:65532
ENTRYPOINT ["/server"]

更简单的做法是让构建阶段提供证书:

FROM golang:1.22-alpine AS build

RUN apk add --no-cache ca-certificates
# ...

然后:

FROM scratch AS release

COPY --from=build /out/server /server
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

如果程序启用了 CGO:

CGO_ENABLED=1 go build ...

那么生成的二进制可能依赖动态链接器和 libc。此时直接复制到 scratch 中常见的失败表现是:

exec /server: no such file or directory

这个错误并不一定表示 /server 不存在,也可能表示 ELF 解释器(动态链接器)不存在。可以在构建阶段检查:

file /out/server
ldd /out/server

静态程序通常会显示类似:

statically linked

ldd 的输出依赖工具实现,不能仅凭一条命令作绝对判断。启用 CGO 后,应选择包含匹配 libc 的运行时,例如适当的 Alpine、Debian slim 或其他兼容基础镜像,并验证架构和动态库。


三、最小运行时的形式化条件

“最小”至少有三种不同含义:

  1. 字节数最小:镜像压缩传输或解压后的体积更小;
  2. 文件集合最小:运行时只包含应用实际需要的文件;
  3. 攻击面最小:减少 shell、包管理器、编译器和不必要服务。

三者相关,但不等价。一个压缩率高的镜像不一定具有最小攻击面;一个没有 shell 的镜像也不自动安全。

设构建阶段文件集合为:

B={b1,b2,,bn}B = \{b_1,b_2,\ldots,b_n\}

运行程序实际需要的文件集合为:

R={r1,r2,,rm}R = \{r_1,r_2,\ldots,r_m\}

理想的最小运行时满足:

RBR \subseteq B

并且最终镜像内容接近:

Ifinal=IbaseRI_{\text{final}} = I_{\text{base}} \cup R

普通单阶段构建更接近:

Isingle=Ibuild-baseBI_{\text{single}} = I_{\text{build-base}} \cup B

由于 R 通常远小于 B,多阶段构建的收益来自:

IsingleIfinalIbuild-base+BRIbase|I_{\text{single}}| - |I_{\text{final}}| \approx |I_{\text{build-base}}| + |B \setminus R| - |I_{\text{base}}|

这个公式只是文件集合模型,不是 Docker 实际压缩大小的精确计算,因为镜像还受到层压缩、文件重复、元数据和基础镜像格式影响。

反例是:如果最终阶段仍然使用完整的 Go 基础镜像,并且只是把二进制复制过去,那么构建阶段与运行阶段仍然共享大量不必要内容,体积收益会很有限。


四、依赖固定:标签、摘要和锁文件解决不同问题

1. golang:1.22-alpine 不是固定输入

镜像标签是可变名称。维护者可以让同一个标签指向新的镜像清单或新的 manifest。以下 Dockerfile:

FROM golang:1.22-alpine

在不同日期构建,可能使用不同的基础镜像内容。

固定基础镜像通常使用 digest:

FROM golang:1.22-alpine@sha256:... AS build

digest 是内容寻址标识,表示某个具体镜像清单或 manifest。它带来的保证是:只要 registry 中该内容仍可获取,解析到的对象不会因为标签移动而改变。

但 digest 也有两个边界:

  • 对多架构镜像,digest 可能指向 manifest list;构建器仍需根据目标平台选择具体平台镜像;
  • digest 只能固定基础镜像内容,不能固定构建过程访问的所有外部网络资源。

实际操作时,可以先检查标签当前对应的摘要:

docker buildx imagetools inspect golang:1.22-alpine

输出中会包含镜像清单和各平台信息。应当审核目标平台对应的 digest,再将摘要提交到 Dockerfile,而不是在构建脚本中每次动态解析标签。

2. 依赖固定必须覆盖传递依赖

基础镜像固定不等于应用依赖固定。应用依赖还包括:

  • 直接依赖;
  • 传递依赖;
  • 操作系统包;
  • 编译器版本;
  • 下载源返回的内容。

Go 项目应提交 go.modgo.sum

COPY go.mod go.sum ./
RUN go mod download

go.sum 保存模块校验和,可以检测下载内容是否与已记录的校验值一致。它不是“把所有模块源码复制进仓库”,而是对模块内容进行完整性校验。

Node.js 项目通常应使用:

COPY package.json package-lock.json ./
RUN npm ci

npm ci 依据 lockfile 安装,而不是像 npm install 那样重新解析并可能更新依赖树。Python 项目应使用项目实际采用的 lockfile 工具,并确认安装命令确实以锁定结果为输入。

3. Alpine 包也可能不是固定输入

下面的命令:

RUN apk add --no-cache ca-certificates

能减少索引缓存,但没有固定 ca-certificates 的确切版本。仓库内容变化后,重建可能得到不同包。

APT 也有类似问题:

RUN apt-get update && apt-get install -y curl

即使 Dockerfile 不变,仓库中的 curl 版本也可能变化。apt-get updateapt-get install 放在同一层是必要的缓存一致性措施,但不是完整的可重复构建保证。

更严格的方案需要同时控制:

  • 基础镜像 digest;
  • 软件包版本;
  • 软件源快照或内部镜像;
  • 包签名和校验;
  • 目标架构;
  • 构建工具版本。

因此,“固定依赖”应理解为固定构建输入集合,而不是只给 FROM 加一个 digest。


五、构建上下文、缓存和 Secret

1. 构建上下文是数据流的起点

执行:

docker build -t docker-demo:release .

最后的 . 表示当前目录作为构建上下文。Dockerfile 中的:

COPY . .

只能读取上下文中的文件,不能读取 Dockerfile 所在目录之外的任意路径。

构建上下文过大,会产生三个问题:

  1. 首次构建上传更多数据;
  2. 上下文内容变化可能影响相关构建步骤的缓存;
  3. 意外文件可能被复制到镜像或进入构建过程。

.dockerignore 因此不仅是体积优化工具,也是减少输入不确定性的工具。

2. 普通缓存与缓存挂载不同

BuildKit 会根据指令、输入文件和相关元数据复用构建结果。还可以使用缓存挂载:

# syntax=docker/dockerfile:1

FROM golang:1.22-alpine AS build

WORKDIR /src

COPY go.mod go.sum ./

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download

COPY . .

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux \
    go build -trimpath -o /out/server .

缓存挂载中的内容:

  • 可被后续构建复用;
  • 不会因为 COPY --from 自动进入最终镜像;
  • 不应当被视为可靠的构建输入;
  • 在不同构建器、清理缓存或 CI 节点切换后可能不存在。

因此,构建必须在缓存为空时仍然正确。缓存只能优化速度,不能承担依赖声明、完整性校验或构建结果正确性的责任。

3. Secret 不应通过 ARGENV 传入

错误示例:

ARG NPM_TOKEN
RUN npm config set //registry.example.com/:_authToken=$NPM_TOKEN

ARG 可能出现在构建历史、元数据或日志关联信息中,并且执行结果可能将凭据写入某个层。

BuildKit 支持 Secret mount:

# syntax=docker/dockerfile:1

FROM alpine:3.20 AS private-deps

RUN --mount=type=secret,id=netrc,target=/root/.netrc \
    chmod 600 /root/.netrc && \
    wget -O /tmp/private-package.tar.gz https://packages.example.com/private.tar.gz

构建时:

docker build \
  --secret id=netrc,src="$HOME/.netrc" \
  --target private-deps \
  -t private-deps:test .

Secret mount 在该 RUN 期间以临时文件形式出现,通常不会作为文件写入镜像层。仍需注意:命令输出、生成的构建产物和错误日志可能泄露秘密;构建脚本不应打印凭据,也不应把包含凭据的配置复制到最终阶段。

Secret mount、cache mount 等 RUN --mount 能力依赖 BuildKit 语法。现代 Docker Engine 通常默认使用 BuildKit,但 CI 中仍应确认构建器配置,不要假设所有旧版 Docker 环境都支持这些指令。


六、缓存失效的因果关系

Dockerfile 的缓存优化不是简单地“把不变命令写在前面”,而是要分析每一步的输入集合。

例如:

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN go build -o /out/server .

当只修改 main.go 时:

  1. COPY go.mod go.sum ./ 的输入没有变化;
  2. go mod download 的输入没有变化,可以命中缓存;
  3. COPY . . 的输入变化,缓存失效;
  4. 后续 go build 重新执行。

如果改成:

COPY . .
RUN go mod download
RUN go build -o /out/server .

修改任何源代码都会先使 COPY . . 失效,再使依赖下载失效。依赖未变化时仍然要重新处理下载步骤,构建时间更长。

不过,缓存命中不是规范层面的“永远保证”。以下因素可能导致失效:

  • Dockerfile 指令变化;
  • 被复制文件变化;
  • 基础镜像变化;
  • 构建参数变化;
  • 构建平台变化;
  • BuildKit 缓存被清理;
  • 依赖下载命令或网络结果发生变化。

缓存优化不能替代锁文件和 digest。缓存命中只表示构建器认为某个结果可复用,不表示该结果一定是最新安全版本。


七、调试层:把诊断能力与生产攻击面分开

最小生产镜像经常遇到一个矛盾:生产镜像没有 shell,出现故障时难以执行诊断命令;把 shell、curl、包管理器永久装进生产镜像,又扩大了攻击面并改变了运行环境。

调试阶段提供了第三条路径:

FROM alpine:3.20 AS debug

RUN apk add --no-cache ca-certificates curl

COPY --from=build /out/server /server
USER 65532:65532
ENTRYPOINT ["/server"]

使用:

docker build --target debug -t docker-demo:debug .
docker run --rm -it --entrypoint /bin/sh docker-demo:debug

可以检查:

file /server
ls -l /server
id

如果要保留目标程序的入口点,同时覆盖环境变量:

docker run --rm \
  -e PORT=8080 \
  -p 8080:8080 \
  docker-demo:debug

也可以直接从构建阶段提取文件,而不运行调试镜像:

docker create --name demo-build docker-demo:build
docker cp demo-build:/out/server ./server
docker rm demo-build

调试层的正确定位是诊断工具,不是生产镜像的备用实现。它仍可能与 release 有差异,例如:

  • Alpine 使用 musl,而 scratch 没有 libc;
  • debug 层拥有 CA 证书或时区数据,release 层没有;
  • debug 层包含 shell,改变了进程和信号行为;
  • debug 层的包版本可能与生产基础镜像不同。

因此,调试镜像适合验证“构建产物是否能在一个可诊断环境中运行”,不能证明最小生产镜像一定正确。最终仍应直接运行 release 镜像进行验收。


八、体积优化的正确顺序

1. 先从构建产物中排除不需要的内容

多阶段构建通常是收益最大的一步:

COPY --from=build /out/server /server

它只复制明确指定的文件,而不是把构建目录全部带入运行时。

错误写法:

COPY --from=build /src /src

这会把源代码、模块缓存和其他中间文件一起复制到最终阶段,抵消多阶段构建的主要收益。

2. 控制构建上下文

使用 .dockerignore 排除:

  • .git
  • 测试生成物;
  • 本地依赖目录;
  • 日志;
  • 编译输出;
  • 编辑器临时文件。

这会减少上下文传输和无意义的缓存失效,但不要排除构建必须的 lockfile、证书或生成代码。

3. 合并清理操作与产生垃圾的操作

APT 示例:

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates \
    && rm -rf /var/lib/apt/lists/*

删除必须在同一个 RUN 中完成。下面的写法不能有效减少最终层中的文件:

RUN apt-get update && apt-get install -y ca-certificates
RUN rm -rf /var/lib/apt/lists/*

原因是第二层只记录“删除”,第一层中已经写入的文件仍然存在于镜像层历史中。联合文件系统在最终视图中看不到它们,但分发和存储仍可能保留相应数据。

不过,多阶段构建通常比“在同一个阶段里安装后删除”更可靠,因为它可以不把整个构建环境复制到最终镜像。

4. 压缩二进制要理解取舍

Go 的:

-ldflags="-s -w"

会移除符号表和 DWARF 调试信息,通常能减小二进制,但会降低 dlv、符号化堆栈和现场分析能力。它不应在调试构建中默认使用。

一种更清晰的做法是让 release 和 debug 使用不同构建参数:

FROM golang:1.22-alpine AS build-release
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux \
    go build -trimpath -ldflags="-s -w" -o /out/server .

FROM golang:1.22-alpine AS build-debug
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux \
    go build -trimpath -o /out/server .

但这会增加 Dockerfile 维护成本。若只是需要 shell 和工具,而不需要调试符号,前面的单一 build 阶段更简单。

5. 不要只看一个“镜像大小”数字

常见检查命令:

docker image ls docker-demo
docker history docker-demo:release
docker image inspect docker-demo:release

它们关注点不同:

  • docker image ls 显示本地镜像的摘要信息;
  • docker history 展示层和 Dockerfile 指令对应关系;
  • docker image inspect 提供配置、架构、入口点、用户等元数据;
  • registry 的压缩传输大小与本地解压存储大小可能不同。

因此,优化前后应使用同一工具、同一平台和同一标签策略比较,否则容易把压缩差异、基础镜像缓存或多架构清单差异误判为 Dockerfile 优化效果。


九、运行时最小化后的常见故障

1. exec format error

典型原因是镜像架构与节点架构不匹配。例如在 ARM 主机上构建了 ARM 二进制,却将其部署到 AMD64 节点。

检查:

docker image inspect docker-demo:release \
  --format '{{.Os}}/{{.Architecture}}'

检查二进制:

docker run --rm --entrypoint /bin/sh docker-demo:debug
# Alpine 调试环境中:
file /server

多平台构建示例:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --target release \
  -t registry.example.com/docker-demo:1.0 \
  --push .

这里的 --platform 会影响基础镜像选择和 Go 的 GOARCH 等构建变量。必须验证依赖是否也支持目标架构;仅让 Go 编译成功,不代表所有 CGO 或系统包都能跨平台工作。

2. HTTPS 失败或证书错误

scratch 没有默认 CA 证书。应用访问 HTTPS 服务时,可能出现:

x509: certificate signed by unknown authority

解决办法不是关闭证书校验,而是复制受信任 CA bundle,并确认它来自已审核的构建输入。企业内部 CA 还需要明确加入哪个证书,避免把整个主机证书目录无差别复制进镜像。

3. 用户或权限错误

如果程序监听小于 1024 的端口,非 root 用户通常无法绑定该端口:

listen tcp :80: bind: permission denied

更安全的设计是使用高端口,例如 8080,由编排层或反向代理将外部端口映射到它。不要为了绕过该错误而简单移除 USER

写入错误也很常见:

open /tmp/cache: permission denied

最小镜像可能没有预创建目录,或者目录属于 root。应当显式准备可写路径,或在运行时挂载临时文件系统。例如 Docker 运行时:

docker run --rm \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=16m \
  docker-demo:release

--read-only 会把容器根文件系统设为只读;应用若需要写入,必须使用明确的卷或 tmpfs。这属于运行时策略,不是 Dockerfile 自动保证。

4. DNS、时区和用户名称缺失

scratch 没有 getentnslookup、时区数据库或用户名数据库。数字 UID 可以工作,但以下行为可能不同:

  • 按用户名解析当前用户;
  • 将 UTC 转换为本地时区;
  • 通过系统工具诊断 DNS;
  • 使用依赖 /etc/passwd 的库函数。

是否复制 /etc/passwd/etc/group、时区数据或 resolv.conf,应由应用实际需求决定。Docker 运行时通常会为容器生成部分网络配置文件,但这不等于镜像内置了完整用户空间。

5. PID 1 和信号处理

容器中的入口进程通常是 PID 1。使用 exec form:

ENTRYPOINT ["/server"]

程序直接成为容器主进程,能够接收停止信号。相比之下:

ENTRYPOINT /server

可能通过 shell 解释执行,信号转发和子进程回收行为取决于 shell 和程序结构。

这不是多阶段专属问题,但最小镜像通常没有 shell,反而更容易迫使入口点写得明确。应用本身仍应正确处理 SIGTERM,以便编排系统进行优雅退出。


十、可重复构建与“同一个 Dockerfile 得到同一个镜像”

可重复构建(reproducible build)不是只把版本号写死。要让两次构建产生等价结果,至少需要控制:

O=f(C,D,B,T,P,N)O = f(C, D, B, T, P, N)

其中:

  • OO:构建输出;
  • CC:构建上下文和 Dockerfile;
  • DD:依赖及其校验信息;
  • BB:基础镜像和构建工具;
  • TT:时间、时区、文件时间戳等环境;
  • PP:目标平台;
  • NN:构建过程中访问的网络内容。

固定 FROM digest 只约束了 BB 的一部分;lockfile 只约束了 DD 的一部分。若构建脚本执行:

RUN curl https://example.com/latest.tar.gz | tar -xz

那么 URL 返回内容变化后,输出就可能变化,即使 Dockerfile 完全不变。

更可控的方式包括:

  • 将依赖版本写入 lockfile;
  • 验证下载包的 SHA-256;
  • 使用内部、版本化且可审计的软件源;
  • 固定基础镜像 digest;
  • 固定构建平台;
  • 避免在构建步骤中使用 latest、当前时间或未版本化网络资源;
  • 对编译器和生成工具版本进行固定。

SOURCE_DATE_EPOCH 可以帮助某些工具统一时间戳,但它不是所有编译器、归档工具和生成器都自动遵守的通用开关。使用前应验证具体工具的行为,不能仅设置环境变量就宣称输出完全可复现。

构建验证可以采用:

docker buildx build \
  --platform linux/amd64 \
  --provenance=true \
  --sbom=true \
  -t registry.example.com/docker-demo:1.0 \
  --push .

现代 BuildKit 能生成 provenance 和 SBOM 相关证明或元数据,但具体产物形式、默认配置和构建器能力会随 Docker/BuildKit 版本变化。生产流水线应检查实际推送到 registry 的 manifest 和 attestations,而不是仅凭命令成功退出判断供应链信息已经完整生成。


十一、构建目标与发布目标的分离

一个实际项目往往不只有一个目标:

FROM golang:1.22-alpine AS build
# 编译产物

FROM scratch AS release
# 生产镜像

FROM alpine:3.20 AS debug
# 调试镜像

FROM build AS test
RUN go test ./...

分别构建:

docker build --target test -t demo:test .
docker build --target debug -t demo:debug .
docker build --target release -t demo:release .

这三个目标的职责不同:

  • test 验证源代码和依赖;
  • debug 提供诊断环境;
  • release 提供最小运行环境。

不要把“能进入 shell”作为生产可运维性的唯一标准。生产调试还可以通过:

  • 日志和指标;
  • 健康检查;
  • 临时复制相同二进制到受控调试环境;
  • 使用编排平台的临时诊断容器;
  • 收集 core dump 或 profile。

调试层应与生产构建共享尽可能多的编译产物,这样才能减少“debug 能运行、release 不能运行”的差异;但最终验收仍需启动 release 目标,因为基础镜像和系统文件集合不同。


十二、与安全扫描和策略门禁的关系

最小镜像通常能减少可扫描的软件包数量,但“扫描结果更少”不等于“漏洞更少”。

例如:

  • scratch 没有包管理器,扫描器可能无法像扫描 Debian 那样枚举系统包;
  • 应用二进制自身仍可能包含存在漏洞的依赖;
  • 静态链接可能把库代码编入二进制;
  • 基础镜像 digest 固定后,新的安全修复不会自动进入镜像。

因此,供应链流程应把以下对象分开检查:

  1. 基础镜像及其来源;
  2. 应用依赖和 lockfile;
  3. 构建产物;
  4. SBOM;
  5. 镜像签名和 provenance;
  6. CVE 扫描与组织策略。

一个合理的门禁不能只写成“扫描到 0 个漏洞才允许发布”,因为不同扫描器对 scratch、静态二进制和语言生态的识别能力不同。应定义:

  • 哪些严重性阻断发布;
  • 哪些漏洞有可验证的误报或不可利用条件;
  • 基础镜像多久更新;
  • digest 更新如何审核;
  • 签名和来源证明缺失时是否拒绝部署;
  • 扫描器无法识别的组件如何处理。

最小运行时是降低暴露面的手段,不是漏洞治理的替代品。


十三、一个更严格的生产 Dockerfile组织方式

在不引入真实 digest 字符串的情况下,可以让 CI 在审核后传入 digest:

# syntax=docker/dockerfile:1

ARG GO_IMAGE_DIGEST

FROM golang:1.22-alpine@${GO_IMAGE_DIGEST} AS build

WORKDIR /src

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download

COPY . .

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux \
    go build -trimpath -ldflags="-s -w" -o /out/server .

FROM scratch AS release

COPY --from=build /out/server /server
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 65532:65532
ENTRYPOINT ["/server"]

构建时:

DIGEST="$(docker buildx imagetools inspect golang:1.22-alpine \
  --format '{{json .Manifest.Digest}}')"

docker build \
  --build-arg GO_IMAGE_DIGEST="$DIGEST" \
  --target release \
  -t docker-demo:release .

这里的命令只是演示把摘要传入 Dockerfile;实际生产中应将经过审核的 digest 固定在版本控制或受控构建配置中,而不是每次构建都重新查询可变标签。若摘要为空、格式不正确或与目标平台不匹配,构建应失败,而不是回退到未固定的标签。


十四、判断多阶段构建是否正确的验证顺序

构建完成后,至少验证以下内容:

docker image inspect docker-demo:release \
  --format 'OS={{.Os}} ARCH={{.Architecture}} USER={{.Config.User}} ENTRYPOINT={{json .Config.Entrypoint}}'

预期应看到:

OS=linux ARCH=amd64 USER=65532:65532 ENTRYPOINT=["/server"]

再检查生产镜像是否误带入构建工具:

docker run --rm --entrypoint /bin/sh docker-demo:release

scratch 镜像,这条命令预期失败,因为不存在 /bin/sh。这不是故障,而是镜像最小化的直接结果。不要为了让诊断命令成功而把 shell 加回生产镜像;应使用 debug 目标:

docker run --rm --entrypoint /bin/sh docker-demo:debug

最后直接验证生产目标:

docker run --rm -d --name demo-release -p 8080:8080 docker-demo:release
curl --fail http://127.0.0.1:8080/
docker rm -f demo-release

如果 debug 镜像能运行而 release 镜像不能运行,应优先比较:

  • 动态链接依赖;
  • CA 证书;
  • 用户和权限;
  • 时区文件;
  • 可写目录;
  • 目标架构;
  • 入口点和环境变量。

多阶段构建的核心不是把 Dockerfile 写成多个 FROM,而是明确回答三个问题:

  1. 哪些文件只服务于构建,绝不能进入运行时;
  2. 哪些输入必须固定,才能审核和复现构建;
  3. 哪些调试能力应通过独立目标提供,而不是扩大生产镜像。

当构建上下文、依赖锁定、BuildKit 缓存与 Secret、运行时文件集合和发布验证被分别建模后,最小镜像才不只是体积更小,而是边界更清晰、故障更容易定位、供应链输入更容易审计。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。