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 go mod download
COPY . .
RUN \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY /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” 不是目标,可复现与可运维才是;体积的意义在于拉取时间、攻击面与存储成本。
可复现:三件事
- 基础镜像 pin digest:
FROM golang@sha256:...,tag 只作注释。tag 会被覆盖,digest 不会。 - 依赖锁文件必须提交,且安装命令用锁模式(
npm ci、go mod download、pip install -r)。 - 构建参数不要注入时间戳:
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 供应链安全。