文章比较长,6000+字,可收藏后分多次观看。
上一篇聊完 Worker Node 架构,留了个坑——kubelet 怎么知道有新 Pod 分给自己,拿到任务后又怎么一步步把容器跑起来。这篇填坑。
kubelet 源码不像 API Server 那样有清晰的请求处理链路,它更像一个死循环——收到事件就干活,干完继续等下一个。社区管这个叫 SyncLoop,是 kubelet 的心脏。
有些地方我可能理解得不够透彻,源码版本以 K8s 1.36.1 为基准,如果有误欢迎指出。
上一篇说过,Scheduler 干的事就两件:选一个 Node,把 spec.nodeName写回 API Server。它不拉镜像、不建容器、不配网络。一个 Pod 从 YAML 变成运行进程,Scheduler 只负责了"去哪里"这一步。
那"谁来干"呢?答案是 kubelet。
当 Scheduler 把绑定结果写回 API Server 之后,Pod 的 spec.nodeName就变成了具体值,比如 worker01。但这时候容器还没影——镜像没拉、Sandbox 没建、网络没配。接下来要发生的事情,全靠 worker01上那个 kubelet。
那 kubelet 怎么知道有活来了?它不会每秒去轮询 API Server——那节点多了 API Server 早就扛不住了。它用的是 Watch 机制。
在讲 Watch 之前,得先说一个更根本的东西。
Kubernetes 是声明式系统。你写一个 YAML 说"我要 3 个 nginx 副本",API Server 把这个期望状态存进 etcd。然后有一堆组件负责让"实际状态"靠近期望状态。Controller Manager 干这事,kubelet 也干这事。
kubelet 的工作模式可以简化成一句话:不断比较期望状态和实际状态,有差异就去消除差异。期望状态来自 API Server(PodSpec 说要一个 nginx 容器、资源限制多少、挂什么卷),实际状态来自容器运行时(containerd 报告容器在跑还是挂了)。
这个循环,社区叫 Reconcile Loop,也就是调谐循环。它是 Kubernetes 的核心设计思想——不只是 kubelet 在用,每一个 Controller 都有自己的调谐循环。区别在于 kubelet 的调谐对象是本节点的 Pod,粒度更细、实时性要求更高。
理解了这个,后面 SyncLoop 的设计就顺理成章了。
整了个简化的结构,大概长这样:上面是 API Server,提供期望状态。下面是 Container Runtime(containerd),提供实际状态。中间的 SyncLoop 不停地把两边对齐。
PLEG 是旁边那个独立模块,全称 Pod Lifecycle Event Generator,专门负责感知容器侧的变化。这个后面会单独讲。
中间的链路是:Pod Manager(维护本节点 Pod 的期望状态)→ SyncLoop(核心循环,接收各类事件)→ PodWorkers(按 Pod 维度串行处理)→ SyncPod(单个 Pod 的创建/更新/删除逻辑)→ Container Runtime(通过 CRI 调用 containerd)。

kubelet 启动时,会通过一个叫 Reflector 的组件跟 API Server 建立连接。Reflector 这个名字来自 client-go 的 k8s.io/client-go/tools/cache包,它的工作模式是两步:先 List,后 Watch。
第一步 List:kubelet 启动时先调一次 API Server 的 List 接口,把当前分配给本节点的所有 Pod 全量拉下来,存到本地缓存里。这一步是为了建立基准——我得先知道现在有哪些 Pod 是我的活。
第二步 Watch:全量拉完之后,Reflector 跟 API Server 建立一个长连接(HTTP/2 的 Server-Sent Events),从 List 返回的 ResourceVersion 开始监听后续变更。API Server 那边有任何跟本节点 Pod 相关的增删改,都会通过这个连接推过来。
所以 kubelet 不轮询。它跟 API Server 之间始终有一条 Watch 连接着,有变化推过来,没变化就安安静静等着。这比每秒查一次省资源得多——N 个节点就是 N 条长连接,而不是 N × 每秒一次的 HTTP 请求。
Watch 推过来的事件类型很直接:ADD(新 Pod 被调度到本节点)、UPDATE(Pod 配置变了,比如改了镜像版本或资源限制)、REMOVE(Pod 被删除)。
这些事件被封装成 PodUpdate结构,通过一个 Channel 传给 SyncLoop。到这一步,"发现任务"的环节就完成了。

