复制输入决定后续 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
默认参数传给程序,包装脚本的进程结构决定谁收到停止信号。

ENTRYPOINT 和 CMD 定义可被运行者覆盖的启动默认值

镜像的 ENTRYPOINT 和 CMD 共同参与容器创建时的启动命令。使用 exec 形式 ENTRYPOINT 时,CMD 通常提供追加给入口的默认参数;没有入口时,CMD 可以提供可执行程序及其参数。这些是启动默认值,不是强制执行边界。有权限创建容器的运行者,可以替换入口或参数,因此不能把 ENTRYPOINT 描述成无论如何都不可改变的程序锁。 坏掉的样子 在 docker run image ... 中写到镜像名之后的参数,会替换 CMD,不是自动加到原 CMD 默认值之后。exec 形式入口仍然保留,除非显式覆盖。用户以为自己只是追加一个选项,却可能丢掉全部默认参数。因此,命令行工具需要说明哪些值可省略、哪些必须重新提供,以及自身参数解析器如何处理覆盖。 exec 形式使用 JSON 数组,不会隐式运行 shell。$DEMO 这样的字符串会原样传给程序,而不是自动展开环境变量。管道、重定向和通配符也需要真正的解释器,才会获得相应含义。shell 形式会改变参数和进程语义,所以两种形式之间的切换并不只是语法美化,不能只看命令表面似乎相同。 启动包装脚本还可能让服务藏在不转发停止信号的 shell 后面。Docker 通常向容器主进程发送停止信号,等待配置的超时,必要时强制终止。即使服务自身正确安装了 SIGTERM 处理函数,如果信号只到达另一个不会转发的进程,这个处理函数也无法发挥作用。排查优雅退出失败时,应先确认信号实际发给了谁。 快速验证 先取得固定基础镜像,再用宿主 Python 和 Docker 构建器运行脚本。实测为 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16。实验创建独立临时容器并关闭网络,等待每个服务打印就绪信息后测试停止,结束时删除自己的容器和镜像标签,不依赖启动后盲目等待固定时长来猜测服务已经准备好: ...

十月 3, 2027 · 2 分钟 · XB Field Notes
发布标签移到新产物,既有容器与另一个标签仍引用旧产物。

镜像标签可以移动,既有容器仍保留原先选择的镜像

镜像标签是与镜像引用关联的可读名字。如果仓库策略没有禁止重新指派,同一个标签之后可以标识另一个产物。v1.4 和 latest 的区别首先是命名惯例,而不是前者天然不可变。要复现部署,需要记录实际选择的产物,以及影响行为的平台和运行配置。只保存一个看起来像版本号的名字,并不足以证明当时运行的是哪些内容。 坏掉的样子 latest 不会要求仓库寻找最新上传镜像或最高语义版本,它是在引用省略标签时采用的惯例默认值。发布另一个版本,不会自动移动 latest;必须有人或自动化更新这个具体引用。如果把每次 push 都解释成自动更新 latest,就会错误理解发布流程,甚至误判为什么一个节点仍然取得旧版本。 标签的本地解析结果,也可能不同于仓库当前结果。拉取策略、本地镜像是否存在,以及编排工具的行为,共同决定何时查询仓库。因此,两台机器运行相同可读标签,仍可能使用不同产物。仅记录命令文本,会留下实际取回什么、是否保留缓存引用等不确定性;排查时应读取所选产物身份。 移动标签不会改写已经创建的容器所选择的镜像。重启该容器,会保留原配置的镜像和可写状态,不会按照标签的新目标重新创建实例。如果更新流程只是重新打标签,然后 restart 旧容器,命令可能成功,应用却仍运行旧内容。标签更新、容器替换和容器重启需要分别判断,不能混成同一个动作。 快速验证 先取得固定基础镜像,再用 Python 和 Docker 构建器运行脚本。实测环境是 Docker 29.3.1、Linux arm64。所有标签使用独特的本地名字,不向仓库推送任何内容;容器关闭网络,清理只删除实验自己的容器与标签。因此可以实际验证移动行为,又不改变已有发布引用: 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(name,kind='image'):return json.loads(docker(kind,'inspect',name).stdout)[0] name='xbcatm-tags-'+uuid.uuid4().hex[:10];tags=[name+':one',name+':two',name+':release',name+':latest'];container=name+'-old' try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory) (root/'Dockerfile').write_text(f'FROM {BASE}\nCOPY version.txt /version.txt\nCMD ["cat", "/version.txt"]\n') for tag,version in zip(tags,['one','two']): (root/'version.txt').write_text(version+'\n');docker('build','--network','none','-t',tag,str(root)) one=inspect(tags[0])['Id'];two=inspect(tags[1])['Id'];assert one!=two docker('image','tag',tags[0],tags[2]);docker('image','tag',tags[0],name) assert inspect(tags[2])['Id']==inspect(tags[3])['Id']==one docker('create','--name',container,'--network','none',tags[2]) docker('image','tag',tags[1],tags[2]) assert inspect(tags[2])['Id']==two and inspect(tags[3])['Id']==one assert inspect(container,'container')['Image']==one assert docker('start','--attach',container).stdout.strip()=='one' assert docker('run','--rm','--network','none',tags[2]).stdout.strip()=='two' assert docker('run','--rm','--network','none',tags[3]).stdout.strip()=='one' print('moving release changes newly created containers; the existing container still runs its original image') print('omitting a tag selected latest, but updating release did not update latest') docker('image','rm',tags[2]);assert inspect(tags[1])['Id']==two print('removing one tag leaves another reference to the same image intact; all operations stayed local') finally: docker('rm','-f',container,check=False);docker('image','rm',*tags,check=False) 脚本构建两个镜像,/version.txt 分别包含 one 与 two,镜像身份不同。它先让 release 指向第一份镜像,再用没有显式标签的仓库名标记同一镜像。检查确认,省略标签会得到 latest,而这两个引用最初都选择第一份镜像。这里直接验证默认标签规则,不靠名字含义猜测。 ...

