kubelet 与容器运行时:节点上到底发生了什么
前两篇讲了调度(选节点)和 Pod 生命周期(状态机)。这一篇下到节点层:kubelet 在节点上把一个 Pod spec 变成真实运行的进程。
容器运行时曾经等于 Docker。Kubernetes 1.24 移除了 dockershim,此后所有节点必须使用实现了 CRI(Container Runtime Interface)的运行时:containerd、CRI-O 或其他。这次迁移对用户基本无感,但原理上有重要变化——kubelet 和运行时之间的通信从内嵌的特定实现变成了标准 gRPC 接口。
本文只问一个问题:从 pod.spec.nodeName 被写入,到容器进程在节点上跑起来,发生了什么?
节点内部全景图
1 | |
kubelet 是控制平面在每个节点上的代理,但它不直接创建容器——它通过 CRI gRPC 接口把创建请求委托给容器运行时,容器运行时再调用底层的 OCI 运行时(通常是 runc)完成真正的进程创建。
kubelet 的工作循环:syncPod
kubelet 通过 Informer 监听 API Server,过滤出 spec.nodeName 等于本节点名的 Pod 事件。每次事件到来,触发对应 Pod 的 syncPod 调用。syncPod 是幂等的调谐函数:比较期望状态(Pod spec)和当前状态(CRI 查询的容器状态),做出最小化的操作使二者一致。
第一步:Admit(准入检查)
kubelet 在本地做资源预检,确认节点的可分配资源能满足 Pod 的 requests。这是在调度器已经做过一次资源检查之后的再次确认——调度器基于 API Server 中的统计信息,kubelet 基于本地实际状态,两者可能存在轻微偏差(如节点上有其他未通过 API Server 调度的进程在消耗资源)。
Admit 失败时,Pod 保持 Pending 状态,kubelet 在本地日志中记录原因,但不会写入 API Server 的 Events(这与调度失败不同,调度失败会产生可见的 Event)。
第二步:挂载 Volume
kubelet 调用 VolumeManager,后者通过 CSI(Container Storage Interface)插件将 PVC 声明的存储卷挂载到本地路径。对于 configMap 和 secret 类型的 volume,kubelet 直接从 API Server 获取数据并写入 tmpfs 文件系统,挂载到容器内。
hostPath volume 直接引用节点文件系统路径,不需要额外驱动,但破坏了 Pod 的可移植性——这是不推荐在生产中使用 hostPath 的主要原因之一。
Volume 挂载必须在容器创建之前完成。如果 CSI 驱动响应超时或卷无法挂载,Pod 会停留在 ContainerCreating 状态,kubectl describe pod 的 Events 会显示具体的挂载错误。
第三步:拉取镜像
kubelet 通过 CRI ImageService.PullImage 接口向容器运行时发送拉取请求,运行时负责与镜像仓库交互。kubelet 本身不理解镜像格式,只知道镜像名称和拉取策略(imagePullPolicy)。
拉取进度体现在 status.containerStatuses[].state.waiting.message 字段。拉取失败后,kubelet 引入指数退避:先等 10 秒,失败后等 20 秒、40 秒……直到最长 300 秒。这个状态就是 ImagePullBackOff。
ImagePullBackOff 和 ErrImagePull 的区别:ErrImagePull 是第一次失败(正在拉取),ImagePullBackOff 是后续重试期间等待退避(暂时停止尝试)。两者都表示镜像拉取失败,只是所处阶段不同。
第四步:创建 Sandbox(pause 容器)
Sandbox 是 Pod 的基础设施层。kubelet 调用 CRI RunPodSandbox 接口,容器运行时创建一个特殊的 pause 容器:
- pause 容器只运行一个几乎什么都不做的进程(
/pause),其唯一职责是持有 Linux 命名空间(network namespace、IPC namespace、UTS namespace)。 - CNI 插件在这一步被调用。CNI 接受
netns文件路径,在该网络命名空间内配置网络接口(veth pair)、分配 Pod IP、设置路由规则。 - 所有后续容器(init container、应用容器)共享 pause 容器的 network namespace,这就是为什么同一 Pod 内的容器可以通过
localhost互相访问。
pause 容器崩溃意味着整个 Pod sandbox 销毁,所有共享该命名空间的容器都会消失。这就是为什么 pause 进程被设计得极其简单——几乎不可能崩溃。
第五步和第六步:启动容器
Init container 按顺序启动,每个调用 CRI CreateContainer + StartContainer。应用容器并行启动,调用相同的 CRI 接口但不等待彼此完成。
CRI CreateContainer 传入 ContainerConfig,包含镜像名、命令、环境变量、volume 挂载点、资源限制等信息。容器运行时(containerd)将这些信息转换为 OCI bundle(config.json + rootfs 目录),然后调用 OCI 运行时(runc)创建容器进程。
第七步:启动探针
所有应用容器启动后,kubelet 为每个配置了探针的容器启动探针 goroutine(Go 协程)。探针在 kubelet 进程内运行,周期性地对容器发起检查(exec/httpGet/tcpSocket),根据结果更新 status.containerStatuses[].ready 字段,进而影响 Pod 的 conditions.Ready。
CRI:kubelet 与运行时的标准接口
CRI 是 kubelet 与容器运行时之间的 gRPC 接口,定义在 k8s.io/cri-api(proto 文件)。任何实现了 CRI 的运行时都可以作为 Kubernetes 的节点运行时,这是 Kubernetes 支持 containerd、CRI-O 等多种运行时的基础。
CRI 分为两个服务:
ImageService — 管理镜像:
1 | |
RuntimeService — 管理 Sandbox 和容器:
1 | |
kubectl exec 的实现:API Server 收到 exec 请求后,通过 kubelet 的 streaming server 将请求转发,kubelet 调用 CRI Exec 接口获取流式连接 URL,将 WebSocket 连接直接桥接到容器运行时的流式端点。
containerd 内部组件链
containerd 是目前最主流的 Kubernetes CRI 实现。它的内部组织形成了一条职责分离的调用链:
1 | |
containerd-shim 的关键设计:shim 是连接 containerd 和容器进程的中间层。containerd daemon 重启时,shim 进程保持运行,容器进程的生命周期不受影响。这解决了 Docker 早期"daemon 重启杀死所有容器"的问题。shim 通过 io.containerd.runtime.v2.task 协议与 containerd 通信,负责:
- 代理 containerd 与容器进程之间的 I/O 流(stdin/stdout/stderr)
- 在容器进程退出后收集退出码,上报给 containerd
- 管理容器的 PID 文件
OCI bundle:runc 的输入是一个 OCI bundle 目录,包含:
config.json:容器的运行时配置(命名空间、cgroup、挂载点、进程配置、能力)rootfs/:容器文件系统(镜像层解压或 overlay 挂载点)
cgroup v2:资源隔离的内核实现
Kubernetes 从 1.25 起默认启用 cgroup v2。kubelet 为每个 Pod 创建 cgroup 层级,将容器的资源使用限定在 spec 定义的范围内。
1 | |
cpu.max 的格式是 quota period,表示在 period 微秒内最多使用 quota 微秒的 CPU 时间。500m(500 millicores)对应 100000 200000(100ms/200ms = 50%)。
memory.max 直接对应 limits.memory,超出时触发容器内的 OOM killer,杀死内存用量最大的进程。如果主进程被 OOM 杀死,kubelet 检测到容器退出,根据 restartPolicy 决定是否重启,reason 记录为 OOMKilled。
cgroup v2 相比 v1 的主要改进:统一层级(v1 各子系统分开),memory.oom.group(整组进程被 OOM 时一起杀死,而不是只杀一个进程),io.max 统一磁盘 IO 限制。
PLEG:Pod 状态感知的核心机制
PLEG(Pod Lifecycle Event Generator)是 kubelet 内部的状态同步机制。kubelet 无法在运行时收到容器变化的主动通知(不同于 watch API Server),只能通过轮询 CRI 来获知容器状态变化。
PLEG 的工作流程:
- 每秒调用 CRI
ListPodSandbox和ListContainers获取所有容器的当前状态快照。 - 与上一次快照对比,生成 lifecycle events(
ContainerStarted、ContainerDied、ContainerRemoved)。 - 每个 event 触发对应 Pod 的
syncPod调用,kubelet 根据新状态更新 API Server 中的 Pod status。
PLEG 有一个常见的告警:PLEG is not healthy,出现在 PLEG 轮询超时(默认 3 分钟内未完成一次完整列举)时。通常原因是容器运行时响应过慢(如 containerd 卡顿)或节点上容器数量过多。这个告警不会直接影响已运行的容器,但会导致 kubelet 无法及时感知容器状态变化。
可观察实验
使用 kind 创建本地集群并观察节点内部状态。
1 | |
用 crictl 观察容器(不用 docker)
1 | |
观察 cgroup 资源限制
1 | |
观察 containerd-shim 进程
1 | |
观察 pause 容器
1 | |
将实验映射回内部机制
通过 crictl 看到的每个 pod sandbox,对应 kubelet 的一次 RunPodSandbox CRI 调用。
crictl pods中的 STATE=Ready,表示 pause 容器正在运行,网络命名空间已由 CNI 配置好。crictl ps中的容器列表,是 kubelet 在RunPodSandbox之后调用CreateContainer+StartContainer的结果。- cgroup 层级中的
cpu.max和memory.max,是 kubelet 在调用CreateContainer时通过LinuxContainerResources字段传给 containerd 的,containerd 在创建 OCI bundle 时写入config.json,runc 在创建容器时通过clone系统调用设置 cgroup。
kubectl describe pod 中的 Events(如 Created container app、Started container app)是 kubelet 在 CRI 调用成功后通过 API Server 写入 etcd 的,每个 Event 对应一次 CRI 操作的完成。
当容器因 OOM 退出时,runc 检测到 memory.max 超出并触发 OOM kill,退出码为 137(128 + SIGKILL)。shim 收集退出码,上报给 containerd,containerd 通知 kubelet(通过 PLEG 轮询),kubelet 更新 status.containerStatuses[].lastTerminationState,设置 reason: OOMKilled,并根据 restartPolicy 决定是否重建容器。
模式提炼
kubelet 是"本地 reconciler"——把一个 Pod spec 持续翻译成 cgroup + namespace + OCI bundle 的组合,并通过 PLEG 轮询持续监控实际状态与期望状态的一致性。
三层接口的设计体现了关注点分离:
- kubelet ↔ 容器运行时:CRI gRPC(标准接口,运行时可替换)
- containerd ↔ OCI 运行时:OCI Runtime Spec(runc/kata/gVisor 可替换)
- kubelet ↔ 存储驱动:CSI(存储供应商可替换)
每一层接口都是一道隔离层,上层不需要知道下层的具体实现。这是 Kubernetes 支持多运行时、多存储方案的基础。
工程迁移表
| kubelet 机制 | 工程类比 |
|---|---|
| CRI gRPC 接口 | JDBC 驱动接口,解耦上层应用和数据库实现 |
| pause 容器持有 namespace | Unix 进程组共享文件描述符,socket pair |
| containerd-shim 解耦 | 守护进程 double-fork,父进程退出子进程继续运行 |
| cgroup v2 层级 | JVM -Xmx 内存限制,OS ulimit,线程池 maximumPoolSize |
| PLEG 轮询 | 连接池健康检查(testOnBorrow),Consul service health poll |
| SyncPod 幂等调谐 | systemd unit 状态机,Ansible idempotent task |
| OOM killer | JVM OutOfMemoryError + GC overhead limit,Go runtime GC pressure |
| imagePullBackOff | 指数退避重连(Exponential Backoff Retry),Circuit Breaker half-open |
常见误解
误解一:Docker 仍然是 Kubernetes 的默认容器运行时。
自 Kubernetes 1.24 起,dockershim 从 kubelet 代码中完全移除。现代 Kubernetes 节点使用 containerd(大多数发行版的默认值)或 CRI-O。docker CLI 命令在节点上不再能看到 Kubernetes 管理的容器;应使用 crictl 替代。即使节点上安装了 Docker,它也不参与 Kubernetes 容器的创建流程——Docker 自身使用 containerd,而 Kubernetes 直接对接 containerd 的 CRI 接口。
误解二:pause 容器没有实际作用,可以忽略。
pause 容器是整个 Pod 网络的基础。所有应用容器的网络接口都来自 pause 容器的 network namespace,Pod IP 地址分配给的是 pause 容器。如果 pause 容器崩溃(极罕见),整个 Pod sandbox 被销毁,所有容器的网络连接立即断开。理解 pause 容器也解释了为什么同 Pod 内的容器可以通过 localhost 通信:它们共享同一个网络命名空间,本质上在同一台"主机"上。
误解三:kubelet 直接调用 runc 创建容器。
kubelet 调用的是 CRI 接口(gRPC),接收方是 containerd(或 CRI-O)。containerd 再通过 containerd-shim 调用 runc。这是三层调用链,不是直接调用。kubelet 不知道也不需要知道底层用的是 runc 还是其他 OCI 运行时(如 kata-containers、gVisor)。
误解四:不设 limits 就没有资源隔离。
即使不设置 limits,容器仍然运行在 cgroup 内(BestEffort QoS 的 cgroup 层级),只是没有 cpu.max 和 memory.max 的上限约束。节点内存压力时,BestEffort 容器的 oom_score_adj 为 1000,是 OOM killer 的最高优先目标,比 Guaranteed(-997)高出近 2000 分。没有 limits 不是"没有隔离",而是"有隔离但没有上限保护"。
误解五:PLEG 告警意味着容器已经不正常。
PLEG is not healthy 告警表示 kubelet 的状态轮询超时,不代表容器本身出现问题。已运行的容器在告警期间继续运行,只是 kubelet 暂时无法感知状态变化。告警通常由容器运行时(containerd)的响应延迟引起,排查方向是 containerd 进程状态和节点上的容器数量。
练习
练习一:用 crictl 替代 docker 操作容器。
在 kind 集群的控制平面节点内,用 crictl pods 和 crictl ps 列出所有 Pod 和容器。找到 coredns 的容器 ID,用 crictl inspect 查看它的 OCI 配置,找到 linux.namespaces 字段确认它使用了哪些 Linux 命名空间。用 crictl logs 查看它的日志,与 kubectl logs -n kube-system <coredns-pod> 的输出对比。
练习二:验证 cgroup 资源限制。
创建一个设置了 resources.limits.cpu: "500m" 和 resources.limits.memory: "128Mi" 的 Pod。进入 kind 节点,找到该 Pod 的 UID,在 /sys/fs/cgroup/kubepods/ 下定位对应的 cgroup 目录。读取 cpu.max 和 memory.max,验证与 spec 设置的对应关系(500m 对应 100000 200000,128Mi 对应 134217728)。
练习三:观察 OOM Kill。
创建一个 limits.memory: "64Mi" 的 Pod,在容器内运行分配超过 64Mi 内存的命令(如 dd if=/dev/zero of=/dev/shm/test bs=1M count=100)。通过 kubectl describe pod 观察 lastState.terminated.reason: OOMKilled 和 restartCount 的变化。比较 BestEffort(无 limits)Pod 和 Guaranteed Pod 在节点内存压力时的 OOM 顺序差异。
练习四:追踪一次完整的 Pod 创建 CRI 调用序列。
启动 containerd 的 debug 日志(或在 kind 节点查看 containerd 日志),创建一个 Pod,在日志中找到对应的 RunPodSandbox、CreateContainer(init container)、CreateContainer(app container)、StartContainer 调用序列,确认调用顺序与 syncPod 流程一致。记录每个 CRI 调用的耗时,分析 Pod 启动时间分布在哪个阶段。
系列导航
- 00 核心概念导读
- 01 API Server 与声明式 API
- 02 etcd 与持久化
- 03 控制器模式与 Informer 机制
- 04 Pod 生命周期
- 05 调度器深入
- 06 kubelet 与容器运行时
- 07 网络模型与 CNI
- 08 Service 与 kube-proxy
- 09 Ingress 与 Gateway API
- 10 存储体系
- 11 配置与密钥管理
- 12 RBAC 与安全模型
- 13 资源管理与自动伸缩
- 14 CRD 与 Operator 模式
- 15 Helm 与应用打包
- 16 可观测性
- 17 生产集群运维
参考资料
- CRI API: https://kubernetes.io/docs/concepts/architecture/cri/
- containerd 官方文档: https://containerd.io/docs/
- kubelet Architecture: https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/
- CRI-API proto 定义: https://github.com/kubernetes/cri-api
- OCI Runtime Spec: https://github.com/opencontainers/runtime-spec
- cgroup v2 内核文档: https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html
- PLEG 设计文档: https://github.com/kubernetes/community/blob/master/contributors/design-proposals/node/pod-lifecycle-event-generator.md
