CI 日志里最贵的 38 秒,和更贵的另一行 history
构建日志里的固定节目:
#12 [internal] load build context
#12 transferring context: 1.24GB 38.4s done
Dockerfile 里明明只 COPY 了十几 MB 的源码,发给构建器的却是 1.2GB。同一个问题的更糟版本发生在审计时:
$ docker history --no-trunc shop/api:2026.10 | grep -i token
# ... RUN |1 NPM_TOKEN=ghp_9f31c2e8...a41 /bin/sh -c npm ci
一个 ARG,把 registry token 永久写进了镜像元数据,镜像推到哪个仓库它就跟到哪。这两件事根因相同:没搞清构建上下文到底是什么、以及它在 Dockerfile 生效之前就已经交出去了。
context 是打包发送,不是按需读取
docker build 最后那个路径参数就是 context root,默认 .。客户端把整个目录应用 .dockerignore 之后打包交给构建器(BuildKit 下过滤发生在 client 端)。几个直接推论:
- 构建器拿到的是完整快照,COPY 和 ADD 都是从这份快照里取文件。COPY 了一条,不等于只传了一条;没 COPY 的东西也已经过去了。
- Dockerfile 里的相对路径以 context root 为基准,不是 Dockerfile 所在目录;Dockerfile 自己反而可以放在 context 外面:
docker build -f build/Dockerfile .。 - 传统 builder 每次全量发送。BuildKit 在同一个 daemon 上连续构建会尽量只传变化的增量,但 CI 每次都是全新环境,增量复用无从谈起。
- remote builder(buildx 的 docker-container driver、远端 daemon)下 context 走网络,1.2GB 会被放大成分钟级的等待。
所以收紧 context 是发送侧的事,不是 Dockerfile 侧的事。而发送侧唯一的闸门,是 .dockerignore。
.dockerignore:唯一在传输之前的过滤器
位置必须在 context root——注意不是"Dockerfile 旁边",也不是"仓库根",而是取决于 build 命令最后那个参数。语法形似 .gitignore,但差异必须说清:
- 它决定"什么会被发给构建器",与版本控制无关。被忽略的文件对构建不可见,COPY 会直接报错——这是好事,比静默缺文件好发现。
- 模式相对 context root 匹配完整路径,支持
*、?和**(任意层级目录)。.gitignore 那种每个目录放一份、按所在目录生效的规则在这里不存在,整棵 context 只有 root 这一份生效。 !是后置例外,规则按顺序匹配,最后一条命中的模式决定去留。
# --- 版本库与 CI 元数据:对构建无用,只剩体积 ---
.git
.gitignore
.github
# --- 依赖与产物:容器内会重新安装/编译,本地这份是死重 ---
node_modules
target
dist
build
coverage
# --- 密钥与环境文件:进了 context 就有进层的风险 ---
.env
.env.*
*.pem
*.key
# --- 日志与临时产物 ---
*.log
tmp/
# --- 例外,注意顺序:必须放在对应的排除规则之后 ---
!README.md
!.env.example
! 的顺序语义用最后两行体会:.env.* 排除全部环境文件,!.env.example 把示例捞回来——反过来写(先放行再排除)等于白捞。另一个坑是"先排除整个目录、再用 ! 捞回其中几个文件",这种行为在不同 builder 版本上不稳定,稳妥的写法是排除具体文件,而不是排除目录再打捞。
改完必须量化验证,别靠信仰:
# 构建输出里看真实传输量,plain 进度才有这一行
docker buildx build --progress=plain -t shop/api:dev .
# transferring context: 23.4MB 0.9s done
# 本地找大头,逐层看目录
du -h --max-depth=2 . | sort -rh | head -20
从 1.2GB 到 23MB:一次真实的瘦身
回到开头那个 1.2GB 的构建,du 逐层排出来:
$ du -h --max-depth=1 . | sort -rh | head
1.2G .
691M ./node_modules
180M ./.git
96M ./testdata
41M ./dist
12M ./src
8.4M ./.github
4.1M ./web
逐项判刑:node_modules 在容器内会重新 npm ci,纯死重;.git 带着全部历史进 context 对构建毫无意义,还让每次 commit 后 context 的内容变化更大,对缓存也不友好;testdata 是本地联调用的大文件,镜像里只需要其中一小部分;dist 是上次本地构建的残骸。补全 .dockerignore 之后,transferring context 从 1.24GB 掉到 23.4MB——本地从 38 秒到 1 秒以内,remote builder 上省得更多。
但有两类问题这把刷子解决不了。一类是布局问题:服务在 monorepo 深处、又必须 COPY 仓库根的文件时,硬把 context root 抬到仓库根再维护一份超长 ignore 清单,脆且容易漏——下一节的命名上下文是正路。另一类是维护问题:新同事往仓库塞一个 200MB 的数据集,下个 CI 周期就替他付账;把 transferring context 的字节数打进 CI 日志摘要,异动一眼可见。
密钥:不进 context,也不进层
两条机制决定了铁律:镜像层只读叠加,后一层删除文件抹不掉前一层的字节;history 会记录 ARG 和 ENV 的实际取值。所以密钥只能"挂载进这一次 RUN,用完即走":
# syntax=docker/dockerfile:1
FROM node:22 AS deps
WORKDIR /app
# secret 只在这次 RUN 期间可见,不进层、不进 history
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --omit=dev
# token 放本地文件,而这个文件本身已被 .dockerignore 排除
printf '//registry.example.com/:_authToken=%s\n' "$NPM_TOKEN" > .npmrc.local
docker buildx build --secret id=npmrc,src=.npmrc.local -t shop/api:dev .
拉私有仓库走 –ssh,agent socket 同样只活在 RUN 期间:
RUN --mount=type=ssh \
git clone git@github.com:shop/private-pb.git /tmp/pb
docker buildx build --ssh default=$SSH_AUTH_SOCK -t shop/api:dev .
把构建输入分两类:非敏感的构建参数(版本号、目标平台)走 ARG,接受它进 history;一切凭证走 –secret 和 –ssh。ENV 比 ARG 还糟——运行时 env 里也读得到,密钥永远不进 ENV。
如果密钥已经进了某个层或 history:当作已泄漏处理,第一时间轮换 token,而不是重建镜像了事——旧镜像还躺在本地缓存和 registry 里,派生它的缓存也可能带着。换钥匙是止血,清理派生层是收尾,顺序别反。
–build-context:monorepo 不必再抬 context root
命名上下文解决"构建要引用 context 之外目录"的问题:
# syntax=docker/dockerfile:1.7
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
# protos 是命名上下文,不是本 context 里的路径
COPY --from=protos gen/pb/ /src/gen/pb/
# context 仍是服务目录本身,小而干净;兄弟目录按名字引入
docker buildx build \
--build-context protos=../proto-gen \
--build-context shared=../shared \
-t shop/svc-user:dev .
另一个用法是换底:--build-context base=registry.internal/base-go:1.23-p1 配合 Dockerfile 里写 FROM base,不改一行 Dockerfile 就能在 CI 上统一替换基础镜像。顺带一个安全红利:.env 这类文件根本不必出现在任何 context 里,运行时注入即可。
层缓存与 context 的关系一句话带过:context 中任何一个字节的变化都会使对应的 COPY 层失效,这是缓存命中率的第一道变量,缓存策略本身见之前 buildkit 缓存那篇,不展开。
常见坑点
- .dockerignore 放错位置:
docker build ../时生效的是 context root(即../)下那份;放在 Dockerfile 旁边而 context 在别处,等于没写。 - 文件名打错静默失效:
.dockerinore不会有任何报错,唯一症状是 transferring context 没变小。把构建输出的传输量当回归指标看。 - tar 管道构建(
docker build - < ctx.tar、tar -c . | docker build -):本地目录的 .dockerignore 不会被客户端过滤,tar 根里再放一份是否生效取决于 builder 实现,最稳的做法是打包前就把不该发的剔干净。 - CI 缓存目录被卷进 context:actions/cache 把缓存放进
./.npm-cache,下一次构建全量上车;要么 ignore 它,要么把缓存挪出仓库再挂载。 COPY . .当默认习惯:.venv、testdata、本地 SQLite 文件全部进 context;改成显式 COPY 需要的目录(先 go.mod/go.sum,再 cmd/、internal/)更小,对缓存也更友好。- 以为 multi-stage 能兜住 secret:最终镜像确实不带 builder 阶段的层,但
--target指到中间阶段、或把 builder 阶段导出成缓存镜像时它还是会露面;secret mount 从根上就没有这个暴露面。 - 忽略过头静默变味:COPY 具体文件缺失会报 not found,好发现;
COPY . .之下少了 testdata 是无声的行为变化。
总结
上下文的纪律就三条:context root 尽量小,.dockerignore 收紧并用 transferring context 的数字验证;密钥不进 context 不进层,ARG 和 ENV 只留给非敏感参数;跨目录引用走 –build-context,别靠抬高 context root 解决。传输量停在两位数 MB,history 里搜不到任何凭证,这两件事做到了,这个话题就毕业了。
你以为在构建镜像,其实先把整个目录寄了出去——先管好信封,再谈里面装什么。