多阶段 Dockerfile 可以在一个阶段编译软件,再把选定输出复制到另一个运行阶段。最终阶段继承自己的基础镜像和明确复制的文件,而不会自动继承构建器里的编译器、源码目录和软件包缓存。这建立了有用的边界,但作者仍必须识别程序真正执行时需要的一切。文件复制成功,只能证明文件已进入产物,不能证明运行环境已经完整。
坏掉的样子 即使 ELF 解释器和共享库缺失,COPY 仍可成功把动态链接程序放进 scratch 镜像。构建可能正常结束,因为复制不会执行程序,也不会验证全部运行依赖图。随后启动却可能报告 no such file or directory,尽管配置路径上的可执行文件确实存在。此时需要检查解释器和库,而不是只反复确认程序文件是否被复制。
脚本需要解释器,应用可能需要证书根或时区数据,还有程序只在特定请求路径上加载插件或资源。简单启动测试可能漏掉这些较晚发生的需求。选择小型运行基础镜像,只有在它满足应用真实契约时才有价值,包括受支持架构和必要文件路径。不能因为某个镜像最小,就默认它适合所有应用。
早期阶段不属于最终镜像的继承链,也不意味着它被安全擦除。构建缓存和明确导出的中间镜像,仍可能保留其内容。敏感构建输入需要正确的密钥传递方式,复制输出也必须检查是否意外包含不该分发的材料。多阶段结构控制最终文件系统组成,却不会自动保证整个构建过程的保密性。
快速验证 历史 Docker 示例保持原样;当前没有 Docker 运行环境,因此本次没有重跑。
使用 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 中清楚可见。
...