这是全文重点。
SyncLoop 的入口在 kubelet 源码的 pkg/kubelet/kubelet.go里,函数签名大概是 func (kl *Kubelet) syncLoop(...)。我读源码时发现这个函数的逻辑其实不复杂——它是一个 select 循环,监听多个 Channel,谁来了消息就处理谁。
但到底监听哪些 Channel?我 originally 以为就一路——API Server 推过来的事件。实际翻了源码发现是四路。
第一路是 PodConfig Channel,来源是 API Server。就是前面说的 Reflector 把 Watch 事件转成 PodUpdate推过来的那个。ADD、UPDATE、DELETE 事件都从这里进来。这是最主要的一路。
第二路是 PLEG Channel,来源是容器运行时。容器自己挂了、重启了、OOM 了,PLEG 会感知到并通知 SyncLoop。这路事件不是 API Server 推的,是 kubelet 自己从容器运行时那边探查到的。
第三路是 Runtime Updates,来自 CRI Runtime。比如 containerd 那边容器状态变了,也会通过这条路通知。跟 PLEG 有点像,但信号来源不同。
第四路是定时 Sync,一个周期性的定时器。即使没有任何事件触发,kubelet 也会每隔一段时间重新同步一次所有 Pod,确保没有遗漏。这个间隔由 --sync-frequency控制,默认 1 分钟。你可以理解为兜底机制——万一前面三路漏了什么,第四路兜住。