八月 24, 2027 · 1 分钟 · XB Field Notes
whiteout 从合并视图隐藏文件,原层仍然保留其字节。

后续镜像层删除文件,并不会擦除先前层的原始字节

容器镜像层描述按顺序应用的文件系统变化。一层可以新增或替换文件,也可以记录某个先前路径应从合并文件系统中消失。普通容器进程看到的是最终合并视图,但这个视图不等于随镜像分发的全部字节历史。因此,仅检查运行中的容器,可能遗漏早期层仍然保留的数据,尤其容易误判删除操作的实际效果。 坏掉的样子 把凭据复制进镜像,再通过后续 RUN 删除,不会从早期不可变层中移除凭据。能够取得相关层的人,仍可能恢复原始字节。同样,把很大的构建压缩包放进某层,再在下一条指令里删除,最终路径虽然不存在,分发内容仍可能包含整个压缩包。目录看起来干净,并不能证明镜像已经没有这份数据。 镜像层是文件系统变更集,不一定是紧凑的二进制差分。替换大文件,可能把替换后的完整内容放入另一层。删除通过 whiteout 标记,在应用层时屏蔽原有路径。OCI 还规定了隐藏下层目录内容的不透明目录规则,所以不能把正确应用镜像层简化成反复普通解压 tar;删除和覆盖语义需要被专门处理。 不是每条 Dockerfile 指令都会创建文件系统层。ENV、LABEL 和 CMD 改变镜像配置,可以产生标记为 empty_layer 的历史条目。因此,Dockerfile 行数、构建历史条目数,都不能可靠地替代根文件系统层数。即使没有增加应用文件字节,这些配置变化仍然影响执行方式和产物身份,不应被当成无关信息。 快速验证 使用 Python、Docker 构建器和固定摘要的基础镜像运行脚本。实测为 Docker 29.3.1,采用 containerd 镜像存储,基础镜像内为 Linux arm64 的 Python 3.11.16。标记内容特意设为公开文本。脚本构建并保存一个临时镜像,直接读取归档成员,不把归档解压到宿主文件系统,结束时删除自己的标签: 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=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-whiteout-'+uuid.uuid4().hex[:10];tag=name+':probe';marker=b'PUBLIC_DEMONSTRATION_BYTES\n' try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'original.txt').write_bytes(marker) (root/'Dockerfile').write_text(f'FROM {BASE}\nCOPY original.txt /demo/original.txt\nRUN rm /demo/original.txt\nENV EXAMPLE_MODE=demo\nLABEL example.purpose="public fixture"\nCMD ["python", "-c", "from pathlib import Path;assert not Path(\'/demo/original.txt\').exists();print(\'absent\')"]\n') docker('build','--network','none','-t',tag,str(root)) assert docker('run','--rm','--network','none',tag).stdout.strip()=='absent' archive=root/'image.tar';docker('image','save','-o',str(archive),tag) with tarfile.open(archive) as outer: manifests=json.load(outer.extractfile('manifest.json'));assert len(manifests)==1 manifest=manifests[0];config=json.load(outer.extractfile(manifest['Config'])) assert len(manifest['Layers'])==len(config['rootfs']['diff_ids']) assert len(manifest['Layers'])==len(inspect(BASE)['RootFS']['Layers'])+2 found_data=False;found_whiteout=False for layer in manifest['Layers'][-2:]: with tarfile.open(fileobj=outer.extractfile(layer),mode='r:*') as contents: for entry in contents: path=entry.name.removeprefix('./') if path=='demo/original.txt':found_data=contents.extractfile(entry).read()==marker if path=='demo/.wh.original.txt':found_whiteout=True assert found_data and found_whiteout assert all(item.get('empty_layer') for item in config['history'][-3:]) print('the final container hides the removed file, but the older layer still contains the exact public marker') print('the deletion layer contains a whiteout; ENV, LABEL and CMD add metadata history without filesystem layers') finally:docker('image','rm',tag,check=False) Dockerfile 先把 original.txt 复制到 /demo,再在后续 RUN 删除。容器默认命令断言路径不存在,并输出 absent。这验证最终运行视图,却不能证明镜像归档里也没有对应字节,所以实验接下来单独检查保存后的表示。把两个观察拆开,是避免将“看不见”误判为“没有保存”的关键。 ...

八月 16, 2027 · 2 分钟 · 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