6.4 Dockerfile 与构建优化

一句话

Dockerfile 的价值不在 “能构建”,而在 “结果可复现、层序不失效、镜像里没有不该有的东西”;三条杠杆:层序(把易变的放最后)、多阶段(把工具链留在 builder)、缓存挂载(把依赖下载与源码变更解耦)。

层是缓存单位,顺序决定一切

缓存键 = 父层 digest + 指令文本。一条指令一变,它和它之后的所有层全部失效。于是:

顺序 改动频率 该放哪
基础镜像、系统包 月级 最前
语言依赖清单(go.mod / package-lock.json / requirements.txt) 周级 其次,且单独一层
依赖下载与编译产物 跟着上一步 紧接依赖清单
业务源码 每次提交 靠后
构建参数、版本号、时间戳 每次构建 最后(否则永远不命中缓存)

最常见的错误是 COPY . . 放在依赖安装之前——源码一改,依赖重下一遍。正确顺序是 “先拷清单、装依赖,再拷源码”。

一个可抄的 Go 多阶段 Dockerfile

# syntax=docker/dockerfile:1.7
FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/app /app
USER nonroot
ENTRYPOINT ["/app"]

四个要点:

  • --mount=type=cache 是 BuildKit 的能力(Docker 23+ 默认开启;更早版本要 DOCKER_BUILDKIT=1)。它把依赖目录挂在构建缓存里,不进镜像层。
  • 第二阶段只拷二进制,构建工具、源码、Go 缓存全部留在 builder,最终镜像只剩 “二进制 + 根证书 + 时区”。
  • CGO_ENABLED=0 才能用 static 版 distroless(否则要 base 版带 glibc);-trimpath 去掉本机路径,-ldflags="-s -w" 去符号表,通常可省 20%–30% 体积。
  • USER nonroot 是默认安全基线;distroless 的 nonroot 用户是 UID 65532。

体积预算:先定目标,再谈优化

类型 合理量级 手段
Go 静态二进制 + distroless/static 6–10MB(实测 8.3MB) 多阶段 + -s -w + 精简 base
Node / Python 应用 100–300MB 多阶段 + 只装生产依赖 + slim base
JVM 应用 200–500MB jlink 裁剪、分阶段、避免 fat jar 里的测试依赖
需要 shell / 调试工具的诊断镜像 另建一个 debug tag 不要把工具塞进生产镜像

“镜像小于 X MB” 不是目标,可复现与可运维才是;体积的意义在于拉取时间、攻击面与存储成本。

可复现:三件事

  1. 基础镜像 pin digest:FROM golang@sha256:...,tag 只作注释。tag 会被覆盖,digest 不会。
  2. 依赖锁文件必须提交,且安装命令用锁模式(npm ci、go mod download、pip install -r)。
  3. 构建参数不要注入时间戳:ARG BUILD_TIME 之类会让每次构建的层都不同,缓存与可复现性同时失效。需要版本信息就写进 -ldflags 的变量,且只影响最后一层。

秘密不进镜像的三种写法

做法 机制 为什么比 ENV 安全
RUN --mount=type=secret,id=npmrc 构建期临时挂载,不落层 环境变量会写进 config,docker history / 镜像 config 都能读出来
BuildKit 的 --ssh 转发宿主 ssh-agent 私钥不进构建上下文
运行时注入(K8s Secret / 外部密钥管理) 进程启动时读取 镜像里根本没有

一条判据:任何 “构建时需要、运行时不运行” 的凭证,都不该出现在镜像里;docker history --no-trunc 能看到的字符串,都等于泄露。

实测(Docker Desktop 29.9.2 / linux-arm64,2026-10 本机跑过)

$ docker build --progress=plain -t dk-app:1 .      # 第一次
#18 naming to docker.io/library/dk-app:1 done
$ docker build --progress=plain -t dk-app:1 .      # 第二次:依赖与源码层命中缓存
#10 CACHED  #11 CACHED  #12 CACHED
$ docker images dk-app:1 --format '{{.Size}}'
8.3MB
$ docker history dk-app:1 --format '{{.CreatedBy}} {{.Size}}' | head -3
ENTRYPOINT ["/app"] 0B
USER nonroot 0B
COPY /out/app /app # buildkit 1.52MB
$ docker run --rm dk-app:1
uid=65532 euid=65532

三条结论与文中一致:第二次构建命中缓存、最终镜像只有 8.3MB 且不含 builder 阶段任何层、默认非 root(65532)。docker export 后 tar -t 还能验证它没有 /bin/sh。