四路汇总到 SyncLoop 的 select 里,谁来了处理谁。这个设计的好处是——不管事件来自 API Server 还是容器运行时,处理逻辑是统一的,都走 SyncPod 这条路。
我拿一个新建 Pod 的 ADD 事件走一遍完整流程,这条链路是理解 kubelet 的主线。
整个过程大致是这样的:API Server Watch 推送 Pod ADD 事件 → Reflector 把事件转成 PodUpdate 通过 Channel 传给 SyncLoop → SyncLoop 调用 HandlePodAdditions 更新 Pod Manager 的期望状态 → 把 Pod 丢给 PodWorkers → PodWorkers 调用 SyncPod → SyncPod 通过 CRI 调 containerd 创建 Sandbox、配网络、拉镜像、建容器、启动 → containerd 交给 runc 创建 Namespace 和 cgroup → 容器进程跑起来。
注意这一步——SyncLoop 不直接创建容器。它把活派给 PodWorkers,自己回去继续等下一个事件。这个设计是有原因的。
我在自己集群上跑了一遍验证。在 master01 上创建 Pod,然后删掉,看 Events 的完整生命周期:
kubectl run nginx-test --image=m.daocloud.io/docker.io/library/nginx:alpine --restart=Never
# 等 Pod Running 后
kubectl delete pod nginx-test
kubectl get events --sort-by='.lastTimestamp' --field-selector involvedObject.name=nginx-test
输出:
LAST SEEN TYPE REASON OBJECT MESSAGE
7m7s Normal Scheduled pod/nginx-test Successfully assigned default/nginx-test to worker01
7m6s Normal Pulled pod/nginx-test Container image "..." already present on machine
7m6s Normal Created pod/nginx-test Container created
7m6s Normal Started pod/nginx-test Container started
11s Normal Killing pod/nginx-test Stopping container nginx-test
这五行事件刚好对应了 kubelet 干的事:Scheduled 是 Scheduler 干的(选了 worker01),后面的 Pulled、Created、Started 是 kubelet 干的(SyncPod 里的三步),Killing 是删除时 kubelet 停容器。注意时间戳——Pulled、Created、Started 三步几乎在同一秒完成,说明镜像本地已有(Pull 被跳过了),Create 和 Start 之间间隔极短。
我一开始想不通这一层。既然事件来了,SyncLoop 直接调 CRI 创建不就行了,中间加一个 PodWorkers 干什么?
后来想明白了,核心原因是并发控制。
假设一个 Pod 同时收到 UPDATE 和 DELETE 两个事件——先更新配置再删除。如果 SyncLoop 直接处理,可能 UPDATE 还没创建完新容器,DELETE 就来了,两个操作交叉执行,状态就乱了。
PodWorkers 的设计是:每个 Pod 有一个独立的 worker goroutine,这个 Pod 的所有事件按顺序排队处理。UPDATE 先处理完,再处理 DELETE。同一个 Pod 的事件是串行的,不同 Pod 之间是并行的。
这解决了一个问题但没解决所有问题。如果同一个 Pod 的事件堆积太多——比如快速更新了 10 次镜像版本——PodWorkers 会做一次 dedup,只处理最新的事件,中间的丢掉。这避免了无意义的重复创建。
所以 SyncLoop 和 PodWorkers 的分工是:SyncLoop 负责收事件和派活,PodWorkers 负责按 Pod 维度串行执行。一个调度,一个执行。
PodWorkers 的 worker goroutine 拿到事件后,最终调用的就是 SyncPod。这个函数是真正执行 Pod 创建/更新/删除逻辑的地方。
SyncPod 内部有几个关键步骤,按顺序来。
第一步是创建 Pod Sandbox。kubelet 通过 CRI 调用 RunPodSandbox,让 containerd 创建一个基础容器(就是那个 Pause 容器)。这个 Sandbox 提供了 Pod 级别共享的 Network Namespace、IPC Namespace 和 UTS Namespace。CNI 插件也在这阶段介入——给 Pod 分 IP、建 veth pair、配路由规则。上一篇讲过这层,不展开了。
第二步是创建 Init Container。如果 PodSpec 里定义了 Init 容器,kubelet 会严格按顺序逐个创建和启动。每个 Init 容器必须成功退出(exit code 0),才会创建下一个。任何一个 Init 容器失败,整个流程卡住,不会创建业务容器。
第三步是创建业务容器。Init 容器全部就绪后,kubelet 按 PodSpec 里 containers数组的顺序,对每个容器执行三步:PullImage拉镜像(如果本地已有且 imagePullPolicy不是 Always 则跳过)、CreateContainer准备 rootfs 和挂载、StartContainer启动。
containerd 拿到这些 CRI 请求后,把镜像转换成 OCI Runtime Spec,交给 runc。runc 才是真正调 Linux 系统调用的那一层——创建 Namespace、设 cgroup、启动容器进程。这跟上一篇讲的链路一致。
在 worker01 上用 crictl看一下实际跑着的容器:
sudo crictl ps
CONTAINER IMAGE CREATED STATE NAME POD ID POD NAMESPACE
012ced3c3b740 ea51152ef8c48 4 minutes ago Running nginx-test ccf4b42657982 nginx-test default
298150763592b 78282b5844742 4 weeks ago Running kube-proxy bc4219b4ba4b6 kube-proxy-npt2l kube-system
074747d792715 e74b80806e1da 6 weeks ago Running node-exporter bac3a27f442a2 prometheus-...-node-exporter monitoring
0c43890e1f23f f3bc59d46b56d 2 months ago Running calico-node f073235e515e8 calico-node-8tp5d kube-system
注意第一列 Container ID 和倒数第三列 Pod ID 不是一回事。crictl ps里每个容器有自己的 Container ID(比如 012ced3c3b740),但它们属于不同的 Pod Sandbox(Pod ID ccf4b42657982)。用 crictl pods能看到 Sandbox 层面:
sudo crictl pods
POD ID CREATED STATE NAME NAMESPACE
ccf4b42657982 5 minutes ago Ready nginx-test default
bc4219b4ba4b6 4 weeks ago Ready kube-proxy-npt2l kube-system
bac3a27f442a2 6 weeks ago Ready prometheus-prometheus-node-exporter-762nx monitoring
f073235e515e8 2 months ago Ready calico-node-8tp5d kube-system
4a0ac310d069a 2 months ago NotReady calico-node-8tp5d kube-system
最后一个 calico-node 的 Sandbox 状态是 NotReady——这是之前重启留下的旧 Sandbox,新的那个 f073235e515e8已经 Running 了。kubelet 的 PLEG 循环会发现这种"幽灵 Sandbox"并清理掉。
另外提一句,crictl直接跑会报权限错误,得加 sudo。如果不想每次敲 sudo,可以配 /etc/crictl.yaml指定 runtime endpoint,或者在用户组里加 containerd 权限。我偷懒直接 sudo 了。
再往下挖一层,看 SyncPod 设的 cgroup 限制实际是什么。nginx-test2 这个 Pod 我没配 resource limits,看看 kubelet 给它设了什么:
sudo crictl inspect 731ceb5514b1b | jq .info.runtimeSpec.linux.resources
{
"cpu": {
"period": 100000,
"shares": 2
},
"devices": [
{ "access": "rwm", "allow": false }
],
"memory": {},
"unified": {
"memory.oom.group": "1",
"memory.swap.max": "0"
}
}
cpu.shares只有 2,这是最低权重——意味着这个容器在 CPU 竞争时几乎拿不到资源。memory是空对象,没设上限。memory.swap.max为 0 是关掉 swap。devices.allow: false是默认禁止访问所有设备。这些值就是 PodSpec 里没配 resources 时 kubelet 的默认 cgroup 设置。如果你在 YAML 里写了 resources.limits.cpu: 500m,这里会变成 cpu.quota: 50000和 cpu.period: 100000(50ms/100ms = 0.5 核)。
到这里,Pod 的容器就算跑起来了。但 kubelet 的工作没完——它还得持续监控这些容器的状态。这就引出了 PLEG。
创建完容器之后,kubelet 面临一个问题:怎么知道容器还活着?
API Server 那边 Push 过来的事件只反映"期望状态"的变化。但容器自己挂了、OOM 了、被手动 kill 了——这些是容器运行时那边发生的事,API Server 不会主动通知 kubelet。
kubelet 需要一双眼睛盯着容器侧。这就是 PLEG。
PLEG 全称 Pod Lifecycle Event Generator,直译过来是"Pod 生命周期事件生成器"。它的核心工作很简单:定期去问 containerd,"你现在管的那些容器都什么状态?"然后跟自己上次记录的状态对比,有变化就生成事件,塞进 Channel 通知 SyncLoop。
具体机制是这样的。PLEG 内部有一个 relist循环,默认每 1 秒执行一次(源码里写死的,不是配置项)。每次 relist 做三件事。
第一步,调用 CRI 的 RuntimeService.ListPodSandbox和 ListContainers,拿到当前所有 Sandbox 和容器的真实状态。第二步,跟自己内存里存的上一次状态对比——看看有没有容器从 Running 变成 Exited,或者从 Exited 变成 Running(重启了),或者新出现了容器,或者容器消失了。第三步,有差异就生成 PodLifeCycleEvent,塞进 PLEG 的 Channel,SyncLoop 那边的 select 监听到了这个 Channel,就知道"容器侧有变化",于是重新触发 SyncPod,让实际状态重新对齐期望状态。

