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 命中。

随后脚本只推进源文件的修改时间,镜像内时间戳保持不变。这直接验证文档所说的 mtime 排除规则,不代表所有文件元数据都被忽略。接着把模式从 0644 改为 0755,时间戳更新,证明本次 COPY 依赖会受到可执行位变化的影响。两步只改变一个因素,能够区分时间与权限的不同作用。

再修改文件文本,会得到另一个时间戳。输入中包含本次实验的唯一名字,避免以前的同类实验碰巧提供初始应用结果。每一步都读取实际镜像内容进行断言,而不是看到构建日志任意位置有 CACHED 就宣布被研究的操作已经复用。对于多条 RUN 的真实项目,也应采用能定位具体步骤的证据。

最后,应用输入保持不变,使用 --no-cache 后 RUN 再次执行。镜像检查仍显示与原来相同的固定基础层前缀。这个选项禁止构建指令复用缓存,不会把明确选择的基础摘要自动替换成另一个新版本。需要更新基础镜像时,应选择预期的新产物;浮动标签是否重新查询,则还涉及单独的拉取策略。

把新鲜度变成明确输入

让稳定的依赖输入先于频繁修改的源码,并通过窄范围 COPY 避免无关文件成为依赖。如果要求重复得到受控结果,就固定软件包版本或经过校验的下载产物。需要主动刷新依赖时,更新这些声明并审阅产物,不应把“这次没有命中缓存”本身当作安全更新策略,因为远端内容和安装规则仍可能不可控。

密钥挂载还需要单独考虑:密钥内容变化不会自动使构建缓存失效。如果密钥影响一次需要刷新的下载,可以使用明确的非敏感修订输入,或显式要求相关步骤重新执行。不要为了改变缓存键,把真实凭据塞进构建参数;缓存控制与凭据传递有不同的安全边界,不应混在一个字段里处理。

缓存命中日志说明性能方面的复用,真正执行的测试说明行为。记录最终产物身份,测试该产物,并保留足以解释它如何生成的构建输入。快速重建只有在复用的工作仍符合本次发布意图时才有价值;否则,速度只是把陈旧结果更快地交给了下一阶段。

延伸阅读

来源