本文摘要改一行源码构建从 20 秒涨到 3 分钟根因是上下文拷贝打穿层缓存。拆开COPY与依赖安装用缓存挂载承接下载把重建范围压到最小。一、问题与结论CI 上一个 Flask 服务改app.py一行注释后提交docker build -t demo .的耗时从约 20 秒涨到 3 分钟。展开--progressplain逐步日志#5 [2/4] COPY . . 0.3s #6 [3/4] RUN pip install -r requirements.txt 152.6s #7 [4/4] CMD [python, app.py] 0.0s耗时来自该流水线构建日志的场景数据后文对比数值请按脚本在你的环境实测。结论先行缓存失效是传递的某一层未命中其后所有层无条件重建。COPY . .放在pip install之前一次改动的爆炸半径就覆盖整条层链。COPY/ADD的缓存键是文件内容校验和.dockerignore只缩小参与计算的文件集合拦不住被拷贝文件自身的变更。层序调整的收益来自低频变更靠前、依赖清单先于源码不是把COPY机械挪到最后一行。层序用尽仍然慢通常是 lock 文件被流水线重写或ARG位置不当此时才需要--mounttypecache承接下载。二、排查与选择依据按顺序做四件事先确认失效层再决定动层序还是动工具docker build --progressplain找出第一个不带CACHED的RUN它前面的COPY即打穿点。看日志首行transferring context:体积远大于源码体积即.git、node_modules、产物目录没被排除。检查COPY requirements.txt ./是否在RUN pip install之前否则源码改动会和依赖清单混进同一个校验和。检查--build-arg BUILD_DATE、VERSION是否在pip install之前被引用每轮不同的值会从该层起级联失效。只有第 2 到第 4 步都无法降低失效频率才值得引入缓存挂载这类超出层缓存的手段。替代方案与取舍做法选择条件代价边界 / 不该用的情况层序拆分依赖清单先行依赖变更频率明显低于源码无额外要求Dockerfile 多两行依赖清单被流水线每轮重写时收益趋近于零RUN --mounttypecache安装耗时长且层缓存必然失效需 BuildKitEngine 23.0 默认缓存只落在构建机本地多 runner 不共享并发构建可能竞争同一缓存目录远程缓存--cache-to/--cache-from分布式 CIrunner 无本地缓存占用仓库存储首次拉取有网络开销单机已稳定命中缓存时不该引入多阶段构建构建依赖不应进入运行镜像Dockerfile 结构更复杂COPY --from链更长只为提速、无体积诉求时不该用同样不该硬套层序依赖只有几项、安装本就秒级构建必须在 legacy builderDOCKER_BUILDKIT0下进行此时--mounttypecache不可用monorepo 里多个包的依赖清单互锁任一变更都会让安装层重跑。三、关键原理每个层的缓存键由本条指令的输入加上父层的缓存标识共同决定因此失效向后传递COPY . . ← app.py 改 1 行校验和变化 └─ RUN pip install ← 父层标识变化无条件重建 └─ CMD ← 同上COPY的校验和基于文件内容只touch不改内容不会失效但COPY . .把上下文所有文件都算进去README.md改一个字、.git里多一次提交都足以打穿它。爆炸半径就是失效层到 Dockerfile 末尾的层数层序优化的目标是把它压到最小不是消除失效。--mounttypecache解决的是另一半问题在/root/.cache/pipnpm 对应/root/.npm挂一个持久目录该目录不进入镜像层不受层缓存失效影响重跑安装时不会重复下载包它不省下解析和安装本身的时间也不会让镜像变大。四、可运行示例环境Docker Engine ≥ 23.0默认 BuildKit。ci-cache-demo/ ├── app.py ├── requirements.txt ├── .dockerignore ├── Dockerfile.bad ├── Dockerfile.good └── bench.shrequirements.txtflask3.0.0 gunicorn21.2.0app.pyprint(cache-demo).dockerignore.git log-*.txt Dockerfile.bad Dockerfile.good bench.shDockerfile.bad——COPY . .打穿缓存FROM python:3.12-alpine WORKDIR /app COPY . . RUN pip install -r requirements.txt CMD [python, app.py]Dockerfile.good——拆分COPY 层序优化# syntaxdocker/dockerfile:1 ARG BASEpython:3.12-alpine FROM ${BASE} WORKDIR /app COPY requirements.txt ./ RUN --mounttypecache,target/root/.cache/pip pip install -r requirements.txt COPY . . CMD [python, app.py]bench.sh#!/usr/bin/env bashset-euopipefaildf${1:-Dockerfile.good}base${2:-python:3.12-alpine}tagdemo-bench:$(echo$base|tr:/--)forroundincold warm dirty;doif[$rounddirty];thenecho# cache-bust$(date%s)app.py;fit0$(date%s)dockerbuild--progressplain --build-argBASE$base\-f$df-t$tag.log-$df-$round.txt21echo$df$round$base$(($(date%s)-t0))sdonegrep-cCACHEDlog-$df-dirty.txt||truedockerimagels$tag操作先./bench.sh Dockerfile.bad再./bench.sh Dockerfile.good脚本在dirty轮改一行app.py完整日志写入log-*.txt。预期输出$ grep -n pip install -A1 log-Dockerfile.good-dirty.txt #6 [3/5] RUN --mounttypecache,target/root/.cache/pip pip install -r requirements.txt #6 CACHED $ grep -n pip install -A1 log-Dockerfile.bad-dirty.txt #5 [3/4] RUN pip install -r requirements.txt 下一行没有 CACHED实际输出把log-*.txt与脚本打印的秒数存档后核对三点——dirty轮Dockerfile.bad的pip install不带CACHEDDockerfile.good同一步带CACHED两者秒数差即方案收益。本文未在你的环境实测具体数值以这些日志为准。常见失败ARG 把正确的层序照样打穿流水线常注入构建日期。若把ARG BUILD_DATE加RUN echo build$BUILD_DATE build-info.txt置于pip install之前BUILD_DATE每轮不同该层必然失效并级联到安装层。修复办法是把ARG与写入build-info.txt的指令移到安装层之后。同理.git未被.dockerignore排除时每次提交都会让COPY . .校验和变化。五、验证结果与边界三类问题各有对应解法COPY . .打穿缓存靠拆分层序pip 重复下载靠/root/.cache/pip缓存挂载镜像体积与启动时间的矛盾回到基础镜像选择。用同一条Dockerfile.good对比基础镜像forbaseinpython:3.12-alpine python:3.12-slim python:3.12;do./bench.sh Dockerfile.good$basedone体积量级alpineslim 完整镜像但alpine的 musl 会让部分 wheel 需要本地编译反而拉长构建slim保留 glibc、兼容性更好。这条权衡未在本文实测以docker image ls与log-*.txt的秒数作判断依据。缓存挂载不进入镜像层加速手段本身不加剧体积问题。边界条件依赖清单每次构建被重写如 CI 自动 bump 版本号时层序与缓存挂载都只能省下载时间monorepo 需拷贝多个清单文件任一变更仍会触发安装多平台构建会更换基础镜像摘要缓存基本重建并发构建共享同一 cache mount 目录时留意写入竞争。参考资料Dockerfile referenceBest practices for writing DockerfilesDocker Build cacheCache storage backendsBuild context 与 .dockerignore