以观察、判断和行动三个面板呈现产物交接检查。

CI 产物需要明确的交接契约

CI 的上游任务可以成功结束,下游却拿到错误文件。产物是某个步骤生成并保存下来、交给其他任务、人员或发布流程使用的输出,仅靠名字通常不足以识别它。应把交接视为一份契约,说明生产它的运行、源码版本、负载内容、完整性证据、访问策略和可用时间窗口。这样,“下载构建结果”才成为能够核对的操作,而不是在共享目录里猜测哪个文件最新。 分清传递目的与保存期限 产物可以是二进制、测试报告、覆盖率数据,也可以是之后进入发布流程的软件包;不能断言它天然不属于发布输入。依赖缓存的目的则是加速可以重建的工作,二者不应混为一谈。选择存储和保留期之前,先确定审批、诊断和回滚需要哪些文件、需要多久。过期以后重新构建,可能生成不同字节,也需要重新验证,不能把重建视为取回原产物。 GitHub Actions 允许自定义产物保留时间,但受到仓库、组织或企业上限约束。安排流程时,要按真实配置考虑周末和延迟审批;请求一个较长保留值,并不能突破平台上限。确实需要长期留存的发布输入,应转入项目使用的持久发布存储或对象存储,并明确生命周期。短期任务存储即使今天能下载,也不应被说明成无限期的发布归档。 坏掉的样子 随手下载“最近一次成功运行”,可能拿到另一个提交、分支或工作流的文件。应明确解析目标生产任务,并核对它的源码与当前批准发布的变化之间的关系。下游还需要等待所需文件真正产生;空上传、缺失产物不能悄悄变成部署工作区里碰巧残留的旧文件。任务变绿与正确负载已经交付,是需要分别检查的事实。 完整性与来源真实性回答不同问题。校验和可以发现负载变化,但能同时替换文件和校验和的人,也能造出自洽的一对文件。来源声明还需要验证它指向的产物、签名方或工作流身份,以及项目要求的策略。二进制旁边存在一份证明文件,并不代表消费方已经执行了验证。必须看真实的验证结果,而不是只看相关配置或附件存在。 谁能访问产物,取决于平台、仓库可见性与授予的权限,不能一概等同于“能看到工作流的人”。即便如此,也只应上传交接必需的文件。凭据、环境变量全集、私有请求头和未经筛选的日志,一旦打包就可能留下额外副本。保存之前要检查成员列表及其内容,尤其当构建输入来自尚未信任的贡献时,不能只靠压缩包名称判断它是否适合交给下游。 快速验证 保存脚本为 artifacts.py 并执行 python3 artifacts.py,只需 Python 标准库。生产方在临时目录创建 ZIP,包含 app.txt 与清单;清单记录本地运行编号、示意性的源码摘要和负载摘要。消费方只读取约定成员,先比较预期生产者,再返回实际负载。这里没有解压任意归档成员,也没有连接真实 CI 平台。 from hashlib import sha256 from pathlib import Path from tempfile import TemporaryDirectory from zipfile import ZipFile import json payload = b'local build output\n' source_digest = sha256(b'example source revision\n').hexdigest() manifest = {'producer_run': 'local-run-41', 'source_sha256': source_digest, 'files': {'app.txt': sha256(payload).hexdigest()}} def produce(path, data): with ZipFile(path, 'w') as archive: archive.writestr('manifest.json', json.dumps(manifest, sort_keys=True)) archive.writestr('app.txt', data) def consume(path, expected_run): with ZipFile(path) as archive: if sorted(archive.namelist()) != ['app.txt', 'manifest.json']: raise ValueError('unexpected member set') received = json.loads(archive.read('manifest.json')) if (received['producer_run'] != expected_run or received['source_sha256'] != source_digest): raise ValueError('producer identity mismatch') data = archive.read('app.txt') if sha256(data).hexdigest() != received['files']['app.txt']: raise ValueError('payload digest mismatch') return data with TemporaryDirectory() as directory: archive = Path(directory) / 'handoff.zip' produce(archive, payload) assert consume(archive, 'local-run-41') == payload rejected = [] try: consume(archive, 'local-run-42') except ValueError as error: rejected.append(str(error)) produce(archive, payload + b'changed\n') try: consume(archive, 'local-run-41') except ValueError as error: rejected.append(str(error)) archive.unlink() try: consume(archive, 'local-run-41') except FileNotFoundError: rejected.append('artifact missing') assert rejected == ['producer identity mismatch', 'payload digest mismatch', 'artifact missing'] print(json.dumps({'valid_payload_bytes': len(payload), 'rejected': rejected, 'platform_retention_tested': False})) 正常交接返回十九字节。用 local-run-42 去领取实际来自 local-run-41 的归档,会得到生产者身份不匹配;保留旧清单、替换文件内容,会得到摘要不匹配;删除归档后继续下载,则得到文件缺失。最终结果必须列出这三种拒绝情况,任何失败都不会转而消费另一个构建。这样可以看清下游契约的失败分支确实执行了。 ...

