“docker run 成功了”最多说明某条客户端请求获得了响应,不保证 probe-app 能够接收真实业务请求。先把13 的镜像制品、18 的 content/task和17 的 create/start沿时间摆成一条链,再讨论不同故障落在哪一层。

一次请求的可能路径

用户端请求 daemon 创建或启动容器;daemon 对镜像引用做解析或拉取,准备快照与运行配置;下游运行时创建对应 Linux 进程并执行入口。是否通过 containerd、shim、哪一种 OCI runtime 由本机版本与配置决定,不能给出未经版本验证的唯一函数调用栈。进程 exec 成功之后,应用还可能解析参数失败、只监听 loopback 或尚未就绪。把“客户端返回”、“daemon 对象创建”、“宿主进程执行”、“HTTP /ready=200”各自作为检查点,才能说清哪个阶段失败。

正常案例要求同一次构建的 image digest 贯穿 docker inspect、runtime ID、宿主 PID、namespace inode 和请求日志,按时间排序保存不同组件的事件;失败案例把探针入口命令改为不存在路径,只改自建镜像或容器,预期镜像可拉取而 exec 失败,不能描述成下载错误。附件列出跟踪字段与清理,源码包沿用可信探针,避免把完全不同应用的日志拼成一条链。

当前云端没有 Docker/containerd,真实事件链及错误案例均 NOT_RUN;切勿把 00 中普通进程的 /identity 冒充 Docker 进程证据。下面的实现路径以 Moby v28.4.0 源码为阅读定点,不等于已经在本机验证该版本。官方 CLI 页面描述的是用户可见操作,具体内部函数和失败清理需要随 daemon 实际版本再核一次。

把 run 拆成可以区分的事件

docker run 是客户端命令,并不是 Linux 系统调用。先把 Docker 的 create 和 start 单独看:创建操作负责解析镜像和用户配置并建立容器对象;启动操作在已有对象上准备挂载、网络与进程。镜像不存在时,CLI 可能先尝试拉取;“在 daemon 内部某个地方必然自动拉取”的说法不适用于所有 API 调用。docker create 返回的 ID 是一个可用于继续查询的容器身份,不是宿主 PID。要证明程序已经执行,至少还要看 docker start 的结果、运行状态、宿主进程和应用日志。docker exec 又是另一个命令:它试图在已经运行的容器中执行额外进程,不等同于 docker run 启动容器原本的入口,也不能用 docker exec 成功替代主进程健康证明。

Moby v28.4.0 的 daemon/create.go 中,ContainerCreate 进入 containerCreate,在请求配置非空检查和镜像解析之后返回 CreateResponse{ID: ctr.ID, Warnings: warnings};这段路径的返回不包含应用监听结果。在同一 tag 的 daemon/start.go 中,ContainerStart 把请求交给 containerStart;后者处理已启动状态检查与挂载、网络,然后构造运行时规格,调用 libcontainerd.ReplaceContainer、ctr.NewTask、initializeCreatedTask 和 tsk.Start。按这份源码,Docker 容器元数据对象、containerd 对象和 task 的创建时刻不同;下游返回一个 task,并不证明探针完成了 /ready。在 api/server/router/container/container_routes.go 的 postContainersStart 路由中,后端启动成功后才向请求方写 204 No Content。这个 HTTP 码说明启动接口已返回,不是探针 HTTP 服务自己返回了 204。实现会在部分错误路径执行清理和复位,但不能据此想当然地断言所有失败都不会留下对象;清理结果应以实验 ID 逐个核查。

下游的 OCI create、start 是另一层契约。上一篇拆过 runc 的状态语义;此处要避免把两种“create”混为一谈。Docker create 给出持久的容器配置身份,不必同时完成 OCI runtime 的 create;Moby 在启动路径中才接入 task 创建。OCI 约束的是运行时对 bundle 和容器生命周期的行为,并不规定 Docker CLI 如何处理镜像下载、名称冲突、端口发布或业务就绪。因而“Docker create 成功 ⇒ runc create 成功”没有一般性保证,“runc start 成功 ⇒ HTTP 200”也不成立。若 VM 安装的不是本篇源码定点,务必以 VM 的 API 版本、daemon 配置和实际实现解释观测值,不把源码行号当作万能时间线。

同一张证据表为什么要有四个身份