验证方式(在自己的机器上复现同样的判据)

docker build --progress=plain -t app:1 .      # 第二次构建:依赖层应显示 CACHED
docker history app:1                          # builder 阶段的层不应出现在最终镜像
docker images app:1 --format '{{.Size}}'      # Go + distroless 预期 10–30MB
docker run --rm app:1 id                      # 预期 uid=65532(nonroot)

四条都能对上,才算 “构建优化做完了”;只看 docker images 的数字小,不算。

反模式

反模式 后果
RUN apt-get update 与 install 分开写 缓存命中 update 层,install 时索引过期导致 404
COPY . . 在依赖安装之前 改一行代码就重下全部依赖
单阶段构建,把编译器留在运行镜像里 镜像大 5–10 倍,攻击面同样放大
用 latest 作基础镜像 某天构建突然失败或行为变化,且无法复现
在 RUN 里写 export TOKEN=... token 永久留在层里
把 .git、测试数据、本地缓存拷进去 体积大,还可能泄露历史与密钥
为 “层数少” 把 10 条命令塞进一个 RUN 可读性差、缓存粒度变粗,改一条全重跑
只优化体积,不做多阶段 运行镜像里带着整个构建链

三条铁律

🔑 易变的放最后:源码在依赖之后,版本号在最后;顺序错了,缓存等于没有。

🔑 构建工具不进运行镜像:多阶段不是 “优化选项”,是安全基线。

🔑 可复现优先于体积:pin digest + 锁文件;体积是结果,不是目标。

自检

Q1 每次只改一行 Go 代码,构建却要重新下载全部依赖,最可能的原因是什么?

▸ 答案:COPY . . 放在了 go mod download 之前。缓存键按层算,源码层一变,后续所有层(含依赖安装)全部失效。修法是把 go.mod go.sum 单独先 COPY 并下载依赖,再 COPY 其余源码;配合 --mount=type=cache,target=/go/pkg/mod,即使缓存层失效,模块也不必重新下载。

Q2 为什么 RUN rm -rf /var/lib/apt/lists/* 单独一行不减小镜像?

▸ 答案:因为删除发生在新的一层,下层那几十 MB 仍在(whiteout 只遮蔽)。必须写成 apt-get update && apt-get install -y --no-install-recommends ... && rm -rf /var/lib/apt/lists/*,让 “下载—安装—清理” 在同一层完成。这是分层语义的直接结果,机制见 6.2 镜像与分层、写时复制。

Q3 docker history 里能看到 ENV DB_PASSWORD=xxx,说明什么?

▸ 答案:说明密码已经写进镜像 config,任何人拉到这个镜像都能读出来(docker inspect 也能看到)。即便后续层删掉了环境变量,config 的历史仍保留。正确做法是用 BuildKit 的 --mount=type=secret 或运行时的密钥注入,并立即轮换这个已泄露的密码。

Q4 团队把镜像从 800MB 压到 120MB,但部署变慢了,为什么?

▸ 答案:常见原因是层结构变差:为了压体积把很多步骤合并成一层,导致任何改动都让整层重建、整层重新推送与拉取;或者把依赖从 “可共享的基础层” 挪进了应用层,节点上再也无法共享。优化要看 “首次拉取时间 × 变更频率”,不是只看静态体积。基础层共享率(多少镜像共用同一 base)比单镜像体积更影响集群整体拉取成本。

Q5 用 distroless static 跑 HTTPS 调用,需要自己拷根证书吗?

▸ 答案:当前版本不需要,但这正是要实测的原因。本机实测(2026-10,gcr.io/distroless/static-debian12:nonroot)镜像里带 /etc/ssl/certs/ca-certificates.crt(对应 319kB 的 cacerts 层),HTTPS 可直接用。旧版本/其他变体才可能缺证书。真正缺的是时区数据(tzdata)与 shell:容器里 date 显示 UTC、/bin/sh 不存在,前者会让 “按本地时区算的定时任务” 偏 8 小时。判据是导出镜像文件系统实测(docker export … | tar -t | grep -E "ssl|zoneinfo|bin/sh"),不是看文档。

跨篇相关:层与写时复制的语义见 6.2 镜像与分层、写时复制;镜像里的漏洞与基线见 6.6 镜像安全与扫描;运行镜像的运行时链路见 6.5 容器运行时 (containerd / runc);制品的不可变性与仓库分发见 54.1 不可变制品、54.2 镜像与仓库;CI 侧的构建缓存与关键路径见 52.1 构建与缓存;供应链安全见 38.10 供应链安全。