Kubernetes 系列 · 第一篇

K8s 控制平面:谁也不直接找谁

Kubernetes 的控制平面看起来有好几个"角色"——apiserver、etcd、scheduler、controller-manager,分工不同。但支撑这一整套系统的,其实是一条特别硬的规则:没有任何一个组件会直接调用另一个组件。scheduler 不会给 kubelet 打电话说"这个 Pod 归你了",kubelet 也不会去问 scheduler "有没有新任务"。所有人都只做一件事:盯着 apiserver,看 etcd 里的数据变了没有。

这一篇不依赖能跑起来的集群,而是直接读 Kubernetes v1.37.0 的真实源码,把"一个 Pod 从 kubectl apply 到真正跑起来"这条链路上,每个组件到底在等什么、看到了什么、看不到什么,一步步过一遍。

4 处真实源码
reflector 的 List-Watch、kubelet 的字段选择器、scheduler 的客户端过滤、Binding 子资源写入
0 次组件间直接调用
全部经过 apiserver 中转,这是贯穿全文的核心结论

控制平面有哪些角色

先认清每个组件具体是谁,后面追踪 Pod 的生命周期时才知道"现在轮到谁在动"。

组件做什么
kube-apiserver唯一对外暴露的入口,处理认证、鉴权、准入控制,是所有读写 etcd 的唯一通道——包括其它控制平面组件自己也不能绕过它直接碰 etcd。
etcd唯一的数据存储,一个基于 Raft 的分布式 KV。整个集群的状态(所有 Pod、Node、Service……的定义和状态)都在这里,除了 apiserver,没有别的组件直接连它。
kube-scheduler决定新 Pod 该跑在哪个 Node 上。本质是 apiserver 的一个客户端:监听未调度的 Pod,算完之后把结果写回 apiserver。
kube-controller-manager一堆 controller 的集合(Deployment controller、Node controller……),每个都在做同一件事:观察"现状"和"期望状态"的差距,然后调用 apiserver 把现状往期望状态上拉。
kubelet跑在每个 Node 上(严格说不算控制平面,是节点组件),只关心"分配给我这个 Node 的 Pod",负责把它们真正跑起来、汇报状态。
共同点

都是 apiserver 的客户端

scheduler、controller-manager、kubelet,包括你敲的 kubectl,在架构图上地位是一样的——都只是"连到 apiserver 上的一个客户端",没有谁比谁更特殊。

交互方式

List-Watch,不是轮询

每个客户端先做一次 List 拿到当前全量数据和一个 resourceVersion,再从这个版本开始 Watch,之后只推增量变化——不用短轮询,也不会错过变化。

真实源码:所有人共用的同一套机制——List-Watch

client-go 的 Reflector 是这套机制的通用实现,scheduler、kubelet、controller-manager 内部全在用它,不是各自造轮子。

staging/src/k8s.io/client-go/tools/cache/reflector.go · kubernetes @ v1.37.0, L467 // ListAndWatchWithContext first lists all items and get the resource version at the moment of call, // and then use the resource version to watch. func (r *Reflector) ListAndWatchWithContext(ctx context.Context) error { ... if fallbackToList { err = r.list(ctx) if err != nil { return err } } logger.V(2).Info("Caches populated", ...) return r.watchWithResync(ctx, w) }

先 List 拿一份完整快照(和一个此刻的 resourceVersion),再从这个版本开始 Watch——这样即使 Watch 连接中途断了,重连时用记住的 resourceVersion 继续接着看,不会有"断线期间的变化全部丢掉"这种事。这是 K8s 里几乎所有"谁在关心什么资源"的通用底层实现。

真实源码:kubelet 只看得到自己的 Pod

kubelet 的 watch 带了一个服务端过滤条件——不是"apiserver 把所有 Pod 都推过来,kubelet 自己再挑",而是 apiserver 压根不会把无关的 Pod 发过来。

pkg/kubelet/config/apiserver.go · kubernetes @ v1.37.0, L35 // NewSourceApiserver creates a config source that watches and pulls from the apiserver. func NewSourceApiserver(logger klog.Logger, c clientset.Interface, nodeName types.NodeName, ...) { lw := cache.NewListWatchFromClient(c.CoreV1().RESTClient(), "pods", metav1.NamespaceAll, fields.OneTermEqualSelector("spec.nodeName", string(nodeName))) ... }

fields.OneTermEqualSelector("spec.nodeName", nodeName) 是个字段选择器,作为 Watch 请求的一部分发给 apiserver——过滤发生在服务端。一个 1000 个 Pod 的集群,某个 Node 上的 kubelet 大概率只需要关心其中几十个,字段选择器让它压根不用收到、也不用处理另外几百个跟自己无关的 Pod 事件。

真实源码:scheduler 反而是"来者不拒",自己过滤

和 kubelet 不一样,scheduler 的 Pod watch 没有字段选择器——它先收下所有 Pod 事件,再用代码自己判断要不要处理。

