Dockerfile 顺序决定昂贵构建操作依赖哪些先前输入。如果应用源码在依赖安装之前被复制,即使依赖声明未变,修改源码也可能让安装重跑。先复制依赖输入、安装依赖,再复制应用代码,可以建立更小的依赖边界,让它与安装器真正需要的工作匹配。这里优化的是依赖关系,而不只是把几行命令挪到看起来更整齐的位置。

坏掉的样子

在软件包安装前使用宽泛的 COPY . .,会让文档、生成输出或无关源码编辑,都可能成为重做昂贵工作的原因。问题不在于 COPY 天生很慢,而在于后续安装依赖了一份包含无关变化的文件系统状态。这些变化并不是选择和安装依赖所必需,却通过前置步骤扩大了失效范围。

如果安装还读取锁文件、本地包源码、补丁、工作区元数据或包管理器配置,仅复制一个清单也不够。遗漏真实输入,可能保留已经不符合构建意图的缓存结果。正确边界必须包含安装器实际使用的全部输入,而不是只选择常见 Dockerfile 示例里出现的某个文件名。否则,表面命中率提高,实际正确性却下降。

缓存顺序也不能修复可变依赖。相同安装命令可能一直保留旧结果,而干净构建却从远端解析到不同内容。固定版本和校验哈希解决产物选择问题,控制 COPY 顺序解决复用问题。两者都需要处理,构建更快并不能证明软件包图可复现,也不能证明依赖已经更新到本次发布要求的版本。

快速验证

使用宿主 Python、Docker 构建器和已取得的固定基础镜像运行脚本。它先通过 HTTPS 下载两个公开的 idna wheel,并在构建前验证固定 SHA-256。容器构建关闭网络,pip 从本地 wheel 目录安装,并强制要求哈希。实测环境为 Docker 29.3.1,Linux arm64 基础镜像内 Python 为 3.11.16:

from pathlib import Path
from tempfile import TemporaryDirectory
from urllib.request import urlopen
import hashlib,json,subprocess,uuid

BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
WHEELS={
 '3.10':('https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl','946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3'),
 '3.11':('https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl','771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea')}
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

name='xbcatm-order-'+uuid.uuid4().hex[:10];tags=[name+':early',name+':late']
try:
    with TemporaryDirectory(prefix=name+'-') as directory:
        root=Path(directory);(root/'wheels').mkdir()
        for version,(url,digest) in WHEELS.items():
            with urlopen(url,timeout=40) as response:data=response.read()
            assert hashlib.sha256(data).hexdigest()==digest
            (root/'wheels'/url.rsplit('/',1)[1]).write_bytes(data)
        install='RUN pip install --no-index --no-deps --require-hashes --find-links=/wheels -r /app/requirements.txt && python -c "import time;open(\'/installed\',\'w\').write(str(time.time_ns()))"\n'
        common=f'FROM {BASE}\nLABEL example.run="{name}"\nCOPY wheels /wheels\n'
        (root/'early.Dockerfile').write_text(common+'COPY requirements.txt app.py /app/\n'+install+'CMD ["python", "/app/app.py"]\n')
        (root/'late.Dockerfile').write_text(common+'COPY requirements.txt /app/\n'+install+'COPY app.py /app/\nCMD ["python", "/app/app.py"]\n')
        def requirement(version):
            (root/'requirements.txt').write_text(f'idna=={version} --hash=sha256:{WHEELS[version][1]}\n')
        def application(message):
            (root/'app.py').write_text(f'import idna\nprint({message!r},idna.__version__,idna.encode("bücher.example").decode())\n')
        def build():
            for tag,kind in zip(tags,['early','late']):docker('build','--network','none','-f',str(root/(kind+'.Dockerfile')),'-t',tag,str(root))
        def marks():
            return [docker('run','--rm','--network','none',tag,'cat','/installed').stdout for tag in tags]
        requirement('3.10');application('first');build();first=marks()
        application('second');build();second=marks()
        assert first[0]!=second[0] and first[1]==second[1]
        for tag in tags:assert docker('run','--rm','--network','none',tag).stdout.strip()=='second 3.10 xn--bcher-kva.example'
        print('editing only application code reruns the early-copy dependency install but reuses the late-copy install')
        requirement('3.11');build();third=marks()
        assert all(a!=b for a,b in zip(second,third))
        for tag in tags:assert docker('run','--rm','--network','none',tag).stdout.strip()=='second 3.11 xn--bcher-kva.example'
        print('changing the hashed requirement reruns both installs and actually changes idna from 3.10 to 3.11')
finally:docker('image','rm',*tags,check=False)

提前复制版本的 Dockerfile 在 pip 之前一起复制 requirements.txt 与 app.py。延后复制版本先复制依赖声明,执行相同安装命令,再复制应用。两者都提前加入经过验证的 wheel 目录,因为这些文件确实是安装输入。pip 之后立即写入标记,用来观察安装所在操作是否真的执行,而不是凭耗时猜测。

初始应用使用 idna 3.10 编码 bücher.example。两个镜像都调用实际安装的库,得到预期 ASCII 域名 xn--bcher-kva.example。这验证真实第三方依赖,不用 sleep 代替安装,也不把镜像构建成功直接当成应用能够导入软件包的证明。运行结果同时包含版本,能够确认到底安装了哪个版本。

只把应用消息从 first 改为 second 后,两个镜像都输出新消息和版本 3.10。提前复制的镜像得到新的安装标记,延后复制的镜像保留原标记。程序输出证明源码变更已进入两份镜像,标记比较则指出哪一份布局重复执行了不必要的依赖工作。这样可以排除“缓存太多导致应用没更新”的错误解释。

接着,把依赖声明改为 idna 3.11 和对应哈希。两份安装标记都改变,应用也都报告 3.11,并继续得到相同预期域名转换。这是必须验证的相反情况:真实依赖声明变化,在两种布局下都应让安装失效。如果某个布局忽略了这项输入,即使很快也不正确,不能只用源码修改时的成功缓存来评价它。

两个 wheel 从实验开始就一起提供,因此修改 requirement 不会同时改变 wheel 目录,第二次失效的原因保持明确。结束时清理临时镜像与文件,不清理其他构建器状态。安装时间戳只是测试观测手段,不是建议给生产可复现产物增加的随机输入;正式构建应去掉这类只为证明执行次数而加入的标记。

让缓存边界对应软件包边界

审查安装器会读取什么,再把这些输入放到安装操作之前。让无关文件留在该 COPY 范围之外,并用 .dockerignore 排除构建完全不需要的材料。被忽略的文件之后无法满足必需 COPY,因此当某个依赖明明在工作区却构建不可见时,应检查真实构建上下文,而不是不断加禁用缓存选项。

包管理器缓存挂载,可以在安装确实需要重跑时减少下载,但不能替代正确失效规则或输出校验。依赖安装结果复用与下载缓存复用,是不同层次的优化;修改任意一层后,都应运行最终应用验证。不能因为下载更少,就认定安装状态一定与新的锁文件一致。

用有代表性的构建测量效果:完全相同输入、只改源码、升级依赖。三种情况都保留正确性断言。有效的 Dockerfile 应让必要工作重新执行,让不必要工作继续复用,并提供足够证据区分两者。这样,构建性能的改进才能与实际发布内容保持一致。

延伸阅读

来源