WEBKT

Kubernetes 临时容器在 Containerd 底层的生命周期与 Task 状态转换剖析

15 0 0 0

在 Kubernetes 日常运维中,kubectl debug 已经成为诊断容器内故障的标准手段。通过引入临时容器(Ephemeral Containers),我们无需在生产镜像中预装大量的排障工具,即可动态地将调试工具注入到运行中的 Pod。

然而,从底层的容器运行时角度来看,临时容器的创建、运行和销毁过程与普通容器有着本质的区别。本文将绕开 Kubernetes 控制层面的抽象,深入 Containerd 核心架构、CRI 接口以及 containerd-shim 层面,剖析临时容器在底层的具体生命周期与 Task 状态转换机制。


1. 架构映射:Pod Sandbox、Container 与 Task 的解耦

在深入生命周期之前,必须理清 Containerd 中三个核心概念的关系:Sandbox(沙箱)Container(容器元数据)Task(任务进程)

  • Sandbox:在 CRI 标准中,一个 Pod 对应一个 Sandbox。在 Linux 层面,它主要对应一个由 pause 容器持有的 Network/IPC/UTS Namespace。
  • Container:在 Containerd 中,Container 只是一个元数据对象(Metadata Store)。它记录了容器的配置、镜像、挂载点以及它属于哪个 Sandbox,但并不对应具体的 OS 进程。
  • TaskTask 才是容器运行时的具体实体。一个 Task 对应一个由 containerd-shim 管理的进程树(最终由 runc 拉起)。
+-------------------------------------------------------------+
|                     Pod Sandbox (Pause)                     |
|  +------------------+  +------------------+  +-----------+  |
|  |  App Container   |  |  Sidecar Container|  | Ephemeral |  |
|  |   (Task: 1024)   |  |   (Task: 1056)   |  | (Task:???)|  |
|  +------------------+  +------------------+  +-----------+  |
+-------------------------------------------------------------+

普通容器在 Pod 创建时就已经在 Sandbox 中规划好了。而临时容器的特殊之处在于:它是在 Sandbox 已经处于运行状态后,动态地向该 Sandbox 中追加 Container 元数据并生成新的 Task。


2. 注入时刻:CRI 的交互时序

当执行 kubectl debug -it <pod> --image=busybox 时,API Server 并不会重建 Pod,而是通过 /ephemeralcontainers 子资源更新 Pod Spec。Kubelet 监听到这一变更后,开始通过 gRPC 与 Containerd CRI 插件交互。

以下是底层的关键 CRI 调用时序:

Kubelet                  CRI Plugin (Containerd)          Shim / runc
   |                                |                           |
   |--- 1. CreateContainer -------->|                           |
   |    (SandboxID, EphemeralSpec)  |                           |
   |                                |--- 2. Create Metadata --->| (Write Boltdb)
   |                                |                           |
   |<-- 3. ContainerID ------------|                           |
   |                                |                           |
   |--- 4. StartContainer --------->|                           |
   |    (ContainerID)               |                           |
   |                                |--- 5. Create Task ------->| (runc create)
   |                                |                           | (Task: CREATED)
   |                                |--- 6. Start Task -------->| (runc start)
   |                                |                           | (Task: RUNNING)
   |<-- 7. Success -----------------|                           |

Phase 1: 元数据写入与 CreateContainer

Kubelet 调用 CreateContainerRequest。在此请求中,PodSandboxId 指向已经存在的 Pod 沙箱,而 ContainerConfig 则包含了调试镜像(如 busybox)的信息。
Containerd 收到请求后:

  1. 在它的 BoltDB 数据库中写入一条新的容器记录。
  2. 为该容器准备 rootfs(挂载 overlayfs 层)。
  3. 生成 OCI Spec 配置文件(config.json),其中会将该临时容器的 namespaces 配置指向已有 Sandbox 的 Namespace 文件路径(如 /proc/<sandbox-pid>/ns/net)。

Phase 2: 进程拉起与 StartContainer

Kubelet 紧接着发送 StartContainerRequest

  1. Containerd 的 Task Service 启动一个新的 containerd-shim-runc-v2 实例(如果配置了每个容器独立 shim,或者复用已有 shim)。
  2. Shim 调用 runc create 创建容器进程,此时容器处于 CREATED 状态,主进程被挂起(通常停留在 init 阶段)。
  3. Shim 调用 runc start 唤醒进程,执行用户指定的命令(如 /bin/sh),此时 Task 状态转为 RUNNING

3. Task 状态机转换深度解析

临时容器的 Task 状态机完全遵循 Containerd 的 Task 生命周期。我们可以通过 ctr 命令行工具来直观地观察其状态变迁。

     +---------+      runc create      +---------+
     |   NEW   |---------------------->| CREATED |
     +---------+                       +---------+
                                            |
                                            | runc start
                                            v
     +---------+       Process Exit    +---------+
     | STOPPED |<----------------------| RUNNING |
     +---------+                       +---------+
          |
          | Delete Task
          v
     +---------+
     | DELETED |
     +---------+

1. NEW 状态

当 Containerd 刚刚读取到元数据,尚未向 shim 发起请求时。此状态极短。

2. CREATED 状态

在执行 runc create 之后。此时,内核 Namespace 已经创建并加入,cgroups 限制已经应用,但容器内的 1 号进程尚未开始执行用户的 ENTRYPOINT

  • 底层表征:在宿主机上可以看到一个由 containerd-shim 维护的 runc 占位进程。
  • cgroups 状态:在 /sys/fs/cgroup/ 下对应的临时容器目录已被创建,资源限额开始生效。

3. RUNNING 状态

