Pod 已经分配给一台节点,怎样找到真正接收请求的那个进程?17 的 OCI runtime从执行配置走到进程,18 的 containerd 对象把内容与运行分开,24 的 CNI解释网络配置;然而 Kubernetes 的 Pod UID 并不是这三个系统共同使用的进程号。需要在每一道边界找到上一层交给下一层的稳定标识,再验证请求确实被该对象处理。已有Kubernetes 核心概念介绍词汇,本篇聚焦节点的身份链,不重述控制面全集。

“已分配节点”只解决了哪个问题

调度器为一个尚未指定节点的 Pod 选择 spec.nodeName,这表明控制面希望某台节点承担它,不表明该节点已拉取镜像、配置网络,更不表明探针可用。kubelet 按本机应处理的 Pod 做协调,所需镜像可能尚未拉下,Pod sandbox 可能失败,应用也可能启动后立即退出。kubectl get pod -o wide 能给出节点与当前 IP,却不能通过显示的 Running 推断某一次业务请求已经落到哪只进程。下一篇讨论资源约束,28 讨论 Ready 与业务成功;本篇先解决“这是什么对象”的问题。

选一种专用 kind 集群做正常例,先保存 Pod 的UID 而不只保存名称。名称是命名空间内的 API 对象名;删掉再创建同名 Pod,UID 应取自新对象。旧进程可能还在终止,新进程也可能尚未开始,在交叠窗口里单看 Pod 名很容易把旧日志、旧容器 ID 贴到新请求上。若 Pod 对象不变但其中的应用容器被重启,UID 可能不变,容器 ID 与进程却换了。因而至少要同时保存 UID、容器重启次数和采样时间;在等待状态时不能把尚不存在的 ID 填上猜测值。

kubectl 的 Pod JSON 给出 metadata.uid、spec.nodeName、status.containerStatuses[].containerID 和 imageID。containerID 常带运行时前缀,例如 containerd 的 containerd://;只有确认实际 CRI 实现是 containerd,才剥去前缀去查询对应节点。imageID 指运行时报告的已解析制品身份,它不是以 tag 字符串原样复制,也未必是你在构建宿主上看到的 config ID。若 containerStatuses 尚未出现,先看事件与镜像状态,不从某个老的 crictl ps 输出里“挑一个最像的”补进链里。

取证的时间窗也重要:kubectl get pod、节点 crictl ps 和 /proc 并非一次原子快照。夹在两条命令之间的崩溃和重启可能使第一条记录的容器 ID 在第二条命令查询时已经退出,旧 PID 被系统回收。保存每步的 UTC 时间、命令退出码与原始 JSON,遇到对象不再存在先重新读 Pod 当前状态,不把下一次运行产生的 PID 接到旧 ID 之后。尤其不能只把脚本最后一行的“成功”作为证据:链上每条箭头都要能反向找到同一次调度、同一次 sandbox 和同一次容器启动。

kubelet 调用的是 CRI,不是 OCI 命令行

Kubernetes 的 Container Runtime Interface(CRI)分别描述运行时与镜像服务。CRI v1 的 RuntimeService 包含 RunPodSandbox、PodSandboxStatus、CreateContainer、StartContainer、ContainerStatus 等调用;kubelet 不会直接把 Pod YAML 当作 OCI config.json 发给 runc create。选择沙箱运行时、准备容器运行配置、存储和网络等操作经过具体 CRI 实现,再由其驱动底层执行路径。OCI Runtime Specification 约定的是另一道更靠近本机进程的接口;看到容器运行不能反推 kubelet 曾经直接调用过哪个 OCI 命令。

以固定阅读定点 Kubernetes v1.33.4 的 kubeGenericRuntimeManager.SyncPod 为例,它先计算现有 sandbox/容器与所需状态之间的差异;如果原 sandbox 需要更换,会先协调旧对象的停止,再按条件创建新的 sandbox,接着处理 init 与应用容器。控制逻辑不是“一条 Pod 创建请求永久对应一条固定系统调用链”:重试、旧 sandbox 变更、镜像取得失败、init 容器未完成都会使分支与实际顺序不同。官方 CRI 协议说明接口语义,定点源码说明该版本的协调分支;两者都不提供我们未来 VM 上的一份调用日志。

