
Pod 已经分配到节点，却一直没有进入 Ready。此时 scheduler 已完成自己的工作，接下来要区分的是：运行环境没有建立、镜像没有准备好、容器进程没有启动，还是进程已启动但健康检查未通过。

这些阶段都可能表现为“容器还不能服务”，但负责组件、失败位置和恢复动作不同。Pod Sandbox 是把它们分开的一个重要观察点：一个 Sandbox 已经 Ready，并不意味着业务容器已经启动成功。

<!--more-->

## kubelet 通过 CRI 使用运行时

容器运行时接口（Container Runtime Interface，CRI）是 kubelet 与运行时之间的 gRPC 接口，包含 RuntimeService 与 ImageService。kubelet 不直接执行 `runc`，也不直接把 Docker CLI 命令拼成容器操作。本文环境使用 containerd，它的 CRI 实现接收请求，再安排底层容器任务与 OCI runtime 的执行。

kind 的节点在宿主机上是 Docker 容器，节点内部的 Kubernetes 工作负载则由该节点内的 containerd 管理。这是两层运行环境：在宿主机执行 `docker ps` 主要看到 kind 节点；要观察 Pod Sandbox 和业务容器，应进入目标节点使用 `crictl`。把两层容器 ID 混用，会查错日志和进程。

本文对应 Kubernetes v1.35.1（`8fea90b45245ef5c8ba54e7ae044d3e777c22500`），实际运行时为 containerd v2.2.1。没有修改 Kubernetes 二进制，也没有启用共享进程命名空间、host network 或容器级重启规则。

## Sandbox 是 Pod 的运行环境边界

Sandbox 表示运行时为 Pod 建立的一组运行环境。Linux 普通 Pod 中，网络命名空间通常由 Sandbox 维持，多个业务容器加入同一个网络命名空间。因此它们共享 Pod IP、端口空间和 loopback，一个容器可以通过 `127.0.0.1` 访问另一个容器的监听端口，也可能发生端口占用冲突。

