容器 13:Dockerfile 怎样变成可运行制品
构建上下文与阶段边界
COPY 只能按构建上下文和忽略规则读取输入;在仓库根构建时不检查 .dockerignore,可能无意把实验日志或其他任务的文件送给构建器。这里的独立源码包提供 probe_app.py、.dockerignore 及三种 Dockerfile:单阶段 Dockerfile.single 将脚本复制到 Python 基础镜像;Dockerfile.multi 在 check 阶段运行 py_compile,最终阶段只复制脚本。两者仍依赖相同 Python runtime;多阶段在这个例子里不应被宣称必然更小或更快。BuildKit 对步骤形成依赖图,跳过与目标无关的步骤要结合构建输出与目标阶段判断。
构建上下文不是当前 shell 目录的同义词
执行 docker build -f examples/containers/Dockerfile.single examples/containers 与执行 docker build -f examples/containers/Dockerfile.single .,即使 Dockerfile 路径相同,传入构建器的 context 根也不同。COPY probe_app.py /app/probe_app.py 的源相对上下文查找,不是相对 Dockerfile 所在目录或当前 shell 主观认为的“项目根”。上下文中被 .dockerignore 排除的文件,也不能通过正常 COPY 重新找回来;另一方面,忽略规则若遗漏其他任务的输出,扩大 context 可能把不应发送的日志、密钥或生成目录交给构建器。实验应在本篇源码归档解压后的独立目录启动,记录 context 绝对路径与 .dockerignore 内容,不把整个博客仓库作为 context 传给 daemon。
要证明“文件未被送进最终镜像”,不能只看最末层文件列表:如果它曾出现在前一层,之后又被删除,历史 blob 仍可能含旧字节。正确方法是从构建 context、每个阶段实际 COPY 的输入、最终 manifest 的 layer 与 config 依次核对。对独立下载的附件先验证归档 SHA,再按自编探针源码固定 Dockerfile;如果临时删除 probe_app.py,正例是构建在输入读取或 COPY 解析阶段失败,退出非零,且不会产出可运行的新目标 image。具体错误文案依 BuildKit/daemon 版本而变,本文没有实测这条失败日志,不拿命令模板写成“曾出现某一行报错”。
Dockerfile 的两个方案实际差在哪里
单阶段从 PYTHON_IMAGE 指定的 Python 基础镜像开始,设置 WORKDIR /app,复制应用脚本、设置 USER 65532:65532、声明端口、指定 JSON 形式 CMD。多阶段先在名为 check 的阶段复制同一脚本并运行 python3 -m py_compile,再从同一个基础镜像开一个最终阶段,只从 check 复制脚本。检查阶段生成的缓存文件和构建工具不会自动进入最终阶段;这里基础镜像也包含 Python,所以多阶段不是“去掉 Python 就能运行”的技巧。若两者最终都只含同一个脚本和相同基础镜像,它们的最终文件树可能接近,但构建历史、层元数据、基础镜像解析时刻仍可能导致 digest 不同,不能在未构建时断言镜像字节完全一致。
USER 控制最终镜像默认进程 UID/GID,不能由它推断 Docker daemon 也以非特权用户运行,更不能保证挂载进去的所有目录都可写。EXPOSE 18080 表明制品声明的端口信息,既不启动 Python 服务,也不自动向宿主发布端口;实际监听地址取决于应用参数和运行中的 netns,宿主映射还需单独配置。CMD 只有运行时真正创建进程才会被执行,docker image inspect 能读到这个默认配置,不代表 /ready 已返回 200。13 的交付要沿制品 digest → 最终 config → 实际宿主 PID → 探针 HTTP 请求逐层检查,不能只拍一张构建完成截图。
BuildKit 的依赖图意味着什么
官方 BuildKit 文档说明会解析 Dockerfile 为构建图、按依赖关系决定需要执行的阶段,并利用缓存与并行能力;这一点不等于任何修改都只执行一条指令。构建 --target check 时,最终阶段本来就不必运行;构建最终目标时,它依赖 check 阶段中要复制的脚本,故 check 阶段的 RUN py_compile 在当前这份 Dockerfile 中是依赖链的一部分。如果另写一个完全不被最终阶段引用的 lint 阶段,BuildKit 可能跳过它;那种跳过不能推出“本例已经执行了 py_compile”。要验证,需要保存选择的 target、实际构建日志及步骤标识,并检查输出制品不是历史缓存里的旧 tag。尚无 Docker daemon 的本云端不能提供这些步骤日志。
缓存也是构建依赖的一部分:某条 RUN 复用旧结果并不意味着它在这次构建进程里重新运行。比较单阶段与多阶段时先记录是否启用了缓存、基础镜像按哪个 digest 解析,随后分别核对最终制品的层、配置与服务行为;不要用“build 耗时短”推断没有执行编译检查。14 将单独对比 cache mount、密钥与可重复性,13 只要求区分最终制品和中间步骤的证据。若要证明最终镜像没有编译阶段的多余生成物,可下载最终层按 tar 字节检查文件清单,同时检查最终运行时根路径;单靠 Dockerfile 的意图不等于已经部署的是这份新构建。
基础镜像的解析必须记录时刻
ARG PYTHON_IMAGE=python:3.12-slim 允许在构建时替换基础引用。这是一个 tag 默认值,并非不可变内容地址;今天和未来构建两次即使 Dockerfile 和 probe_app.py 不变,也可能因为 tag 指向更新而使用不同的 Python 版本或基础 layer。专用 VM 中要先获取可核验的平台特定基础 digest,传入两个构建命令,并保存每次实际选中的 base、平台和 builder 版本;只在文章里写“固定 Python 3.12”仍不足以重现底层字节。如果 build 环境经过公司代理或镜像镜像站,记录哪一个 registry 实际提供内容,不能拿客户端的 python --version 替代 daemon 中解析的基础 image。
成功构建后的最小正常实验是分别运行两个构建结果,在同样的宿主 VM、端口映射和就绪时序条件下,请求 /identity、/health、/ready,记录容器 ID、宿主 PID、最终 manifest digest、请求退出码和响应内容。/ready 可以因探针自己的延迟设置暂时返回 503,这不能自动归因于 Dockerfile 失败;和 00 的普通进程基线对照时还需注意时刻与端口冲突。运行结束后只删除本次实验所创建的容器与标签,验证端口释放和状态目录清理;共享主机上的其他容器绝不触碰。
正例要在专用 Linux VM 中先固定 python:3.12-slim 的解析 digest,给两份 Dockerfile 传同一个 PYTHON_IMAGE,分别 build、inspect 输出制品的平台和配置、run 后请求同一个 /identity//ready。边界例将 COPY 的源文件从上下文中移走,预期构建阶段立即报缺失,不应被记录成应用启动失败。入口列有取证、清理和版本冻结事项。本云端没有 Docker/BuildKit,两个 Dockerfile 仅作 STATIC_CHECKED 结构审读,构建、运行与错误输出全部 NOT_RUN。
已有附件的独立性仅限源码与命令:最小归档提供 Dockerfile 和 probe-app,RUN 列出专用 VM 的补跑前提,CHECKSUMS.sha256 校验包本身;它没有提供已构建镜像的 manifest digest 或一张“运行成功”的截图。当前的 STATIC_CHECKED 是静态读配置、验证 Python 源与归档哈希,不能用它填充 docker build 的退出码或 pod/容器 ID。若 VM 无权访问固定基础 digest,记录具体 registry/权限错误和补跑入口,其他仍可做的 Dockerfile 机制研究不因此中断。
一次缺文件失败如何避免误判
在 VM 中从归档解压两个仅供本次构建的目录 A、B,A 完整保留 probe_app.py,B 在私有 .dockerignore 中增加对此脚本的排除规则。两次都以目录本身作 context,保存传给 docker build 的 -f 路径、builder 版本、目标平台和最终退出码。若 B 在 COPY 之前因缓存/传输或镜像拉取失败,不能宣称“忽略规则导致缺文件”;先使基础 digest 与网络条件一致,再比较发生失败的具体步骤。若 B 在 COPY 源检索时报错,检查 A 是否在相同前提下成功,才能把差异限缩到 context 内容。恢复测试文件时只改回 B 的私有目录,不去修改当前博客工作区或并行任务的 .dockerignore。
还有一种更隐蔽的失败:COPY 在构建时成功,但运行时 CMD ["python3", "/app/probe_app.py", ...] 因 shebang、解释器路径或镜像平台问题执行失败。此时 build 阶段的源文件确实存在,排障对象是最终镜像内根文件系统和运行时入口,不是回头修改 .dockerignore。多阶段也可能只在 check 阶段做了语法编译,但未执行一次真正的 HTTP 请求;py_compile 返回 0 证明文件可被该 Python 版本编译,不证明主程序绑定端口、写状态目录或接收 TERM 的行为正确。将各阶段“成功”的含义写清楚,比把所有绿色构建记录都解释为上线成功有用。
对照多阶段是否产生不同制品
在专用 VM 构建两个结果之后,用固定的 manifest digest 而非可变 tag 分别 inspect image config 与 layers。检查最终镜像的工作目录、USER、CMD、Env、暴露端口以及 /app/probe_app.py 的实际字节;两份最终配置若相同,仍需比较 layer 顺序与 blob digest 之后才能说两份制品字节一致。即便字节完全一致,运行探针可能因端口映射、启动时就绪延迟或卷挂载不同而产生不一样的请求结果,所以运行对照还需统一运行参数。反过来,如果最终镜像体积不同,先查 base digest、构建缓存、层元数据和被 COPY 的字节,不能机械归因为“多阶段就是更轻”。
最终 image 的 digest、运行时 bundle 和进程 PID 属于不同时刻的对象。Dockerfile 不描述这个进程何时被宿主以哪个 PID 启动,镜像在 registry 上的名字也可能在 build 后被重指向。实际链路须从本次构建输出的 digest 出发,确认 push/pull 仍按相同 digest 选择了正确平台,再到 VM 上的容器 ID 和探针请求。13 的 VM 尚未到位,后两段都在状态表里明确保留 NOT_RUN,不能借其他博文已有的 Docker 命令例子填入本系列的运行证据。
镜像不能正常运行时,先把 build 日志中的报错位置和 runtime 的退出事件分列:若 build 从未产出新 digest,却启动了同名旧 tag 的容器,会误以为“新代码运行失败”;若 build 产出 digest 但容器 ID 对应另一平台 manifest,排查应该回到引用选择而非修改 Python 探针。记录构建输入的文件哈希、选择的平台、最终 digest 与请求结果,一次比对只变一个参数,才能避免跨阶段因果跳跃。当前源码附件给出了固定入口,缺的是这些真实节点侧输出,而不是再加一套不相关的构建工具。
所有测试只操作本次创建的标签与容器;旧标签和其他工作区均不作为清理对象。
| 现象 | 核查对象 | 不等价的阶段 |
|---|---|---|
| 构建失败 | 上下文与 COPY、BuildKit 步骤 | 不是 runtime 失败 |
| 镜像成功但服务不通 | 镜像 digest、进程与监听地址 | build 退出 0 不等于 Ready |
| 多阶段大小不变 | 最终基础镜像与 COPY 字节 | 阶段数不保证体积变小 |
练习
- 先预测在自己的构建目录里把
probe_app.py加进.dockerignore会在哪里失败;在专用 VM 实测并记录确切 BuildKit 错误行,随后恢复测试目录。 - 先预测两个 Dockerfile 的最终 config/层、可运行行为及字节大小是否相同,锁定基础 digest 后构建并比较,而非仅凭阶段数判定。
系列总目录:从进程隔离到运行时与编排。