九月 3, 2027 · 1 分钟 · XB Field Notes
把频繁编辑的应用代码放到安装之后,稳定依赖层就能继续复用。

先复制依赖输入,再复制应用代码

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

八月 17, 2027 · 2 分钟 · XB Field Notes
以观察、判断和行动三个面板呈现构建差异的定位。

可复现构建把源码与产物对应起来

可复现构建的含义是:另一方依据声明的源码、构建步骤和相关环境,能够逐字节重建指定产物。这个承诺必须说明比较哪些输出;如果交付的可执行文件不同,只比较目标文件相同是不够的。复现结果为源码与分发内容之间的联系提供证据,而源码审阅、来源证明和签名分别回答其他问题。两次相同的恶意构建仍然有害,被污染的工具链也可能稳定复现自己的异常行为。 记录真正影响字节的输入 源码版本与编译器版本只是输入的一部分。依赖、目标架构、SDK、链接器、参数、文件遍历顺序、目录路径、时间戳、区域设置和打包工具,都可能进入最终结果。应固定实际相关的输入,并说明比较边界。容器镜像有助于描述环境,却不会自动消除网络下载、宿主机挂载或变化的时间戳。一个镜像标签也不必然代表永远不变的镜像内容。 时间宏和调试信息是常见差异来源。SOURCE_DATE_EPOCH 为支持该约定的工具提供稳定时间,并不会改写所有程序对当前时间的理解。编译器的前缀映射能够规范化嵌入的源文件路径;链接器还可能独立记录目标文件位置或其他元数据。因此,编译阶段消除了差异,仍应继续检查实际链接输出,不能在中间产物相同时就停止验证。 坏掉的样子 只比较挑选出的某个段,可能掩盖真正发布文件中的不同。应明确可复现性覆盖可执行文件、调试符号、压缩归档还是带签名安装包。如果签名过程加入会变化的数据,就说明并论证可复现负载的边界,同时单独校验签名后的分发包。不能悄悄删除难以处理的字节,再宣称原始文件完全相等;比较范围本身也是需要审阅的契约。 同一台机器上的两次摘要一致,是这些输入条件下本地重复构建的证据,不等于多个独立构建方已经验证,也不能证明源码与工具正确。反过来,摘要不一致首先意味着需要调查,不能立即断定发生了攻击。应定位差异来自哪个组件,再判断是不是有输入没有纳入环境声明。没有记录 SDK、链接参数或打包规则,可能就足以让相同源码生成不同文件。 快速验证 下面实验在 arm64 macOS、Apple clang 21.0.0 及其本机链接器上执行过。保存为 repro-build.py,安装命令行开发工具后运行 python3 repro-build.py。链接参数属于 Apple 工具链,不应假设 Linux 链接器也接受。脚本创建两个临时构建目录,用三种处理方式编译同一小段 C 程序;目录名差异是刻意保留的输入扰动。 from hashlib import sha256 from pathlib import Path from tempfile import TemporaryDirectory import json import os import subprocess source = '#include <stdio.h>\nint main(void) { puts(__FILE__); puts(__DATE__ " " __TIME__); }\n' with TemporaryDirectory() as directory: base = Path(directory) hashes = {mode: {'objects': [], 'executables': []} for mode in ('unmapped', 'compiler-only', 'complete')} output = [] for name in ('one', 'two'): root = base / name root.mkdir() src = root / 'main.c' src.write_text(source) env = {'PATH': os.environ['PATH'], 'LC_ALL': 'C', 'SOURCE_DATE_EPOCH': '1700000000'} for mode in hashes: obj = root / (mode + '.o') binary = root / mode flags = [] if mode == 'unmapped' else [f'-ffile-prefix-map={root}=/src'] subprocess.run(['clang', '-g', '-O0', *flags, '-c', str(src), '-o', str(obj)], cwd=root, env=env, check=True, capture_output=True) os.utime(obj, (1700000000, 1700000000)) linker = ['-Wl,-reproducible'] if mode == 'complete': linker.append(f'-Wl,-oso_prefix,{root}/') subprocess.run(['clang', *linker, str(obj), '-o', str(binary)], cwd=root, env=env, check=True, capture_output=True) hashes[mode]['objects'].append(sha256(obj.read_bytes()).hexdigest()) hashes[mode]['executables'].append(sha256(binary.read_bytes()).hexdigest()) if mode == 'complete': output.append(subprocess.check_output([str(binary)], text=True).splitlines()) equality = {mode: {kind: pair[0] == pair[1] for kind, pair in records.items()} for mode, records in hashes.items()} assert equality == {'unmapped': {'objects': False, 'executables': False}, 'compiler-only': {'objects': True, 'executables': False}, 'complete': {'objects': True, 'executables': True}} assert output == [['/src/main.c', 'Nov 14 2023 22:13:20']] * 2 print(json.dumps({'sha256': hashes, 'equal_between_directories': equality, 'complete_program_output': output, 'same_host_only': True})) 所有处理都使用时间值 1700000000,并固定目标文件的修改时间。没有路径映射时,目标文件和可执行文件都不同。只做编译器映射后,目标文件摘要已经相等,最终可执行文件仍然不同:链接器调试映射中还保留着各自的目标文件路径。这个中间失败说明,编译阶段通过的检查不能直接为实际发布的二进制文件背书。 ...

