Kubernetes 临时容器在 Containerd 底层的生命周期与 Task 状态转换剖析
在 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 进程。 - Task:
Task才是容器运行时的具体实体。一个 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 收到请求后:
- 在它的 BoltDB 数据库中写入一条新的容器记录。
- 为该容器准备 rootfs(挂载 overlayfs 层)。
- 生成 OCI Spec 配置文件(
config.json),其中会将该临时容器的namespaces配置指向已有 Sandbox 的 Namespace 文件路径(如/proc/<sandbox-pid>/ns/net)。
Phase 2: 进程拉起与 StartContainer
Kubelet 紧接着发送 StartContainerRequest。
- Containerd 的 Task Service 启动一个新的
containerd-shim-runc-v2实例(如果配置了每个容器独立 shim,或者复用已有 shim)。 - Shim 调用
runc create创建容器进程,此时容器处于CREATED状态,主进程被挂起(通常停留在 init 阶段)。 - 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 会通过AttachContainerCRI 接口将标准输入输出(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(如 Always 或 OnFailure)重新调用 CreateContainer 和 StartContainer 来拉起一个新的 Task。但临时容器一旦进入 STOPPED 状态,就再也无法被重新启动。
这一限制在底层是如何实现的?
Kubelet 侧的控制面锁死
在 Kubernetes 设计中,临时容器是只读且不可变的。Kubelet 在同步 Pod 状态(syncPod)时,其内部的工作循环(PodWorkers)会区分普通容器和临时容器:
- Kubelet 在解析 Pod Spec 时,临时容器的信息存储在
Spec.EphemeralContainers中,而不是Spec.Containers。 - Kubelet 的
recreateConsumer逻辑只针对Spec.Containers和Spec.InitContainers进行重启状态机评估。 - 对于
Spec.EphemeralContainers,Kubelet 仅进行状态收集。一旦检测到其State.Terminated不为空,便不再向 CRI 发起任何针对该ContainerID的Start请求。
Containerd 底层元数据的完整性
在 Containerd 侧,临时容器的 Container 元数据和 Task 记录依然存在。我们可以通过 ctr 查看到一个退出码为 0 或 137 的 STOPPED 任务。
由于 CRI 插件没有收到 Kubelet 的拉起指令,这个 STOPPED 的 Task 会一直保留在内存和 Boltdb 中,直到整个 Pod 被删除(此时 Kubelet 会发送 RemoveContainer 和 StopPodSandbox)。
5. 现场实战:从宿主机视角观测临时容器
为了验证上述理论,我们可以在 Kubernetes 节点上直接使用 crictl 和 ctr 进行底层剖析。
步骤 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状态机,从CREATED到RUNNING再到STOPPED。 - 在 控制层面,Kubelet 通过在同步循环中剥离对其重启策略的评估,确保了临时容器“一次性使用”的安全边界,防止了调试工具在生产环境中的无限复活与资源滥用。
理解这一底层的演进和状态转换,有助于我们在开发自定义容器运行时、排查复杂的 Pod 注入故障以及进行高阶容器安全审计时,拥有更透彻的全局视角。