多阶段 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 中清楚可见。

静态最终镜像对输入零、七、一百分别输出 0、49、10000。尾随文本、负数、超范围值、空参数,以及超出 strtol 可表示范围的整数,都返回状态二;缺少参数或参数过多也返回状态二。这些检查直接在最终镜像中覆盖合法边界和拒绝路径,而不只测试构建阶段能够运行程序。

镜像检查确认最终产物只有一个文件系统层,并配置了预期的非 root 数值身份。导出一个已创建容器后,可以看到 /square,却没有构建阶段路径下的编译器和源码文件。实验直接读取归档,不把它解压覆盖宿主文件系统。这独立验证最终组成,而不是因为 Dockerfile 看起来简洁,就假定没有多余文件。

既有记录中 Docker 报告构建镜像为 109,490,331 字节,静态最终镜像为 312,508 字节。这些是引擎的镜像大小字段,不是独占物理磁盘分配量,也不是网络传输耗时测量。真正有意义的不变量,是最终镜像排除了工具链,并在相同统计口径下更小。不同存储实现或压缩方式下,不应直接照搬绝对数字。

动态最终目标也能成功构建,但启动时因为未复制解释器及运行库而出现缺失文件错误。这是关键反例,失败是预期且经过断言的结果。它推翻了“最终 COPY 成功后,构建器就会自动提示补齐运行库”的说法。构建系统无法仅根据文件复制,保证应用执行过程中需要的所有资源都已满足。

验证最后阶段真正执行的内容

应选择适合应用的运行镜像。静态链接适合这里的小计算程序,但不证明所有静态服务都不需要外部文件或运行时加载组件。动态应用需要兼容的运行库或合适的运行基础镜像,并在最终产物内执行有代表性的功能。不要只在工具齐全的构建阶段测试,然后把结果直接转移到环境不同的最终阶段。

使用命名阶段和窄范围 COPY,让审阅者清楚看到交接内容。避免复制整个构建目录,悄悄带入测试、凭据、缓存或无用工具。工作目录、可写路径、用户身份和启动参数,都应按真实运行需求设置。最小化文件与维持可诊断、可运行的服务契约,需要一起判断。

依赖或编译器更新后,应重新测试最终阶段,包括错误输入,以及会额外加载资源的不常见路径。把所选产物身份与结果一起保存。镜像变小只有在继续满足服务契约时才是有效结果;构建成功本身无法证明这个契约,更不能替代最终镜像的启动和功能验证。

延伸阅读

来源

  • Docker multi-stage builds,命名阶段、目标选择与选择性复制产物,已核验 2026-09-12。
  • Dockerfile reference,COPY –from、scratch、USER 与 exec 形式入口,已核验 2026-09-12。
  • execve(2),可执行文件解释器缺失及执行错误,已核验 2026-09-12。