同一阅读定点下,containerd v2.1.0 的 CRI RunPodSandbox 先产生沙箱 ID 并预留名称,取得 runtime 选项、建立内部 sandbox 对象,然后按配置处理 sandbox 任务和网络;其源码为网络建立出错保留清理分支。CNI 插件调用属于此实现的节点网络处理路径,不能把 RunPodSandbox 成功简单解释为“kubelet 自己调用 ip link 已加好 Pod 网卡”。更不能用“某处看见 sandbox ID”证明 ADD、应用 exec 和 HTTP 三件事全都完成:sandbox 对象可能在创建中、已失败或留待清理。对每一种断言分别查 CRI 状态、网络对象和实际请求。

源码里的资源清理顺序不能只看正常路径的顺序推算。在 sandbox_run.go 的这个版本,初始化失败时存在延后处理的 name reserve、lease 与网络回收分支;网络搭建可能分配了一部分资源后才报错,所以错误返回也需要执行相应回退。下一次 kubelet 协调可能再建新 sandbox,而旧对象可能尚待回收。要验证“没有泄漏”,需比较本篇创建前后的 sandbox ID、CNI 地址租约与本次专用节点的接口/路由,而不是看到 Pod 事件报错就默认资源已清完。24 的 CNI DEL 讨论了插件侧回滚,本篇只负责把 API 与节点对象的失败时间线串起来,不替插件制造未运行的证据。

这里还要把三个同名词拆开:Kubernetes Namespace 是 API 名称作用域;Linux network/pid/mount namespace 是进程视角;containerd namespace 则是内部对象划分。kubectl -n c26-dedicated 不会凭借文字相同就让目标进程获得某个 Linux network namespace,ctr -n k8s.io 也不表示进入名叫 k8s.io 的 Kubernetes Namespace。排障时把三个字段写在不同列,避免把“查不到容器”误诊为内核隔离故障。

名字相同但作用域不同还会影响删除动作:删除本次 API 命名空间只是在 Kubernetes 控制面要求清掉该作用域里的对象,不是让操作者去删除节点上的 Linux namespace,也不是删除 containerd 的 k8s.io 命名空间。后两者是运行时可能仍需要的共享基础设施。清理时先等待 API 层对象终止,再从该节点 CRI 核对本篇 UID 的残留,最后只报告可见对象的实际状态;若资源没有按预期释放,应保留证据交由专用 VM 管理者处置,不用全局删除命令“使截图变干净”。

从 Pod UID 到一只可核查的任务

一条有用的取证链从 metadata.uid 开始:选定调度节点 → 在该节点的 CRI 沙箱列表查到匹配同一个 UID的 sandbox ID → 找到属于这个 sandbox 的应用容器 ID → 查容器状态和执行 PID → 从同一个 PID 视角读 /proc/<pid>/ns/{pid,mnt,net} 和 cgroup → 在容器内请求 /identity,最后用本次 Pod 的镜像摘要与业务请求记录闭环。任一环缺失就在这一步停下来找失败原因,不把“同名 Pod 上一次的 ID”悄悄接到新链上。

沙箱的 UID 可来自 CRI metadata 或标签,但标签名和字段位置需按该运行时实际 JSON 核对;crictl pods --name c26-probe 只是缩小候选范围,若两次重建留下同名历史对象仍可能返回不止一个。应用容器也可能重启多次;crictl ps -a 里的旧容器不一定是最新运行者,必须与 Pod containerStatuses 当前 ID、sandbox ID 和采样时间三者交叉校验。kubectl describe 中的事件帮助确定进展,不能取代运行时的原始对象 ID;日志流和客户端错误中往往也缺少把事件唯一绑定到某次创建尝试的信息。

节点当前状态还有一个容易被漏掉的对照面:CRI 的 PodSandboxStatus 与 ContainerStatus 分开查询。沙箱属于哪个 API Pod,既要检查它的 metadata UID,也要检查所在节点和 namespace;容器属于哪个沙箱,检查 podSandboxId 与当前容器 ID 的对应,而不是因为两者名字相似就合并。保存 crictl inspectp、crictl inspect 的完整 JSON 比只截取人眼喜欢的两三行可靠。将身份列、状态列和日志列分开写:UID/ID 确认对象,state/reason 指示节点当时报告的阶段,HTTP/cgroup 才确认执行结果。一个 API 状态变更并不自动为每一层的瞬时状态生成快照。

