前两篇讲了调度(选节点)和 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 声明的存储卷挂载到本地路径。对于 configMapsecret 类型的 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

ImagePullBackOffErrImagePull 的区别: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 ListPodSandboxListContainers 获取所有容器的当前状态快照。
  2. 与上一次快照对比,生成 lifecycle events(ContainerStartedContainerDiedContainerRemoved)。
  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.maxmemory.max,是 kubelet 在调用 CreateContainer 时通过 LinuxContainerResources 字段传给 containerd 的,containerd 在创建 OCI bundle 时写入 config.json,runc 在创建容器时通过 clone 系统调用设置 cgroup。

kubectl describe pod 中的 Events(如 Created container appStarted 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.maxmemory.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 podscrictl 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.maxmemory.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: OOMKilledrestartCount 的变化。比较 BestEffort(无 limits)Pod 和 Guaranteed Pod 在节点内存压力时的 OOM 顺序差异。

练习四:追踪一次完整的 Pod 创建 CRI 调用序列。

启动 containerd 的 debug 日志(或在 kind 节点查看 containerd 日志),创建一个 Pod,在日志中找到对应的 RunPodSandboxCreateContainer(init container)、CreateContainer(app container)、StartContainer 调用序列,确认调用顺序与 syncPod 流程一致。记录每个 CRI 调用的耗时,分析 Pod 启动时间分布在哪个阶段。


系列导航


参考资料