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[调试镜像]
release 和 debug 都可以依赖同一个 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. 阶段、镜像和容器不是同一个概念
需要区分三个对象:
- 构建阶段:Dockerfile 中某个
FROM ... AS name定义的构建节点; - 镜像:选择某个目标阶段后生成的可分发结果;
- 容器:从镜像创建的运行时实例。
例如:
docker build --target build -t demo:build .
docker build --target release -t demo:release .
这会分别把 build 和 release 阶段作为目标生成镜像。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 或其他兼容基础镜像,并验证架构和动态库。
三、最小运行时的形式化条件
“最小”至少有三种不同含义:
- 字节数最小:镜像压缩传输或解压后的体积更小;
- 文件集合最小:运行时只包含应用实际需要的文件;
- 攻击面最小:减少 shell、包管理器、编译器和不必要服务。
三者相关,但不等价。一个压缩率高的镜像不一定具有最小攻击面;一个没有 shell 的镜像也不自动安全。
设构建阶段文件集合为:
运行程序实际需要的文件集合为:
理想的最小运行时满足:
并且最终镜像内容接近:
普通单阶段构建更接近:
由于 R 通常远小于 B,多阶段构建的收益来自:
这个公式只是文件集合模型,不是 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.mod 和 go.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 update 和 apt-get install 放在同一层是必要的缓存一致性措施,但不是完整的可重复构建保证。
更严格的方案需要同时控制:
- 基础镜像 digest;
- 软件包版本;
- 软件源快照或内部镜像;
- 包签名和校验;
- 目标架构;
- 构建工具版本。
因此,“固定依赖”应理解为固定构建输入集合,而不是只给 FROM 加一个 digest。
五、构建上下文、缓存和 Secret
1. 构建上下文是数据流的起点
执行:
docker build -t docker-demo:release .
最后的 . 表示当前目录作为构建上下文。Dockerfile 中的:
COPY . .
只能读取上下文中的文件,不能读取 Dockerfile 所在目录之外的任意路径。
构建上下文过大,会产生三个问题:
- 首次构建上传更多数据;
- 上下文内容变化可能影响相关构建步骤的缓存;
- 意外文件可能被复制到镜像或进入构建过程。
.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 不应通过 ARG 或 ENV 传入
错误示例:
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 时:
COPY go.mod go.sum ./的输入没有变化;go mod download的输入没有变化,可以命中缓存;COPY . .的输入变化,缓存失效;- 后续
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 没有 getent、nslookup、时区数据库或用户名数据库。数字 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)不是只把版本号写死。要让两次构建产生等价结果,至少需要控制:
其中:
- :构建输出;
- :构建上下文和 Dockerfile;
- :依赖及其校验信息;
- :基础镜像和构建工具;
- :时间、时区、文件时间戳等环境;
- :目标平台;
- :构建过程中访问的网络内容。
固定 FROM digest 只约束了 的一部分;lockfile 只约束了 的一部分。若构建脚本执行:
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 固定后,新的安全修复不会自动进入镜像。
因此,供应链流程应把以下对象分开检查:
- 基础镜像及其来源;
- 应用依赖和 lockfile;
- 构建产物;
- SBOM;
- 镜像签名和 provenance;
- 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,而是明确回答三个问题:
- 哪些文件只服务于构建,绝不能进入运行时;
- 哪些输入必须固定,才能审核和复现构建;
- 哪些调试能力应通过独立目标提供,而不是扩大生产镜像。
当构建上下文、依赖锁定、BuildKit 缓存与 Secret、运行时文件集合和发布验证被分别建模后,最小镜像才不只是体积更小,而是边界更清晰、故障更容易定位、供应链输入更容易审计。
系列导航与关联阅读
- 系列入口:Docker 完整学习路线:从镜像与容器到安全、可观测和生产交付
- 上一篇:Dockerfile 与 BuildKit:构建上下文、缓存挂载、Secret 和可重复构建
- 下一篇:Docker 多架构构建:Buildx、QEMU、Manifest 与跨平台发布
- 延伸:Docker 软件供应链:SBOM、签名、扫描、可信基础镜像和策略门禁
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论