正例使用同篇可独立下载的 固定 Pod 清单、清单校验值 和 probe-app 源码包。清单的镜像字段是故意不可运行的占位符:先按 13 的步骤构建并取得实际可拉取的 manifest digest,再在专用 kind VM 的私有目录中替换;不把可变 tag 或本地 config digest 当成内容固定的证明。清单只创建一个 Pod,一只 probe 应用容器,请求 /ready,把 emptyDir 挂到应用的有界状态目录。它不声明本篇已经创建了 Kubernetes 对象,也不证明这一配置组合与任意实际 kind/Kubernetes/containerd 版本兼容。

进入应用之前还有镜像这道边界:Pod 指定 repo@sha256:… 时,节点必须能从其可访问的 registry 解析并获得所选平台的 manifest 和层。宿主上的 Docker 已有同名镜像,不等于 kind 节点的 containerd 已有相同内容;registry 鉴权、架构或网络失败时,应保存 Pod 事件与节点 runtime 的拉取错误,而不是去调整应用的 Python 入口。反过来镜像已在本节点,仍需单独验证运行时能建立 sandbox、挂载目录并运行入口。镜像摘要可以收窄“运行了哪些字节”的范围,不能替代运行时状态。

正常流程在专用命名空间 c26-dedicated 下等 Pod Ready,再在容器内对 127.0.0.1:18080/identity 发一次真实 HTTP 请求;它证明该容器本地的 Python 服务响应该请求,不证明跨主机 Service 已可用,也不测试 29 的 Service/NetworkPolicy。返回的 pid 与 /proc/self/ns/* 是容器进程从自身视角看到的 PID/namespace inode。随后经节点 CRI 查到节点所见 PID,再从**节点的 /proc**读同一组 inode 与 cgroup,将网络/挂载视图与容器内 JSON 对照;同一进程的 PID 数字跨 PID namespace 可以不同,而同一时刻对该进程读取的同类 namespace inode 应能相互对应。若 inode 对不上,应先排除误读了执行请求的客户端进程、容器已经重启或 CRI ID 指向另一容器。不要拿容器返回的 pid=1 直接拼成物理 VM 上 /proc/1。

这里的 emptyDir 是独立的状态目录,挂载于 /tmp/containers-state,只是避免非 root 探针写入镜像只读目录。它的生命周期跟 Pod 绑定,不是 30 的 PV/PVC 持久化实验:删除 Pod 后,不能用下一只 Pod 的 /state 空值反证“节点磁盘已经彻底清除”;也不能把同一 Pod 内的容器重启后数据仍可读解释为外部卷持久化。若做额外 /state 写读对照,记录请求顺序、写入上限与读到的合成内容,且不让存储实验干扰本篇需要的 UID/PID 对照。抓取节点状态与发起请求的间隔同样需要记下,否则一次重启可能让 JSON 和请求落在不同容器。

kind 还有一层容易混淆的宿主:Docker provider 的“节点”本身是一个容器,节点内 crictl inspect 的 PID 是节点容器所见的 PID,不一定是外侧 Linux VM 的 PID。有界取证与清理命令规定 docker exec <本次节点> 读该 PID 对应的 /proc,另保存 docker inspect 节点容器 PID。若要把应用进程继续映射到外侧 VM,应在 VM 对照 /proc/<pid>/status 的 NSpid、namespace inode 和进程命令,说明到底在哪层取值;不能将节点 init 的 VM PID 当作应用 PID,也不能认为多了一层 kind 容器就意味着多了一台物理节点。只有这条跨层映射真的取得证据时,才填外侧 PID 一列。

这也是为什么节点诊断命令必须在Pod 所在的那个节点执行。两节点 kind 集群里,kubectl get 可以从控制终端查询 API 对象,但在另一节点的 crictl ps 中搜不到当前容器不表示 kubelet 没创建它。先从 spec.nodeName 找节点容器,再核对 docker inspect 标签证明这个 Docker 对象属于该实验集群。命令执行成功也不能证明它指向预期 CRI socket:若 VM 同时有宿主 containerd 和 kind 节点内 containerd,两套 crictl 结果可能都真实,却属于不同运行时。把 socket、节点名称、CRI 版本和容器 ID 一起保留,才能避免把误连 socket 当成网络或镜像故障。

何时找 cgroup、何时查 shim

在找到实际应用进程之后,节点 /proc/<pid>/cgroup 能定位它关联的组路径;控制器是否为 v2、谁管理路径以及资源限制是否写入,要结合该节点实际配置与下一篇的控制器文件,不从路径里的 kubepods 字样推断已经触发节流。Pod 的 sandbox 与应用容器可能有不同 cgroup,CPU 与内存计数也不应靠一个路径“概括整个 Pod”。若当前云端只有只读 cgroup v1,即便能读出别的进程路径,也不能复用为本篇 kind Pod 的实际数据。

进程树中看到一只或多只 containerd-shim 时,先问是哪一个版本、哪一种 runtime handler、哪个 task、哪一次 Pod 重建。containerd 的对象/生命周期模型与 Kubernetes Pod 名不是固定一对一映射;某个版本的 sandboxer、任务分组与隔离运行时都可能改变宿主进程布局。需要用 containerd 的 task ID、PID 和父子关系验证当次实现,不能从“创建了一只 Pod”演绎“全世界的每个 Pod 一定只有一只 shim”。源码定点不等于本机已安装的二进制,未来 VM 必须采集运行中的版本和真实进程树。

回到最初的问题,“真正处理请求的进程”也需要定义请求从哪里发起。kubectl exec 在同一容器内用 loopback 调用 /identity,只穿过进程自身监听和 Pod 的网络空间;从控制终端通过 kubectl port-forward 到同一 Pod,则新增 apiserver、kubelet 等中转环节;从另一个节点经 Service 调用,还要考虑 29 的端点选择和数据面。三次都返回相同 JSON 并不能证明三条路径相同。26 只把容器内请求作为识别应用进程的最小证据,把流量入口的可达性留给后续网络篇逐跳取证。如果执行节点自身还在反复重建 sandbox,则优先固定这一条身份链,暂不引入会增加变量的跨节点转发。

把入口失败放到正确的一层

第二轮使用相同镜像摘要创建 c26-bad,只把启动命令改成不存在的 /__c26_missing_executable__。这使“镜像内容是否能取得”和“应用入口是否有效”尽量分开。理想对照是正常 Pod 的 sandbox、镜像和 Python HTTP 均成功,故障 Pod 的 sandbox 可以建立,但应用容器无法运行该入口;然而实际失败究竟报 CreateContainerError、RunContainerError,还是运行之后的退出错误,取决于具体 runtime 和校验阶段,不能把一个预设字样写进验收表。查看 CRI 容器状态、kubelet 事件及所用 OCI 运行时版本的日志,才能说清楚错误在哪道接口上暴露。

如果故障 Pod 连 sandbox 都没成功,则是另一类真实失败:可能是 pause 镜像拉取、CNI 地址分配、网络配置或节点空间错误。此时查 API 对象事件、CRI sandbox 状态、同篇 24 的 CNI 操作证据以及节点网络资源,不要因为起初写了坏入口就宣布已经观察到 exec 错误。容器 ID 若为空,用旧容器的 /proc 路径去“补齐”反例尤其危险,会把第一次故障和下一次重试掺在一起。重复实验前保存两次对应的 UID、sandbox ID 与容器 ID,再通过专用命名空间删除本次 Pod,核对 CRI 残留;不得为清理博客实验去重启共享节点的 daemon。

故障注入的顺序也要控制。先让正常清单在同一节点、同一个 digest 下跑通,保存 digest 与请求结果;再创建坏入口的独立 Pod,不修改正常对象的镜像引用或 registry。若失败前镜像下载还未完成,两个实验差的是缓存状态,无法归因于入口命令。坏 Pod 的 API 创建成功可能与应用执行失败之间隔着若干次重试,事件列表要保留次数及时间;观察窗口设上限,超时即保存当时状态并停止本轮,不通过无限等待换取某个预期错误。恢复时删除的只应是这个实验命名空间内实际创建的两个 Pod,确认仍有残留再按对象 ID 查,不重启公共 runtime 来“清屏”。

Pod 在 API 中显示 Running 也不等于 /ready 返回 200,更不等于一次外部业务请求成功。坏入口发生在进程执行前,与已运行应用对 /ready 返回 503 是不同层次;存活探针失败导致重启、就绪探针失败让端点不承接流量的差异交给 28 继续验证。此处不要求通过反复修改探针“绕过失败”,而是用一次固定正例和一次只改变入口的负例保持证据之间的可比性。若节点版本与规范条件不相容,记录具体错误并保留 NOT_RUN,不把阶段性 YAML 校验称为容器实验。

记录失败不是只抄一行 kubectl get pod。对于没有应用容器 ID 的情况,Pod 的 waiting reason、镜像相关事件与 CRI 的 sandbox 事件可能提供三个不同的视角;对于容器已经创建但退出的情况,再追加容器退出原因、时间、可取得的日志及节点进程是否尚存。时序中如果出现“旧沙箱停止”与“新沙箱创建”,要给每一个 ID 单独编号,否则将两次重试写成一次失败会遗漏真正的回滚缺口。读者复跑时即便得不到跟某个教程相同的 reason 字符串,只要能以 UID/ID 把本次失败层和清理范围限定住,也比截图里一个熟悉的错误词更有解释力。

迁移判断:什么时候能复用这条链

换一个运行时或换成非 kind 节点时,先保留身份问题,不要机械照抄命令。第一层仍从 API Pod UID、nodeName、当前 containerID 和镜像 digest 入手;第二层检查对应节点 CRI 提供的 sandbox/container 状态;第三层才寻找该运行时暴露的执行 PID、namespace 与 cgroup,最后用目标服务的可识别请求核实。若节点不是 Docker provider 的 kind,docker exec <kind-node> 这一层本来就不存在,应改用该节点自己的受控诊断入口。若 runtime handler 不是常规 Linux OCI,不能假定存在与 containerd info.pid 同形的字段;先读本机版本与实现,再决定能否继续。

这套判断法的出口是一个能回到原始数据的对照表,不是一张“Pod 结构图”。给每一次请求附上源地址、时间、目标 Pod UID、当前容器 ID 与响应的身份字段;若服务前还有 Service、代理或网关,就继续查端点集合和转发链,不能拿 Pod 自身的成功响应推断入口已把请求转到它。换成静态 Pod、不同的 runtime handler 或多容器 Pod 时,同一 Pod UID 下面会出现不同的容器 ID 和不同的进程视角,采样列应按容器名称展开。若某个 runtime 不暴露宿主 PID,把该列标为不可取得,仍可利用 CRI ID、日志和网络请求形成局部结论,拒绝拼凑另一个进程号来使表格完整。

页面静态检查只会证明清单、归档和链接可以取得,不会产生 Pod UID、sandbox ID、真实 PID 或 HTTP 返回。本云端缺 kind/CRI、专用 CNI 与可运行镜像,这两轮节点关联及失败清理全部 NOT_RUN。此处固定的 Kubernetes v1.33.4、containerd v2.1.0 只是阅读实现的源码定点,不是宣称已经在实验 VM 安装这些版本;目标 VM 装好后应先冻结其实际版本,再比照定点检查分支差异。

看到的现象 下一步取证对象 不应直接推出
同名 Pod 又出现 UID、创建时间、当前容器 ID 与上一个 Pod 共用进程
sandbox ID 已分配 CRI 状态、CNI、应用容器 ID 网络和业务均已成功
镜像按 digest 拉到节点 容器状态、入口、HTTP 请求 程序能启动且 Ready
节点进程 PID 与 JSON PID 不同 各层 /proc 与 namespace inode 两个进程必定不相关
看见一只 shim 和 cgroup 路径 task ID、版本、控制器文件 固定一 Pod 一 shim、资源已节流

练习

  1. 先预测在专用 kind 集群删掉并重建同名 c26-probe 时 UID、sandbox ID、containerID、节点内 PID 与镜像 digest 哪些应改变、哪些可能不变。按同篇 RUN 保存前后两个时间点的 Pod JSON、CRI 与 /proc,不要为了使预测成立编辑原始输出。
  2. 先预测网络插件 ADD 失败与应用入口不存在分别在什么阶段留下证据;在隔离环境中分别只改变一种条件,保存事件、CRI 状态和清理结果。如果没有足够权限只保留来源分析与 NOT_RUN,不要把负例的预期错误文案当成实际观测。

上一篇:25:跨主机网络;下一篇:27:资源到节点。

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

参考资料