镜像侧先保存构建输出或拉取后的 digest。标签可重指,镜像 ID 与 registry 中 manifest digest 也不一定是同一种对象:镜像 ID 通常涉及 config,仓库摘要指向拉取/推送的 manifest 或 index。docker image inspect 的 Id 和 RepoDigests 应原样记下;RepoDigests 为空时不能自行编造一个远端 digest。接下来记容器完整 ID、daemon 连接端点和创建/启动事件。多 daemon 或 Docker context 环境下,只凭短 ID 和一个终端截屏无法证明查的是同一主机。再记主进程的宿主 PID、容器内 PID 及 /proc/<pid>/ns/* inode;名称空间句柄并不携带 registry digest,需要用容器 ID 把两侧拼起来。最后用 /identity、/health、/ready 响应与访问时间说明处理了哪次请求;宿主 PID 变化可能表示重启,不能把两次采样当作同一次启动的静态属性。

真正排查时,每条记录还要有采集者、时钟口径和命令退出码。docker events --filter container=<完整ID> 的事件顺序能帮助排除“先发生 HTTP 请求,后创建容器”的错误拼接,但事件流不是分布式精确时钟,也不是 Linux execve 的系统调用跟踪;捕获窗口太晚可能丢失历史。daemon 日志、CLI stdout/stderr、容器 stdout/stderr 各管不同层,docker logs 没有内容不能反推出 daemon 没试过启动,也不能证实 execve 失败。要判定具体调用失败,需要与所选版本的错误消息、task 事件和宿主进程状态交叉核验。

对于 probe_app.py,/health 返回 200 只表明 HTTP handler 还能答复;/ready 根据进程内启动时钟和 --ready-delay 判断是否达到本探针的就绪条件,不含数据库或外部服务检查。若它绑定的是容器内部 127.0.0.1,从宿主的发布端口连接可能失败,而在容器内访问可成功;反之容器运行正常不意味着宿主端口已发布或防火墙准许到达。主进程 stdout 上打印的 listen 与一次端到端 HTTP 返回必须分别保存:前者证明代码走到了监听处,后者证明这次网络路径实际通到应用。

三种失败不要合并成一条“启动失败”

第一种是在镜像准备前或期间失败。写错本地标签且禁止拉取时,可能连镜像配置都取不到;registry 凭据错误发生在远端请求环节。这里不应虚构一个 OCI runtime 状态。第二种是创建成功而启动失败:例如将可信探针镜像的进程入口覆盖为 /this-file-does-not-exist,可以在实验 VM 使用 docker create ... image /this-file-does-not-exist 保存容器 ID,随后 docker start 观察下游报错。正常镜像的来源与失败镜像的来源要相同,只变入口命令;如果错误早在 create 配置校验阶段被拒绝,就记录实际发生点,不按脚本期待强行挪到 start。失败后 docker inspect 的状态、对象存在性、宿主 PID 与清理结果都要保存。第三种是进程已开始但应用不可用:用原探针加 --ready-delay 10 可以构造有界的 503 窗口。此时进程仍能答 /health,而 /ready 的 503 是探针定义的结果;入口路径错误甚至不能建立同一个 HTTP 监听,不应只用“都不是 200”笼统归类。

一次 run -d 正常案例应先确定构建制品,再给容器起唯一名称,映射只在实验 VM 的 loopback 端口,对同一个 ID 获取创建/启动事件、inspect 与 daemon 侧错误、宿主 /proc 和探针请求。删除前记录一次退出状态,删除后再用同名唯一标识检查残留,清理只作用于这次实验对象。日志时间戳与 HTTP 时间不要误认为同一时钟;错误返回后不要只保存最后一条 docker rm -f 的成功输出。可复核命令与记录要求包含正常与入口不存在两套入口,源码包则提供 Python 服务及构建定义,真正的镜像 digest 必须在 VM 上生成。

当一个值缺失,判定从最靠近该值的层开始。例如有 image digest 但没有 Docker 容器 ID,先核对拉取和 create 的状态,不应立即调查 HTTP 负载均衡;有容器 ID 而 State.Pid=0,区分还未调用 start、已经正常退出和启动报错,不把 0 当 namespace inode;PID 存在但 503,则先检查监听地址、探针参数和请求路径,之后才排查路由。这个分层判断不是按组件名称给故障分派责任,而是按“上一步已经获得什么证据,下一步第一次缺什么证据”确定要采集的对象。

源码能说到哪里,观测才能说到哪里

daemon/create.go 的镜像解析不能用来证明启动时镜像层已经解包并可被进程读取;daemon/start.go 的 ReplaceContainer、NewTask 等名字也不能代替 containerd v2.1.0 那一篇对 content、snapshot 与 task 的独立检查。若要知道某次失败在挂载路径,先找 start 返回的错误和其前后的事件,再查挂载配置是否有效;若要知道是 OCI bundle 问题,检查所选 runtime 输出和 task 创建失败路径。把 CLI 的 docker run 直接翻译成一串固定的内部函数调用很诱人,却会在 daemon 切换镜像存储后端、版本升级或者使用别的 runtime 时误导读者。本文选用 v28.4.0 只是让“这里讨论的实现路径”有一个确切对象;实验报告应按 VM 实际版本重新记源码入口,版本不同就标为未对齐,不覆盖原有研究记录。

时间线也不能只截两张状态图。docker inspect 返回的是查询那一瞬间的快照,进程可能在两次查询间启动并退出。如果命令行捕获到完整 ID,而随后 State.Running=false、State.ExitCode 非零,需要先查 StartedAt、FinishedAt、应用 stderr 和 docker events 的 die 事件,判断进程是从未启动还是启动后失败;docker logs 读取的输出可能由日志驱动管理,不应把缺少业务日志单独当作没有执行系统调用的证据。即便 docker wait 最后返回了应用退出码,这个数字也只描述容器主进程的退出结果,不会复写早先的镜像下载、Docker API 错误或者所有并发请求结果。使用 run --rm 会使失败对象在退出后消失;诊断模式应先不用自动删除,保存证据后再按 ID 清理。

再看两个经常被混淆的时刻。execve 成功之后原进程映像被替换,但进程继续运行不意味着其初始化业务资源已经完成;探针 Python 解释器启动、解析参数、创建状态目录和开始 HTTPServer 监听每一步都可能失败。如果状态目录不可写,应用可能在建立监听前抛异常;它与 OCI runtime 找不到 /usr/local/bin/python3 并非同一层。相反,程序已监听但将 --host 改成 127.0.0.1,网络路径上的访问可能失败,容器内本地连接仍成功。要区分它们,分别保留进程 stderr、监听端口和容器内/发布端口的实际请求,而不是只在宿主跑一次 curl 就归因于“Docker 网络故障”。这些反例无需向任何共享网络发包,实验只使用自建容器与本地绑定端口。

对于有重启策略的容器,单次返回码还会进一步变弱。启动接口曾经返回成功,而进程随后异常退出并被重新创建,后来采到的宿主 PID 可以与起初不同。取证时从 docker inspect 读重启计数、前后 StartedAt 和完整容器 ID,在可访问的实验主机上记录两次宿主 /proc/<pid>/ns;若中途 PID 更替,按各自生命周期分成两条记录,不能把更换后 /identity 的内容附到之前的启动调用上。Docker 层面的 restart 策略是 daemon/策略行为,不能误说成 OCI runtime 规范自动重启进程;21 再讨论 daemon 重启及 shim 与主进程的存活区别。

当前探针的 --ready-delay 最大为 30 秒,有利于控制实验窗口。构建镜像时 Dockerfile 使用 USER 65532:65532,因此 /tmp/containers-state 是否能被创建也与目录权限有关:本程序调用 mkdir,如果路径已被只读挂载或覆盖成另一所有权的 bind mount,异常可能发生于 HTTPServer 创建前。先对照默认镜像的正常入口,再针对自己创建的容器改一个变量;“端口冲突 + 目录只读 + 不存在入口”同时出现时,首个可见错误只告诉当前这次启动最先碰到哪个条件,不能为所有剩余路径背书。固定输入变量让错误定位可证伪:恢复唯一改动再跑同一命令,检查首次缺失的那项证据是否补齐。

身份和时间也要按机器边界解释

用 CLI 返回值组织这条链时,先明确当前 Docker context 指向哪台 daemon。同一工作站可以把 docker run 发给远端 Linux daemon;在客户端运行 ps 或检查客户端的 /proc 不会看到远端应用 PID。反过来,某个节点的 ctr 能列出 containerd 对象,不说明此对象一定由本次 Docker CLI 创建。要把多层身份连接起来,先固定 daemon socket、容器完整 ID 和时间窗口,随后到该 daemon 所在主机检查 PID。宿主 PID 只是当下的数字,不是全局持久 ID;PID 复用后旧截图甚至可能指向另一个进程。用 /proc/<pid>/stat 的启动时刻及命名空间 inode 与容器创建时间交叉检查;采样时进程已退出则如实记“未捕获”,不能填另一次进程的数字。

即使 containerd 的 task 与 Docker 容器共用一段 ID,也不代表这两个 API 操作在同一刻完成:一个是管理服务的元数据对象,另一个是可执行工作负载及其 IO、进程生命周期。将“同名”误写成“同一层”会导致清理时只删一个视图就误判另一个已经不存在。反例是在专用 VM 先 docker create 而不 start:分别检查 Docker 对象是否可 inspect、此时是否有应用 PID、能否在 containerd 侧找到相应 task,不以本文预设的三项结果代替实际查询。然后再启动并观察新增事件。对照按自建容器 ID 过滤,并在实验结束后把容器和探针进程均核验为已结束。

如果要更严格地说“执行了入口”,可以在独占 VM 针对自建实验 daemon 启用与本篇版本匹配的调试日志,或对自建程序记录其启动打印和进程身份;不能在共享宿主全局开启高开销追踪并把其他租户进程信息一起写入附件。单凭应用的一条 listen stdout,只能说应用代码至少曾运行到该打印位置,不保证请求所需的状态目录持久化成功,甚至不保证采集 stdout 后应用仍在运行。反之,进程短命且日志驱动没有及时收集时,应用无输出不能证明 OCI start 没执行。创建结果、开始事件、宿主 PID 快照、应用答复各回答不同命题,缺哪一环就保留哪一环的未知。

计时从什么地方开始,也影响“Docker 慢”这个结论。从用户输入命令到镜像准备的时长、从容器对象创建到 task Start 返回的时长、从 task 启动到 /ready 首次成功的时长,不能用一个 time docker run 混为一谈;后一段甚至包含应用自己设的十秒延迟。对比时应同时采集客户端和服务端时钟,并单独校准时钟偏移;在没有同步手段时只能比较同一主机上单调计时的相邻阶段,不能从两个主机的挂钟相减推出毫秒级延迟。34 的性能篇会在可用 VM 上设计冷热缓存与多轮计时,本篇只记录关键事件的原始时间,不输出没有运行依据的平均值。

这种取证还有一个不易察觉的前提:查询动作本身必须在对象仍可访问的时刻进行。若演示时加了 --rm,一个毫秒内退出的入口可能在 inspect 前已被移除,事后只剩终端上的错误。应在构建、启动和访问三个阶段分别记下镜像引用、容器 ID、命令完整参数与终端返回,短命容器先不设置自动删除。若要重试,一定给新实例不同名称和记录编号,避免旧容器残留导致名称冲突,让一次 create 拒绝被误认为重试后的 exec 失败。反向验证也成立:当 docker ps -a 中还能查到旧容器,应比对完整 ID 而不是只根据名字判断它是不是这次执行的对象。

还有镜像摘要不能拿标签替代的问题。若基线和反例两次构建共用可变标签,第二次构建可能已经把本地标签指到新配置;即使名称相同,这时比较的也不是同一份字节。更可靠的做法是各自保存完整镜像 ID、构建输入校验和与所用 Dockerfile,并在反例中覆盖启动参数而不重建镜像。若为了复现错误必须改 Dockerfile,也应单列新的 digest,并说明额外变化对结论的影响。

把失败与清理写进同一份记录

本篇建议把实验划为基线、应用未就绪和入口缺失三轮,每轮使用独立容器名称;先在专用 VM 打印内核、daemon 与 containerd 版本和 Docker context,不跨机器拼接。输入项包括镜像标签及实际摘要、程序参数、端口映射与预期退出范围,命令执行后记原始 stdout/stderr 和退出码。正常轮用 curl --fail-with-body 对 /identity 和 /ready 发请求,记录响应状态、容器 PID 与宿主 PID;对延迟轮提前记录 503 是预期的应用返回,过了延迟再请求一次,并保留实际间隔而非断言正好 10 秒。入口缺失轮的 docker create、docker start 分开执行,每次读取实际状态与事件;若创建失败,则改写失败假设,不能填写本来想得到的容器 ID。为避免遗漏 docker start 失败但对象仍存在的情况,退出时按实际保存的完整 ID 删除三轮对象,再用 docker ps -a --no-trunc 和自建名称过滤验证不存在。底层 task 或残留进程若仍在,留待该 VM 的运行时管理员进一步核对;不在共享主机执行清除整个 daemon 缓存的命令。

最终判断依据是链路上的最早失配,而不是离用户最近的错误码。若源头是尚未完成 pull,后续当然看不到业务 HTTP;若源头是容器未创建,不能从缺 PID 推导 seccomp 拒绝;若应用主动返回 503,则不能因为状态码不理想就重建镜像。正常对照提供相同输入下的正向证据,失败轮只改一个条件,两个结果连同清理构成一个可迁移的诊断法。这里还没有 VM 原始事件、PID、容器 ID 和 digest,答案栏继续空缺;静态源码核对仅支持为何采这些字段,不支持断言本云端已跑过其中任何一轮。

现象 从哪层开始排查 缺什么证据不能定论
pull 成功但 run 失败 config、snapshot、exec 错误 没有宿主 PID 不等于网络问题
run 返回但业务 503 进程/监听/应用 ready 缺业务请求不能判定成功
只有容器 ID 对应 task PID、镜像 digest 不能还原调用链

练习

  1. 先预测入口路径不存在时,镜像下载、容器创建、进程执行三个阶段会在哪一步出现首个可见失败;在专用 VM 保留对应返回码和原始日志。
  2. 预测 probe-app 设 --ready-delay 10 与入口立即异常退出的 /health、/ready 及进程状态差别,逐项验证不把 503 当 exec 失败。

上一篇:18:containerd 对象;下一篇:20:rootless。

系列总目录:从进程隔离到运行时与编排。

参考资料