比如一个 nginx 容器 OOM 挂了,PLEG 发现它从 Running 变成 Exited,通知 SyncLoop,SyncLoop 触发 SyncPod。SyncPod 发现"期望状态是 Running,实际是 Exited",于是重新拉镜像、创建容器、启动。这就是 K8s 里容器自动重启的底层机制——不是 Controller Manager 在远程重启的,是本节点 kubelet 通过 PLEG 感知后自己处理的。我一开始也以为是 Controller Manager 干的,翻完源码才明白根本不是那么回事。
有个实操发现值得说一下。我最初想从 kubelet 日志里直接看 PLEG 的工作痕迹:
sudo journalctl -u kubelet --since "5 min ago" | grep -i pleg
结果是空的。默认日志级别下 PLEG 不输出任何日志。这跟我想的不一样——不是说 PLEG 每秒都在干活吗?确实在干,但它干活时不会特意喊一声。kubelet 的日志分级,默认级别(--v=0)只输出重要事件,PLEG 的 relist 属于常态化操作,不算"事件",只有出问题(比如 relist 超时)时才会输出日志。要看 PLEG 的日常工作痕迹,得把 kubelet 日志级别调到 --v=4以上。
但生产环境别随便调 --v,日志量会暴增。你只需要知道:看不到 PLEG 日志不等于 PLEG 没在工作。反过来,如果看到了 PLEG is not healthy,那才是真出事了。
前面看了创建,顺手看了删除。把 nginx-test2 删掉后,worker01 上的 kubelet 日志这样走:
sudo journalctl -u kubelet --since "10 min ago" | grep -E "RemoveContainer|orphaned"
I0724 22:38:50.306005 scope.go:122] "RemoveContainer" containerID="731ceb5514b1b..."
I0724 22:38:50.313729 scope.go:122] "RemoveContainer" containerID="731ceb5514b1b..."
E0724 22:38:50.314307 remote_runtime.go:569] "ContainerStatus from runtime service failed"
err="rpc error: code = NotFound desc = ... not found"
I0724 22:38:50.623046 kubelet_volumes.go:161] "Cleaned up orphaned pod volumes dir"
podUID="c9c0a757-..." path="/var/lib/kubelet/pods/c9c0a757-.../volumes"
删除流程跟创建是反过来的:先 UnmountVolume 卸载挂载,再 TearDown 拆 Volume,Volume detached 之后 RemoveContainer 删容器。
注意 RemoveContainer 出现了两次——第二次是 PLEG 的 relist 发现容器没了又触发了一次清理,但这次 ContainerStatus返回 NotFound,因为容器已经被删了。这不算错误,kubelet 遇到 NotFound 就知道"已经清理完了",不再重试。最后 Cleaned up orphaned pod volumes dir清理 Pod 的 Volume 目录,整个删除流程结束。
这段日志里的时序值得留意:UnmountVolume 在 22:38:49,RemoveContainer 在 22:38:50,Cleaned up volumes 在 22:38:50。从删除到清理完毕不到 1 秒。但如果你 Volume 用的网络存储(NFS、Ceph),这个时间可能拉长,这时候 PLEG relist 就可能超时。
生产环境里你可能遇到过 Node 突然变 NotReady,kubectl describe node里看到一行:
KubeletNotReady PLEG is not healthy: pleg was last checked ...
这就是 PLEG 出问题了。
PLEG 的 relist 循环每秒执行一次,但 containerd 响应 ListContainers的速度不是 kubelet 能控制的。如果容器数量多、containerd 负载高、或者底层运行时卡住了,一次 relist 可能要好几秒甚至几十秒。kubelet 有个超时阈值(默认 3 分钟),如果连续 3 分钟 PLEG 没成功完成一次 relist,kubelet 就认为 PLEG 不健康,Node 标记为 NotReady。
Node 一旦 NotReady,Scheduler 不再往这个节点调度新 Pod。已有的 Pod 还在跑——容器没挂,只是 kubelet 失去了感知容器状态的能力。这时候如果你手动 crictl ps会发现容器都在,但 kubelet 上报给 API Server 的状态已经不正常了。
PLEG 不健康的根因通常是容器运行时层面的问题——containerd 响应慢、容器数量过多、底层存储卡顿(containerd 要读 overlayfs 的状态)。排查方向是看 kubelet 日志和 containerd 日志,不是去看 API Server。
理论说完了,落到实际排查。两个最典型的故障场景。
Pod 一直卡在 ContainerCreating。
上一篇末尾提过这个场景,这里从 SyncLoop 的角度再看一遍。Pod 卡在 ContainerCreating 意味着 SyncPod 流程里某一步走不下去了。kubectl describe pod的 Events 部分会告诉你卡在哪。
如果是 ImagePull 卡住,可能镜像仓库不通、Tag 写错了、认证没配。看 Events 里的 Failed to pull image那行。如果是 Volume 挂载失败,通常是 PVC 没绑定成功、StorageClass 不匹配、或者 CSI 插件有问题,Events 里会有 MountVolume.SetUp failed之类的信息。如果是网络配置失败,CNI 插件报错,这个 kubectl describe不一定看得到,得去 Node 上看 kubelet 日志:journalctl -u kubelet -f,搜 CNI 相关的报错。
上一篇提过,控制面组件(API Server、Controller Manager、Scheduler)是静态 Pod,不走 systemd,所以 journalctl -u kube-apiserver查不到日志。但 kubelet 自己是个 systemd 服务,journalctl -u kubelet可以直接用。这一点别搞混。
Node 突然变 NotReady。
第一时间看 kubelet 日志。如果看到 PLEG is not healthy,按上面说的排查容器运行时。如果看到的是 container runtime not ready,说明 kubelet 跟 containerd 的通信出问题了——systemctl status containerd查一下。
还有一种情况是 Node 心跳超时。kubelet 默认每 10 秒向 API Server 上报一次 Node 状态,如果 API Server 连续 40 秒没收到心跳(node-monitor-grace-period默认 40 秒),Node 标记为 NotReady。这种通常是网络问题或者 kubelet 本身挂了——systemctl status kubelet一看就知道。