随着 runc start 的调用,容器内部主进程开始执行。

  • 对于带有交互式参数 -it 的临时容器,Kubelet 会通过 AttachContainer CRI 接口将标准输入输出(Stdio)劫持并重定向到用户的 TTY。
  • 多进程共享:如果 Pod 开启了 shareProcessNamespace: true,此时临时容器内的进程可以通过 /proc 看到宿主沙箱内其他应用容器的 PID。

4. STOPPED 状态

一旦调试工作结束(例如用户输入 exit 或主进程执行完毕),Task 状态立即发生转移。

  • 信号传递:如果是外部强行终止,Containerd 调用 Task 的 Kill 接口,向 shim 发送 SIGKILL/SIGTERM 信号。
  • 进程消亡:容器内进程退出,内核回收 Namespace,但 containerd-shim 进程依然存活。
  • 状态固化:此时 Task 进入 STOPPED 状态。Containerd 记录下该 Task 的 ExitStatus(退出码)和 ExitedAt(退出时间)。

4. 为什么临时容器“无法重启”?

普通应用容器在退出后,Kubelet 会根据 Pod 的 restartPolicy(如 AlwaysOnFailure)重新调用 CreateContainerStartContainer 来拉起一个新的 Task。但临时容器一旦进入 STOPPED 状态,就再也无法被重新启动

这一限制在底层是如何实现的?

Kubelet 侧的控制面锁死

在 Kubernetes 设计中,临时容器是只读且不可变的。Kubelet 在同步 Pod 状态(syncPod)时,其内部的工作循环(PodWorkers)会区分普通容器和临时容器:

  • Kubelet 在解析 Pod Spec 时,临时容器的信息存储在 Spec.EphemeralContainers 中,而不是 Spec.Containers
  • Kubelet 的 recreateConsumer 逻辑只针对 Spec.ContainersSpec.InitContainers 进行重启状态机评估。
  • 对于 Spec.EphemeralContainers,Kubelet 仅进行状态收集。一旦检测到其 State.Terminated 不为空,便不再向 CRI 发起任何针对该 ContainerIDStart 请求。

Containerd 底层元数据的完整性

在 Containerd 侧,临时容器的 Container 元数据和 Task 记录依然存在。我们可以通过 ctr 查看到一个退出码为 0 或 137 的 STOPPED 任务。
由于 CRI 插件没有收到 Kubelet 的拉起指令,这个 STOPPED 的 Task 会一直保留在内存和 Boltdb 中,直到整个 Pod 被删除(此时 Kubelet 会发送 RemoveContainerStopPodSandbox)。


5. 现场实战:从宿主机视角观测临时容器

为了验证上述理论,我们可以在 Kubernetes 节点上直接使用 crictlctr 进行底层剖析。

步骤 1:部署一个常驻 Pod

kubectl run target-app --image=nginx --restart=Never

步骤 2:注入临时容器

在另一个终端启动调试:

kubectl debug -it target-app --image=busybox --target=target-app -- sh

步骤 3:在宿主机检索容器 ID

登录到 Pod 所在的 Node,使用 crictl 查看当前的容器列表:

# crictl ps -a
CONTAINER           IMAGE               CREATED             STATE               NAME                ATTEMPT             POD ID
e7f3b8a91c12d       busybox@sha256:...  10 seconds ago      Running             debugger-87xzp      0                   a1b2c3d4e5f6g
a1b2c3d4e5f6a       nginx@sha256:...    2 minutes ago       Running             target-app          0                   a1b2c3d4e5f6g

可以看到,临时容器 debugger-87xzp 已经成功挂载到同一个 POD ID 下。

步骤 4:通过 ctr 观察 Task 状态

切换到 Containerd 默认的 Kubernetes 命名空间(k8s.io):

# ctr -n k8s.io task list | grep e7f3b8a91c12d
e7f3b8a91c12d    123456    RUNNING

这里的 123456 就是 containerd-shim 为该临时容器分配的真实进程 PID。

步骤 5:分析 Namespace 共享

查看该 PID 的 Namespace:

# ls -l /proc/123456/ns/
lrwxrwxrwx 1 root root 0 net -> 'net:[4026532248]'

再查看主容器 target-app(假设 PID 为 123000)的 Network Namespace:

# ls -l /proc/123000/ns/
lrwxrwxrwx 1 root root 0 net -> 'net:[4026532248]'

两者完全一致。这从底层证实了临时容器是通过在 CreateContainer 时,将 Namespace 路径指向已存在的 Sandbox 网络空间来实现网络共享的。

步骤 6:退出临时容器并观察 Task 变化

在临时容器内输入 exit 退出。再次在宿主机查看:

# ctr -n k8s.io task list | grep e7f3b8a91c12d
e7f3b8a91c12d    123456    STOPPED

此时 Task 变为了 STOPPED。查看其退出状态:

# crictl inspect e7f3b8a91c12d | grep -A 5 "state"
    "state": "CONTAINER_EXITED",
    "exitCode": 0,
    "finishedAt": "2023-10-27T10:00:00.000000000Z",

6. 总结

Kubernetes 临时容器的本质是对底层容器运行时(CRI)动态追加容器能力的一次优雅包装

  • CRI 层面,它利用了已经存在的 PodSandboxId,在不干扰已有容器的前提下,按需发起 CreateContainer
  • Containerd 层面,它遵循标准的 Task 状态机,从 CREATEDRUNNING 再到 STOPPED
  • 控制层面,Kubelet 通过在同步循环中剥离对其重启策略的评估,确保了临时容器“一次性使用”的安全边界,防止了调试工具在生产环境中的无限复活与资源滥用。

理解这一底层的演进和状态转换,有助于我们在开发自定义容器运行时、排查复杂的 Pod 注入故障以及进行高阶容器安全审计时,拥有更透彻的全局视角。

云原生探路者 KubernetesContainerd容器运行时

评论点评