Docker 构建缓存优化经验:从"每次冷构建"到"只重跑尾部几层"

最近在折腾一个离线镜像,里面塞了一个 Python(uv 管依赖)+ Node(pnpm 管依赖)的混合项目,外加一个有点重的浏览器二进制安装步骤。

一开始打镜像非常慢。改一个测试文件、调一行注释、改一段文档,Python 依赖重装一遍,Node 依赖重装一遍,浏览器二进制也重装一遍。每次都在冷构建,完全没办法做小步迭代。

后来把 Dockerfile 和构建脚本翻修了一轮,大部分昂贵层开始稳定命中 cache。现在真正每次重跑的只剩尾部这几层:

COPY . /app
uv sync
pnpm --dir extension build
buildx --output type=docker,dest=...

结论就一句话:Dockerfile 的分层顺序和 BuildKit cache 怎么用,直接决定了你每天的迭代成本。

下面记一下这套调整里真正管用的东西。

1. 别一上来就 COPY 全仓库

旧写法大概是这个路子:

COPY . /app
RUN uv sync
RUN pnpm install
RUN pnpm build

任何一个文件变动都会打穿 COPY . 这一层,后面全跟着重跑。哪怕你只改了一个测试文件,依赖安装也从头来一遍。

Docker 官方文档说得够直白:把指令按“不常变 → 经常变”排序,昂贵且稳定的放前面,经常改动的源码放最后。

2. 先把 lockfile 单独 COPY 进去

调整之后,依赖文件的 COPY 独立出来,装完依赖再 COPY 源码:

COPY pyproject.toml uv.lock ${APP_HOME}/
RUN --mount=type=cache,target=/home/runner/.cache/uv,uid=1001,gid=1001 \
    uv sync --no-install-project; \
    UV_NO_SYNC=1 uv run python -m <browser_install_step>

COPY extension/package.json \
     extension/pnpm-lock.yaml \
     extension/pnpm-workspace.yaml \
     ${APP_HOME}/extension/
RUN --mount=type=cache,target=/home/runner/.local/share/pnpm/store,uid=1001,gid=1001 \
    pnpm --dir extension install --frozen-lockfile

COPY . ${APP_HOME}
RUN --mount=type=cache,target=/home/runner/.cache/uv,uid=1001,gid=1001 \
    uv sync; \
    pnpm --dir extension build

效果很直接。改 src/ 或 extension/ 下的 TS、测试文件,不会触发 Python / Node 依赖安装。只有改 pyproject.toml / uv.lock 时才会重跑 Python 依赖和浏览器安装;只有改 package.json / pnpm-lock.yaml 时才会重跑 pnpm install。

这个“按变化频率分层”的思路不复杂,但收益很大。

3. 层失效了,也别重新下载

普通 layer cache 是精确匹配:指令变了,这层就废,下次从头跑。

BuildKit 的 --mount=type=cache 是另一种思路——它给某个 RUN 步骤挂一个持久化的缓存目录。即使这层因为其他原因重跑了,包管理器自己的下载缓存还在,不用重新从网络拉包。

实际用的几个:

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
    apt-get update && apt-get install ...

RUN --mount=type=cache,target=/home/runner/.cache/uv,uid=1001,gid=1001 \
    uv sync --no-install-project

RUN --mount=type=cache,target=/home/runner/.local/share/pnpm/store,uid=1001,gid=1001 \
    pnpm --dir extension install --frozen-lockfile

几点注意:

  • apt 的 cache 建议 sharing=locked,避免并发构建写冲突。
  • 非 root 用户构建时 cache mount 要带 uid / gid,不然权限不对。
  • cache mount 不进入最终镜像,只在 builder 侧存在。
  • 换 builder、清 build cache、CI 临时 runner 都可能让它丢失,别当永久存储用。

4. .dockerignore 不只是“排除文件”

.dockerignore 看着不起眼,但 build context 大小直接由它决定:

