复制输入决定后续 RUN 的结果能否复用。

Docker 构建缓存复用已记录的工作,也可能复用过期结果

Docker 构建缓存保存构建操作的结果,让后续构建在相关输入仍匹配时复用已有工作。缓存命中不表示命令重新运行并生成相同输出,而是构建器接受了一份先前结果。这个区别直接影响软件包安装、生成时间戳、远程下载,以及输出可能变化却没有相应声明输入变化的操作。理解缓存时,首先要区分“再次执行”和“取出已经执行过的结果”。 坏掉的样子 没有变化的 RUN apt-get update 可以直接复用昨天的结果,完全不访问软件仓库。构建器通常不会为了判断这条 RUN 是否仍然新鲜,先检查远端仓库有没有更新。因此,缓存构建成功不能独立证明软件包已经收到最新安全修复。刷新策略必须明确,并与项目要求的可复现性一致,不能依赖某次构建恰好缓存失效。 COPY 的输入不同于普通命令字符串。文件内容和相关元数据参与缓存计算,但源文件修改时间本身不参与。部署脚本如果只是 touch 文件,依赖它的 RUN 仍可能继续命中缓存。反过来,即使文本完全相同,改变可执行权限也可能使 COPY 失效,从而重新执行后续工作。排查时需要比较实际输入,不能只看文件大小和肉眼可见的文字。 基础镜像、步骤实际使用的构建参数和前置依赖也会影响结果。缓存失效沿依赖传播,不能把复杂多阶段构建一律解释成从某行开始全部重做的直线。下面使用简单的单阶段链:复制文件发生变化,就使其后的时间戳 RUN 重新执行,而固定基础镜像的文件系统层仍可继续复用。这种受控结构让实验结论容易对应到真实原因。 快速验证 使用宿主 Python 运行脚本,并连接一个可用于实验的 Docker 构建器。如果本地没有指定摘要,先拉取它。实测环境是 Docker 29.3.1、Buildx 0.32.1,以及 Linux arm64 的 Python 3.11.16 基础镜像。构建和容器均关闭网络。脚本删除自己的临时标签,不清理其他镜像,也不对全局构建缓存执行 prune: from pathlib import Path from tempfile import TemporaryDirectory import json,os,shlex,subprocess,uuid BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(["docker",*args],capture_output=True,text=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def inspect(image):return json.loads(docker("image","inspect",image).stdout)[0] name='xbcatm-cache-'+uuid.uuid4().hex[:10];tag=name+':probe' try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);app=root/'app.txt';app.write_text('initial '+name);app.chmod(0o644) build_code="from pathlib import Path;import time;Path('/built-at').write_text(str(time.time_ns()))" (root/'Dockerfile').write_text(f'FROM {BASE}\nCOPY app.txt /app.txt\nRUN python -c {shlex.quote(build_code)}\n') def build(*options):return docker('build','--network','none','--progress','plain',*options,'-t',tag,str(root)) def stamp():return docker('run','--rm','--network','none',tag,'python','-c',"from pathlib import Path;print(Path('/built-at').read_text())").stdout.strip() build();initial=stamp();base_layers=inspect(BASE)['RootFS']['Layers'] result=build();assert stamp()==initial and 'CACHED' in result.stderr timestamp=app.stat().st_mtime_ns+10_000_000_000;os.utime(app,ns=(timestamp,timestamp)) build();assert stamp()==initial print('unchanged inputs reuse the RUN result; changing only source mtime also leaves that result cached') app.chmod(0o755);build();mode_changed=stamp();assert mode_changed!=initial app.write_text('changed '+name);build();content_changed=stamp();assert content_changed!=mode_changed print('changing copied-file permissions or content invalidates COPY and reruns the dependent RUN') build('--no-cache');assert stamp()!=content_changed assert inspect(tag)['RootFS']['Layers'][:len(base_layers)]==base_layers print('no-cache reruns the instruction while this pinned base retains the same filesystem layer prefix') finally:docker('image','rm',tag,check=False) 首次构建把纳秒时间戳写进 /built-at。第二次用相同输入构建,日志出现缓存操作,镜像中的时间戳也完全相同。这比比较构建耗时更有说服力:命令可能执行得很快,但保留下来的同一个时间戳表明,这里复用了之前记录的结果。测试同时看日志和目标文件,避免把其他步骤命中缓存误认为目标 RUN 命中。 ...

二月 20, 2028 · 1 分钟 · XB Field Notes