从 Watch 到容器创建到 PLEG 监控,完整链路串起来是这样:API Server 存了 Pod 的期望状态,kubelet 通过 Reflector 的 List + Watch 拿到本节点的 Pod 事件,进入 SyncLoop。SyncLoop 把事件派给 PodWorkers,PodWorkers 按 Pod 维度串行调用 SyncPod。SyncPod 通过 CRI 让 containerd 创建 Sandbox、拉镜像、建容器。容器跑起来之后,PLEG 每秒轮询一次容器状态,发现变化就通知 SyncLoop,触发新一轮 SyncPod。这个循环不停运转,Pod 的实际状态就一直被往期望状态上拉。
上一篇我们停在"六层链路"的架构理解层面——从 YAML 到 API Server 到 Scheduler 到 kubelet 到 containerd 到 Linux 内核。这篇往下钻了一层:kubelet 内部怎么运转的,SyncLoop 怎么调度,PodWorkers 为什么存在,SyncPod 干了什么,PLEG 怎么让 kubelet 感知真实世界。
再往下就是 CRI 的具体接口实现了。下一篇拆 CRI——kubelet 调 containerd 的那层 gRPC 接口具体长什么样,SyncPod 里那些 CRI 调用的参数和返回值是什么意思。containerd 内部怎么建容器再往后放一篇。