.git
.agent
.venv
node_modules
extension/node_modules
dist
build
*.tar
*.env.local
**/*.env

几个层面的收益:

  • 更快:传给 builder 的上下文小得多。
  • 更安全:.env、本地 secrets、旧 bundle 不会被意外烘进镜像。
  • cache 更稳:无关文件的变动不会打穿 COPY . 层。

调完之后可以 exec 进去确认目录确实被排除了:

docker exec <container> bash -lc \
  'test -d /app/.agent && echo present || echo absent'

返回 absent 才算数。

还有一个取舍:要不要把 tests/ 也放进 .dockerignore?放了,测试文件改动不影响 cache;不放,镜像里能跑 smoke 测试。看你的场景选。

5. 离线部署,直接出 tar

如果最终用途是“本地构建 → scp 到服务器 → docker load”,可以跳过中间的 load/save:

docker buildx build --output type=docker,dest=$tar_path ...

比传统写法少绕一步:

# 旧路径
docker buildx build --load ...
docker image save -o image.tar image:tag

直接拿到 deploy tar,适合离线分发。如果你还需要本地 docker run 做 smoke 测试,加个开关就行,比如 LOAD_IMAGE=1 RUN_SMOKE=1,让脚本先 load 再跑。

6. Cache 失效了怎么排查

构建速度掉回冷构建的时候,按这个顺序过一遍:

  1. Dockerfile 里昂贵层前面的指令是不是动过。
  2. pyproject.toml / uv.lock 改了没。
  3. package.json / pnpm-lock.yaml 改了没。
  4. 是不是不小心带了 --no-cache 或 NO_CACHE=1。
  5. Docker Desktop / buildx builder 的 cache 被清了没。
  6. 是不是切了 buildx builder。
  7. .dockerignore 漏了某个大目录或频繁变化的目录。
  8. 是不是误把 .git、dist、旧 tar、runtime 状态放进了 context。

顺手记几个有用的命令:

docker buildx ls          # 看当前 builder
docker system df          # Docker 磁盘占用
docker buildx du          # build cache 占用
docker buildx prune       # 清 cache(会让下次变冷构建)

7. 几种机制怎么区分

刚接触的时候很容易把 layer cache、cache mount、.dockerignore 搞混,列个表:

概念作用典型场景进最终镜像?
Layer cache复用某条指令的结果RUN pnpm install 输入没变是
Cache mount给构建步骤挂持久缓存目录npm/pnpm/uv/pip/apt 下载缓存否
.dockerignore控制 build context排除 .git、dist、secrets不进入 context
External cache跨机器 / CI 共享 cacheGitHub Actions / 临时 runner在 registry 或 GHA cache

把这几种分清楚,后面再碰到“为什么没命中”就不会一头雾水了。

几条原则

  • Dockerfile 越靠前,放稳定、昂贵、少变的东西。
  • Dockerfile 越靠后,放源码、测试、文档这些频繁改动的。
  • 装依赖之前只 COPY lockfile / manifest,不要 COPY 整个仓库。
  • 大下载和包管理器,能用 BuildKit cache mount 就用。
  • .dockerignore 既是性能优化,也是安全边界。
  • 离线 bundle 直接 buildx --output type=docker,dest=...,本地要跑再开 LOAD_IMAGE。
  • 别随手 --no-cache,除非你真的要看一次冷构建。

参考

Docker DocumentationOptimize cache usage in buildsAn overview on how to optimize cache utilization in Docker builds. Docker DocumentationBuild contextLearn how to use the build context to access files from your Dockerfile Docker DocumentationBuild cache invalidationDig into the details about how cache invalidation works for Docker's build cache Docker DocumentationDockerfile referenceFind all the available commands you can use in a Dockerfile and learn how to use them, including COPY, ARG, ENTRYPOINT, and more. Docker DocumentationCache storage backendsCache backends let you manage your build cache externally. External cache is useful to create a shared cache that can help speed up inner loop and CI builds.

小结

最大的变化是体感上的:之前每次构建都像冷启动,现在改源码只重跑尾部几层,迭代速度快了一大截。镜像大小也顺手降了一些(裁掉了无关层),但真正值钱的是每天省下来的那点等待时间。

下次开新项目,第一版 Dockerfile 应该会直接按这个套路写:先 lockfile,再依赖,再源码,依赖步骤一律挂 cache mount。

#Docker#BuildKit#构建优化#离线部署