八月 15, 2027 · 2 分钟 · XB Field Notes
同一个固定基础镜像仍可对应不同构建、配置和容器可写状态。

固定基础镜像只固定一个输入,完整发布还需要其他记录

按摘要固定容器基础镜像,可以选择确定的镜像对象,避免依赖会移动的标签。这消除了一类意外构建变化,但不会让所有派生镜像或运行实例都完全相同。应用源码、构建参数、下载依赖、目标平台、运行配置和持久数据,仍可能改变构建结果及其行为。因此,基础镜像不可变是一个明确的输入约束,不是完整发布已经可复现的证明。 坏掉的样子 两个 Dockerfile 可以使用相同 FROM 摘要,却生成不同应用。即使 Dockerfile 一样,构建参数变化或未固定的下载取得新内容,也可能改变输出。所以,仅声明基础镜像不可变,无法证明完整构建是确定性的。决定这个性质的是全部输入,以及施加在输入上的转换过程;遗漏任何真正影响结果的部分,都会留下解释缺口。 完全相同的应用镜像,在不同环境变量下运行,也可能选择不同端点、功能行为或存储。可写绑定挂载还能替换镜像内已经测试过的文件。把配置留在镜像外通常有价值,但只要配置参与定义发布行为,它仍然需要版本管理和审阅。外置不等于无须控制,更不意味着配置变化不会影响同一镜像的实际表现。 普通容器还拥有可写文件系统层。紧急编辑可以改变一个实例,而镜像和其他实例保持原样。之后从未修改的镜像替换实例,编辑就会消失。因此,不可变部署实践需要把产物替换与受控运行状态结合起来;只在 FROM 后写摘要,无法自动强制团队遵守这种操作方式,也无法阻止容器内手工修补。 快速验证 使用宿主 Python、Docker 构建器和已取得的固定基础镜像运行脚本。实测环境为 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16。构建和容器关闭网络,因此结果不能归因于远端软件包变化。实验结束删除自己的临时容器和标签,不修改任何既有服务,以便把观察限定在明确的受控输入上: from pathlib import Path from tempfile import TemporaryDirectory import json,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-pinning-'+uuid.uuid4().hex[:10];tags=[name+':one',name+':two'];container=name+'-running' try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory) (root/'Dockerfile').write_text(f'FROM {BASE}\nARG MESSAGE\nRUN printf "%s" "$MESSAGE" > /built-message\nENV MODE=default\nCOPY app.py /app.py\nCMD ["python", "/app.py"]\n') (root/'app.py').write_text('from pathlib import Path\nimport os\nprint(Path("/built-message").read_text(),os.environ["MODE"])\n') layers=inspect(BASE)['RootFS']['Layers'] for tag,message in zip(tags,['one','two']): docker('build','--network','none','--build-arg','MESSAGE='+message,'-t',tag,str(root)) assert inspect(tag)['RootFS']['Layers'][:len(layers)]==layers assert inspect(tags[0])['Id']!=inspect(tags[1])['Id'] assert docker('run','--rm','--network','none',tags[0]).stdout.strip()=='one default' assert docker('run','--rm','--network','none',tags[1]).stdout.strip()=='two default' assert docker('run','--rm','--network','none','-e','MODE=changed',tags[0]).stdout.strip()=='one changed' print('both builds use the same pinned base layers; different build arguments produce different artifacts') print('the identical application image behaves differently when runtime configuration changes') docker('run','-d','--name',container,'--network','none',tags[0],'python','-c','import time;time.sleep(60)') docker('exec',container,'python','-c','from pathlib import Path;Path("/built-message").write_text("edited")') assert docker('exec',container,'python','/app.py').stdout.strip()=='edited default' assert docker('run','--rm','--network','none',tags[0]).stdout.strip()=='one default' failed=docker('run','--rm','--network','none','--read-only',tags[0],'python','-c','from pathlib import Path;Path("/built-message").write_text("edited")',check=False) assert failed.returncode!=0 and 'Read-only file system' in failed.stderr print('a writable container can drift while its image stays unchanged; a read-only root rejects that particular write') finally: docker('rm','-f',container,check=False);docker('image','rm',*tags,check=False) 两次构建使用相同基础引用和应用源码,选择的构建差异只有 MESSAGE;Dockerfile 把它写入 /built-message。第一份镜像输出 one default,第二份输出 two default。完整镜像身份不同,但检查确认它们有序的基础文件系统层前缀完全一致。这里同时对照了公共输入和最终行为,避免把基础相同误当成全部相同。 ...

