前两篇讲了调度(选节点)和 Pod 生命周期(状态机)。这一篇下到节点层:kubelet 在节点上把一个 Pod spec 变成真实运行的进程。

容器运行时曾经等于 Docker。Kubernetes 1.24 移除了 dockershim,此后所有节点必须使用实现了 CRI(Container Runtime Interface)的运行时:containerd、CRI-O 或其他。这次迁移对用户基本无感,但原理上有重要变化——kubelet 和运行时之间的通信从内嵌的特定实现变成了标准 gRPC 接口。

本文只问一个问题:从 pod.spec.nodeName 被写入,到容器进程在节点上跑起来,发生了什么?

节点内部全景图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
API Server → etcd (Pod.spec.nodeName = this-node)
│
▼
kubelet (watches own node's Pods via Informer)
┌────────────────────────────────────────────────────┐
│ SyncPod loop: │
│ 1. Admit — 本地资源检查 │
│ 2. Mount volumes — CSI driver (PVC/configMap/secret)│
│ 3. Pull image — CRI ImageService.PullImage │
│ 4. RunPodSandbox │
│ └─ pause container (holds netns + ipcns) │
│ └─ CNI plugin: allocate IP, setup veth pair │
│ 5. Init containers (sequential) │
│ └─ CRI: CreateContainer → StartContainer │
│ 6. App containers (parallel) │
│ └─ CRI: CreateContainer → StartContainer │
│ 7. Start probes (goroutines: liveness/readiness) │
└────────────────────────────────────────────────────┘
│
▼
containerd → containerd-shim-runc-v2 → runc → container process
cgroup: /sys/fs/cgroup/kubepods/pod<uid>/container<id>/
cpu.max=100000 200000 (50% of 1 CPU)
memory.max=134217728 (128Mi)

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
2
3
4
5
PullImage(image, auth, sandbox_config) → image_ref
ListImages(filter) → [image]
ImageStatus(image) → image_info
RemoveImage(image)
ImageFsInfo() → [filesystem_usage]

RuntimeService — 管理 Sandbox 和容器:

1
2
3
4
5
6
7
8
9
10
11
RunPodSandbox(config, runtime_handler) → pod_sandbox_id
StopPodSandbox(pod_sandbox_id)
RemovePodSandbox(pod_sandbox_id)

CreateContainer(pod_sandbox_id, config, sandbox_config) → container_id
StartContainer(container_id)
StopContainer(container_id, timeout)
RemoveContainer(container_id)

ExecSync(container_id, cmd, timeout) → stdout, stderr, exit_code
Exec(container_id, cmd, tty, stdin, stdout, stderr) → url (streaming)

kubectl exec 的实现:API Server 收到 exec 请求后,通过 kubelet 的 streaming server 将请求转发,kubelet 调用 CRI Exec 接口获取流式连接 URL,将 WebSocket 连接直接桥接到容器运行时的流式端点。


containerd 内部组件链

containerd 是目前最主流的 Kubernetes CRI 实现。它的内部组织形成了一条职责分离的调用链:

1
2
3
4
5
6
7
8
9
10
11
12
13
kubelet
│ (gRPC CRI)
▼
containerd daemon
│ (内部 API)
▼
containerd-shim-runc-v2 ← 每个 Pod sandbox 一个 shim 进程
│ (OCI runtime API)
▼
runc
│ (clone syscall + cgroup + namespace setup)
▼
container process (PID 1 in container)

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
2
3
4
5
6
7
8
9
10
/sys/fs/cgroup/
└── kubepods/
├── burstable/
│ └── pod<uid>/ ← Pod 级别 cgroup
│ ├── container<id>/ ← 容器级别 cgroup
│ │ ├── cpu.max = "100000 200000" (50% of 1 CPU)
│ │ └── memory.max = "134217728" (128Mi)
│ └── container<id2>/
├── guaranteed/
└── besteffort/

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 的工作流程:

  1. 每秒调用 CRI ListPodSandbox 和 ListContainers 获取所有容器的当前状态快照。
  2. 与上一次快照对比,生成 lifecycle events(ContainerStarted、ContainerDied、ContainerRemoved)。
  3. 每个 event 触发对应 Pod 的 syncPod 调用,kubelet 根据新状态更新 API Server 中的 Pod status。

PLEG 有一个常见的告警:PLEG is not healthy,出现在 PLEG 轮询超时(默认 3 分钟内未完成一次完整列举)时。通常原因是容器运行时响应过慢(如 containerd 卡顿)或节点上容器数量过多。这个告警不会直接影响已运行的容器,但会导致 kubelet 无法及时感知容器状态变化。


可观察实验

使用 kind 创建本地集群并观察节点内部状态。

1
2
3
4
5
6
7
8
9
# 创建 kind 集群
kind create cluster --name kubelet-demo

# SSH 进控制平面节点
docker exec -it kind-control-plane bash

# 安装 crictl(crictl 是 CRI 的命令行工具,替代 docker CLI)
# kind 节点通常已预装
crictl --runtime-endpoint unix:///run/containerd/containerd.sock version

用 crictl 观察容器(不用 docker)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 列出所有 Pod sandbox
crictl pods

# 输出示例:
# POD ID CREATED STATE NAME NAMESPACE ATTEMPT
# 3f8a12c4e9ab 2 minutes ago Ready coredns-xxx kube-system 0

# 列出所有容器
crictl ps

# 查看容器详情(OCI config 等)
crictl inspect <container-id>

# 查看容器日志
crictl logs <container-id>

# 在容器内执行命令(等价于 docker exec)
crictl exec -it <container-id> sh

观察 cgroup 资源限制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 先找到目标 Pod 的 UID
kubectl get pod <pod-name> -o jsonpath='{.metadata.uid}'

# 查看 cgroup(在节点内)
POD_UID="<pod-uid>"
ls /sys/fs/cgroup/kubepods/burstable/pod${POD_UID}/

# 查看 CPU 限制
cat /sys/fs/cgroup/kubepods/burstable/pod${POD_UID}/*/cpu.max

# 查看内存限制
cat /sys/fs/cgroup/kubepods/burstable/pod${POD_UID}/*/memory.max

# 查看实际内存用量
cat /sys/fs/cgroup/kubepods/burstable/pod${POD_UID}/*/memory.current

观察 containerd-shim 进程

1
2
3
4
5
6
7
8
9
# 在节点内查看 shim 进程
ps aux | grep containerd-shim

# 每个 shim 对应一个 Pod sandbox,参数中包含 pod sandbox ID
# 例:containerd-shim-runc-v2 -namespace k8s.io -id 3f8a12c4e9ab ...

# 查看 CNI 配置
cat /etc/cni/net.d/*.conf
# 在 kind 中通常是 kindnet 或 flannel 配置

观察 pause 容器

1
2
3
4
5
6
# 找到 pause 容器(镜像名包含 pause)
crictl ps -a | grep pause

# 或通过 crictl pods 列出 sandbox,再用 inspect 查看
crictl inspectp <pod-sandbox-id> | jq '.info.runtimeSpec.linux.namespaces'
# 输出显示 sandbox 持有的 network、ipc、uts namespace 路径

将实验映射回内部机制

通过 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 启动时间分布在哪个阶段。


系列导航


参考资料