这不代表“Pod 内所有 namespace 全部共享”。[`NamespacesForPod`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/kuberuntime/util/util.go#L111-L131) 分别计算 Network、IPC 与 PID 配置。默认 PID 命名空间是容器级的；只有配置 `shareProcessNamespace` 等相应选项时，行为才改变。共享网络也不会自动共享容器的根文件系统，文件共享仍需要合适的卷挂载。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/kuberuntime/util/util.go#L111-L131" >}}

pause 是传统 Linux 运行时中用于维持 Sandbox 命名空间的基础容器进程。它不负责转发所有业务流量，也不代理每一次 localhost 请求。具体 Sandbox 实现受 RuntimeClass 和运行时影响，虚拟机隔离型运行时不能直接套用“一个 pause 进程加几个普通容器”的全部细节。

## 从 SyncPod 到业务进程启动

[`SyncPod`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/kuberuntime/kuberuntime_manager.go#L1394-L1428) 先通过 `computePodActions` 比较期望 Pod 与已观察到的运行时状态。它计算哪些容器需要停止、哪些需要启动，以及是否需要重新创建 Sandbox，不是每次同步都无条件重建全部容器。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/kuberuntime/kuberuntime_manager.go#L1394-L1428" >}}

需要 Sandbox 时，[`createPodSandbox`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/kuberuntime/kuberuntime_sandbox.go#L39-L75) 生成带有 namespace/name、UID、attempt、DNS、日志目录和安全配置的 `PodSandboxConfig`，最终调用 `RunPodSandbox`。这些标识让运行时状态能够重新关联到原来的 API 对象。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/kuberuntime/kuberuntime_sandbox.go#L39-L75" >}}

对于本文的 containerd 普通 Linux 网络路径，运行时负责创建网络命名空间并调用 CNI 建立 Pod 网络。[containerd v2.2.1 的 `RunPodSandbox` 与 `setupPodNetwork`](https://github.com/containerd/containerd/blob/v2.2.1/internal/cri/server/sandbox_run.go#L232-L265) 展示了这条调用链：先保存 netns 路径，再调用网络插件并检查接口与 IP 结果。kubelet 与 containerd 之间的 CRI 返回成功，才会继续后面的启动路径；不能把图画成 kubelet 直接调用 CNI 后再通知运行时。Sandbox 创建中的网络错误通常出现在 `FailedCreatePodSandBox` 事件中。

{{< github-file url="https://github.com/containerd/containerd/blob/v2.2.1/internal/cri/server/sandbox_run.go#L232-L265" >}}

这段代码还在调用 `setupPodNetwork` 之前登记失败清理，因为网络插件可能已经创建了一部分资源才返回错误。重试并不意味着前一轮没有副作用；containerd 会尝试拆除网络和相关运行环境，清理本身也可能失败。本文没有注入这种故障，这里的恢复关系依据源码，不能当作已验证的完整回滚保证。

Sandbox 可用后，kubelet 按初始化与容器生命周期规则推进。[`startContainer`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/kuberuntime/kuberuntime_container.go#L200-L229) 的关键顺序是：

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/kuberuntime/kuberuntime_container.go#L200-L229" >}}

镜像准备完成后，kubelet 生成容器配置并调用 CRI `CreateContainer`。`StartContainer` 与 PostStart 的分支在后文展开。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/kuberuntime/kuberuntime_container.go#L254-L280" >}}

```text
EnsureImageExists
→ generateContainerConfig
→ RuntimeService.CreateContainer
→ RuntimeService.StartContainer
→ PostStart（如果配置）
```

这些是这段函数内的执行顺序，不代表整个节点只允许一个容器镜像操作。其他 Pod 可以同时启动，镜像拉取并发还受 kubelet 配置约束。

`CreateContainer` 返回的是运行时容器 ID，不等于应用进程已经启动。`StartContainer` 成功后才记录 started 事件；即便如此，应用也可能立刻退出，或者尚未满足 readiness。`PostStart` 在启动之后调用，其失败还可能导致 kubelet 杀死已经启动的容器。

这里的先后关系是 kubelet 发起 CRI 调用与 Hook 调用的顺序。容器 ENTRYPOINT 已可在运行时中执行，`PostStart` 与它并发，没有“Hook 执行完，主进程才开始”的保证。依赖预先生成文件或检查前置条件的应用，应使用合适的 init container 或自身启动逻辑，不能把 PostStart 当作 ENTRYPOINT 之前的同步屏障。Hook 还可能重复投递，处理应当幂等；并发与投递语义见[官方生命周期 Hook 文档](https://v1-35.docs.kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/)。下面的源码可以核对 CRI 调用顺序及 Hook 失败后的终止分支。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/kuberuntime/kuberuntime_container.go#L292-L334" >}}

```mermaid
sequenceDiagram
    participant A as API Server
    participant K as kubelet
    participant R as containerd CRI
    participant N as CNI
    participant C as 业务进程
    K->>A: 建立本节点 Pod 的 Watch
    A-->>K: 返回已绑定 Pod 的变化
    rect rgb(225, 239, 255)
    K->>R: RunPodSandbox（UID 与网络配置）
    R->>N: 普通 Linux 网络路径的 ADD
    N-->>R: 网络配置结果
    R-->>K: Sandbox ID（成功路径）
    end
    rect rgb(238, 246, 222)
    K->>R: 检查或拉取业务镜像
    K->>R: CreateContainer（Sandbox ID）
    R-->>K: Container ID
    K->>R: StartContainer
    R->>C: 经底层运行时启动进程
    R-->>K: 启动结果
    end
    K->>A: 异步更新容器状态和 Pod 状态
```

图展示首次成功启动的主路径。卷准备、安全上下文处理、init container 和探针未展开；没有画出 API 状态更新到实际进程之间不存在的同步完成屏障。API Server 也不直接连接运行时启动进程。

## 一次容器重启通常不重建 Sandbox

在 Sandbox 仍可用时，某个业务容器退出，kubelet 可以只为该容器调用新的 `CreateContainer` / `StartContainer`。原来的 Pod UID、Sandbox ID 和其他业务容器 ID 都可以保持不变，退出容器则获得新的 ID。

与此相对，`computePodActions` 判定 Sandbox 需要重建时，`SyncPod` 先停止相关运行环境，再根据 Pod 的状态和重启规则安排重新创建。源码中 `CreateSandbox` 与 `ContainersToStart` 是不同的动作。已有 Sandbox 失效、节点或运行时恢复后的具体处理，不能从一次应用容器退出推导出来。

容器不断失败时还会触发启动退避。`CrashLoopBackOff` 表达的是 kubelet 当前暂缓重启的状态，不是一个独立的底层错误原因；还应继续读最近一次终止状态、上次日志以及事件。

## 用三种证据区分运行环境与应用

实验在 `zj` 的专用 kind 集群 `article-resources` 中运行，使用预载的 `python:3.12-alpine`。配置、脚本、证据和复现说明见 [k8sdev/article-resources-runtime](https://github.com/jimyag/k8sdev/tree/3cfa654905a750b1716de8c32aa42d7573220954/article-resources-runtime)。先按该目录 README 的固定提交准备步骤创建独立工作目录，再运行实验。

局部重启的检查同时比较 Pod UID、peer 容器和 Sandbox，避免把整个 Pod 重建误判为单容器重启。

{{< github-file url="https://github.com/jimyag/k8sdev/blob/3cfa654905a750b1716de8c32aa42d7573220954/article-resources-runtime/run.py#L46-L60" >}}

第一组 Pod 有两个普通容器：server 监听 `8080`，peer 保持运行。脚本分别读取两个容器的 `/proc/self/ns/net`，并在 peer 内请求 `http://127.0.0.1:8080`。观察到网络命名空间标识相同，HTTP 返回 `200`。两项证据分别对应“共享同一个 netns”和“应用确实可通过 localhost 通信”，单看一个 Pod IP 不足以替代后一个检查。

第二步向 server 的 Python 主进程发送它能够处理的 `SIGINT`，等待 kubelet 重新启动 server。校验同时比较 Pod UID、两个容器 ID、server 重启次数以及通过 CRI 列出的 Sandbox ID：server 获得新容器 ID，peer 容器 ID、Pod UID 和 Sandbox ID 保持不变。这里验证的是应用容器局部重启，没有主动破坏 Sandbox 或网络插件。

第三组 Pod 使用同一个已存在镜像，命令指定为不存在的 `/does-not-exist`。实际运行时错误包含：

```text
RunContainerError
OCI runtime create failed
exec: "/does-not-exist": stat /does-not-exist: no such file or directory
```

该 Pod 的 Sandbox 已经处于 `SANDBOX_READY`，业务容器却没有成功运行。在本文版本的实现中，失败发生于 CRI `StartContainer` 内创建底层任务的阶段，不能仅凭错误文字中的 “create” 就归类为 Kubernetes 的 `CreateContainerError`。多次失败后，还可能观察到 `CrashLoopBackOff`。

## 根据失败阶段收敛排障范围

| 观察到的状态或事件 | 接下来需要核对的事实 |
| --- | --- |
| `Pending` 且尚无 `spec.nodeName` | 调度条件、资源请求、绑定是否完成 |
| `FailedCreatePodSandBox` | RuntimeClass、Sandbox 镜像、运行时与 CNI 错误 |
| `ErrImagePull` / `ImagePullBackOff` | 镜像地址、凭据、仓库连通性、拉取策略 |
| `CreateContainerConfigError` | Secret、ConfigMap、安全上下文与容器配置生成错误 |
| `RunContainerError` | 可执行文件、挂载、权限和底层运行时启动错误 |
| `CrashLoopBackOff` | 最近终止原因、退出码、上次容器日志与启动探针 |
| Running 但不 Ready | readiness 状态、应用监听与依赖；不能只查启动链 |

`kubectl get pods` 的 STATUS 是便于展示的摘要，`ContainerCreating` 也不是 Pod phase。需要把 API 条件、事件、运行时状态和具体进程关联起来，而不是把一个展示字符串当作完整的状态机。