四月 30, 2027 · 1 分钟 · XB Field Notes
两个应用共享基础层与库,运行时编辑分别进入各自可写层。

相关镜像共享文件层,运行中的容器各自保存修改

镜像用有序的不可变层变更集描述文件系统,并带有默认命令等配置。相关镜像可以引用相同基础层,同时加入不同应用文件。容器运行时,再在镜像之上拥有自己的可写层。区分镜像、共享文件层和容器可写状态,才能解释为什么镜像复用能够节省工作,却不会让某个容器里的日常编辑自动出现在所有邻居中。 坏掉的样子 直接把几个相关镜像显示的大小相加,可能高估它们合计占用的本地存储,因为共享内容可能被重复计数。传输大小、解包后的文件大小,以及快照存储实际分配的空间,也不是同一个指标。共享层确实支持复用,但不能在没有测量具体存储实现和统计口径时,声称节省了一个精确的磁盘容量。 另一个常见错误,是把容器里的修改视为镜像修改。在一个普通容器中安装软件包或修改文件,改变的是该容器的可写文件系统状态。从原镜像新建容器,不会自动继承这次修复。于是,手工修好运行中的服务后再替换实例,原故障可能重新出现,因为用于替换的产物从未包含那次修复。 共享层也不是通用的应用兼容性边界。两个应用即使基础层相同,仍可能使用不同配置、挂载数据、架构,或者在后续层加入不同库版本。应比较有序文件系统标识,以及被验证的实际行为。仅仅看到可读标签相同,无法确定运行的是哪些字节,也无法解释运行设置是否一致。 快速验证 先取得固定摘要的 Python 基础镜像,再使用 Python 和 Docker 构建器运行脚本。实测环境为 Docker 29.3.1、Linux arm64,镜像内 Python 为 3.11.16。实验创建两个相关镜像和两个临时容器,不使用容器网络,结束时只删除自身命名的容器和标签,不触碰其他实验或服务: from pathlib import Path from tempfile import TemporaryDirectory import json,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-layers-'+uuid.uuid4().hex[:10];tags=[name+':left',name+':right'];containers=[name+'-one',name+'-two'] try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'shared.py').write_text('value=10\n# '+name+'\n') (root/'Dockerfile').write_text(f'FROM {BASE}\nCOPY shared.py /app/shared.py\nCOPY main.py /app/main.py\nCMD ["python", "/app/main.py"]\n') for tag,increment in zip(tags,[1,2]): (root/'main.py').write_text(f'from shared import value\nprint(value+{increment})\n') docker('build','--network','none','-t',tag,str(root)) left=inspect(tags[0])['RootFS']['Layers'];right=inspect(tags[1])['RootFS']['Layers'] assert left[:-1]==right[:-1] and left[-1]!=right[-1] assert docker('run','--rm','--network','none',tags[0]).stdout.strip()=='11' assert docker('run','--rm','--network','none',tags[1]).stdout.strip()=='12' print('two images share',len(left)-1,'filesystem layer identities and differ in the final application layer') for container in containers: docker('run','-d','--name',container,'--network','none',tags[0],'python','-c','import time;time.sleep(60)') docker('exec',containers[0],'python','-c',"from pathlib import Path;Path('/app/shared.py').write_text('value=100\\n')") assert docker('exec',containers[0],'python','/app/main.py').stdout.strip()=='101' assert docker('exec',containers[1],'python','/app/main.py').stdout.strip()=='11' assert '/app/shared.py' in docker('diff',containers[0]).stdout assert inspect(tags[0])['RootFS']['Layers']==left assert docker('run','--rm','--network','none',tags[0]).stdout.strip()=='11' print('a write in one container changes its writable layer while the sibling container and original image retain the old code') finally: docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False) 两次构建复制相同的 shared.py,其中定义数值十。最后的 main.py 不同:一个加一,另一个加二,所以程序分别输出 11 与 12。镜像检查显示,在本次基础镜像和构建结构下,前五个文件系统层标识相同,最后的应用层标识不同。这同时提供文件身份和程序结果两类证据。 ...

