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 之后立即写入标记,用来观察安装所在操作是否真的执行,而不是凭耗时猜测。
...