pkg/scheduler/eventhandlers.go · kubernetes @ v1.37.0, L458 // assignedPod selects pods that are assigned (scheduled and running). func assignedPod(pod *v1.Pod) bool { return len(pod.Spec.NodeName) != 0 } func (sched *Scheduler) addPod(obj interface{}) { pod, ok := obj.(*v1.Pod) ... if responsibleForPod(pod, sched.Profiles) && (!assignedPod(pod) || isPodDeferred) { sched.addPodToSchedulingQueue(pod) } if assignedPod(pod) { sched.addAssignedPodToCache(pod) } }
这不是疏忽,是真实的设计取舍:kubelet 只需要"跟自己相关"的一小撮 Pod,字段选择器很划算;scheduler 恰恰需要看到全局状态才能做出合理的调度决策(比如统计每个 Node 上已经跑了多少 Pod、资源占用多少),它没法用一个简单的字段选择器表达"我需要全局视图",干脆全收下来,在内存里自己维护缓存和判断逻辑。

真实源码:scheduler 怎么把结果"告诉"kubelet——它不告诉,它写回去

调度决策做完之后,scheduler 并不会去连 kubelet 的某个接口,而是把决策包成一个 Binding 对象,像发起任何别的 API 请求一样 POST 给 apiserver。

pkg/scheduler/framework/plugins/defaultbinder/default_binder.go · kubernetes @ v1.37.0, L51 // Bind binds pods to nodes using the k8s client. func (b DefaultBinder) Bind(ctx context.Context, state fwk.CycleState, p *v1.Pod, nodeName string) *fwk.Status { binding := &v1.Binding{ ObjectMeta: metav1.ObjectMeta{Namespace: p.Namespace, Name: p.Name, UID: p.UID}, Target: v1.ObjectReference{Kind: "Node", Name: nodeName}, } ... err := util.BindPod(ctx, b.handle.ClientSet(), binding) ... }

util.BindPod 用的是普通的 clientset,和 kubectl、controller 用的是同一套客户端库——scheduler 在这一刻和其它任何 apiserver 客户端没有区别。这次写入实际改的是 Pod 的 spec.nodeName 字段。而这个字段,恰好就是上一节 kubelet 那条 watch 用来过滤的字段——写完这一下,kubelet 的 watch 才第一次开始"看得见"这个 Pod。

演示:一个 Pod 从创建到跑起来,两个 watcher 各自看到了什么

左边是 scheduler 的收件箱(无过滤,来者不拒),右边是 kubelet 的收件箱(带字段选择器)。同一次 apiserver 写入,两边收到的东西可能完全不一样。

控制平面事件流未开始
点击"下一步"或"播放"开始。
apiserver / etcd 里 Pod 的当前状态
(尚未创建)
scheduler 收件箱无字段选择器
kubelet(node-1)收件箱spec.nodeName==node-1

事件数据由 Python 脚本生成,精确复现上面两段真实源码里的过滤规则(kubelet 按 spec.nodeName 服务端过滤、scheduler 无过滤 + 客户端 assignedPod() 判断),并用一套独立重新实现的过滤逻辑逐事件重放校验过,断言全部通过后才用于渲染。

这条链路说明了什么

kubectl apply 到 Pod 真正 Running,中间没有任何一步是"A 组件调用 B 组件的接口"。每一步都是"某个组件观察到 apiserver 里的数据变了,做点什么,再把结果写回 apiserver"。这样设计的好处很直接:

组件可替换

换个调度器,不用改 kubelet 一行代码

只要新调度器也遵守"读 apiserver、写 Binding"这套协议,kubelet 完全无感——这也是自定义调度器、多调度器共存能够成立的原因。

状态可恢复

任何组件重启,靠 List 就能恢复现场

没有组件在内存里维护"只有它自己知道"的关键状态——真相永远在 etcd 里,重启后重新 List 一遍就能接着干活,不需要复杂的组件间状态同步协议。

参考与说明

  • 本文源码引用(Reflector.ListAndWatchWithContext、kubelet 的字段选择器、scheduler 的 assignedPod/addPodDefaultBinder.Bind)均取自 kubernetes/kubernetes 仓库 v1.37.0 标签(2026-08-26 发布的最新稳定版),直接从 GitHub 拉取源文件核对过函数签名和关键逻辑,未做删改,仅作篇幅裁剪。
  • 这一篇没有本机真实集群实验:环境里 Docker Desktop 的虚拟机网络出不了外网,拉不了 kind 需要的节点镜像,退而求其次改成"真实源码 + Python 独立验证过的行为模拟"。演示数据的自检包括:kubelet 收件箱严格是 scheduler 收件箱的子集(只少了 Pod 还没分配节点的那一条)、两条收件箱的内容用一套完全独立重写的过滤规则重新推导过一遍并核对一致。
  • 没有涉及:kube-apiserver 内部的认证/鉴权/准入控制链路细节、etcd 的 Raft 共识与 MVCC(计划单独写)、controller-manager 内部 controller 的 reconcile 循环细节(下一篇 informer/controller 模式会展开)、Watch 连接本身的 HTTP chunked / WebSocket 传输细节。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电