三月 22, 2027 · 1 分钟 · XB Field Notes
最终阶段只含选定程序,能否启动仍取决于运行依赖。

多阶段构建需要经过测试的运行边界

多阶段 Dockerfile 可以在一个阶段编译软件,再把选定输出复制到另一个运行阶段。最终阶段继承自己的基础镜像和明确复制的文件,而不会自动继承构建器里的编译器、源码目录和软件包缓存。这建立了有用的边界,但作者仍必须识别程序真正执行时需要的一切。文件复制成功,只能证明文件已进入产物,不能证明运行环境已经完整。 坏掉的样子 即使 ELF 解释器和共享库缺失,COPY 仍可成功把动态链接程序放进 scratch 镜像。构建可能正常结束,因为复制不会执行程序,也不会验证全部运行依赖图。随后启动却可能报告 no such file or directory,尽管配置路径上的可执行文件确实存在。此时需要检查解释器和库,而不是只反复确认程序文件是否被复制。 脚本需要解释器,应用可能需要证书根或时区数据,还有程序只在特定请求路径上加载插件或资源。简单启动测试可能漏掉这些较晚发生的需求。选择小型运行基础镜像,只有在它满足应用真实契约时才有价值,包括受支持架构和必要文件路径。不能因为某个镜像最小,就默认它适合所有应用。 早期阶段不属于最终镜像的继承链,也不意味着它被安全擦除。构建缓存和明确导出的中间镜像,仍可能保留其内容。敏感构建输入需要正确的密钥传递方式,复制输出也必须检查是否意外包含不该分发的材料。多阶段结构控制最终文件系统组成,却不会自动保证整个构建过程的保密性。 快速验证 使用 Python、Docker、固定的 Python 基础镜像,以及供构建阶段安装 Debian 软件包的网络运行脚本。运行容器关闭网络。实测是 Docker 29.3.1、Linux arm64,GCC 为 Debian 12.2.0-14+deb12u1 包提供的 12.2.0。软件仓库是实时仓库,因此实验验证运行组成,不承诺未来永远取得完全相同的编译器字节: from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,tarfile,uuid BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=360) if check:assert result.returncode==0,result.stderr return result def inspect(image):return json.loads(docker('image','inspect',image).stdout)[0] name='xbcatm-stages-'+uuid.uuid4().hex[:10];tags=[name+':builder',name+':good',name+':bad'];container=name+'-inspect' try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory) (root/'square.c').write_text('#include <stdio.h>\n#include <stdlib.h>\n#include <errno.h>\nint main(int argc,char **argv){\n char *end;long value;\n if(argc!=2){fprintf(stderr,"one integer required\\n");return 2;}\n errno=0;value=strtol(argv[1],&end,10);\n if(errno||end==argv[1]||*end||value<0||value>100){fprintf(stderr,"range 0..100\\n");return 2;}\n printf("%ld\\n",value*value);return 0;\n}\n') (root/'Dockerfile').write_text(f'''FROM {BASE} AS builder RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev && rm -rf /var/lib/apt/lists/* WORKDIR /source COPY square.c . RUN gcc -O2 -Wall -Wextra -Werror -static -o /square-static square.c && gcc -O2 -Wall -Wextra -Werror -o /square-dynamic square.c FROM scratch AS good COPY --from=builder /square-static /square USER 65532:65532 ENTRYPOINT ["/square"] FROM scratch AS bad COPY --from=builder /square-dynamic /square USER 65532:65532 ENTRYPOINT ["/square"] ''') for tag,target in zip(tags,['builder','good','bad']):docker('build','--target',target,'-t',tag,str(root)) print('compiler:',docker('run','--rm','--network','none',tags[0],'gcc','--version').stdout.splitlines()[0]) for argument,expected in [('0','0'),('7','49'),('100','10000')]: assert docker('run','--rm','--network','none',tags[1],argument).stdout.strip()==expected for argument in ['7x','-1','101','','999999999999999999999999999']: invalid=docker('run','--rm','--network','none',tags[1],argument,check=False) assert invalid.returncode==2 and 'range 0..100' in invalid.stderr invalid=docker('run','--rm','--network','none',tags[1],check=False) assert invalid.returncode==2 and 'one integer required' in invalid.stderr invalid=docker('run','--rm','--network','none',tags[1],'7','8',check=False) assert invalid.returncode==2 and 'one integer required' in invalid.stderr good=inspect(tags[1]);builder=inspect(tags[0]);assert good['Size']<builder['Size'] and len(good['RootFS']['Layers'])==1 assert good['Config']['User']=='65532:65532' docker('create','--name',container,tags[1],'7');archive=root/'rootfs.tar';docker('export','-o',str(archive),container) with tarfile.open(archive) as contents: names={entry.name.removeprefix('./') for entry in contents} assert 'square' in names and 'usr/bin/gcc' not in names and 'source/square.c' not in names bad=docker('run','--rm','--network','none',tags[2],'7',check=False) assert bad.returncode!=0 and 'no such file or directory' in bad.stderr print('the static final image runs valid input, rejects invalid input, and contains one application layer without compiler or source') print('recorded image sizes:',builder['Size'],'builder bytes;',good['Size'],'final bytes') print('the dynamic scratch image builds successfully but cannot start because its interpreter/runtime was not copied') finally: docker('rm','-f',container,check=False);docker('image','rm',*tags,check=False) 构建阶段把同一个小型 C 程序分别静态链接和动态链接。程序接受一个零到一百范围内的整数,并输出平方。编译开启警告且把警告视为错误。两个有名字的最终目标各自只把对应可执行文件复制到 scratch,并选择数值用户和组 65532。这样,构建工具与运行产物的交接在 Dockerfile 中清楚可见。 ...

九月 25, 2026 · 2 分钟 · XB Field Notes