Kubernetes 的控制平面看起来有好几个"角色"——apiserver、etcd、scheduler、controller-manager,分工不同。但支撑这一整套系统的,其实是一条特别硬的规则:没有任何一个组件会直接调用另一个组件。scheduler 不会给 kubelet 打电话说"这个 Pod 归你了",kubelet 也不会去问 scheduler "有没有新任务"。所有人都只做一件事:盯着 apiserver,看 etcd 里的数据变了没有。
这一篇不依赖能跑起来的集群,而是直接读 Kubernetes v1.37.0 的真实源码,把"一个 Pod 从 kubectl apply 到真正跑起来"这条链路上,每个组件到底在等什么、看到了什么、看不到什么,一步步过一遍。
先认清每个组件具体是谁,后面追踪 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",负责把它们真正跑起来、汇报状态。 |
scheduler、controller-manager、kubelet,包括你敲的 kubectl,在架构图上地位是一样的——都只是"连到 apiserver 上的一个客户端",没有谁比谁更特殊。
每个客户端先做一次 List 拿到当前全量数据和一个 resourceVersion,再从这个版本开始 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 的 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 事件。
和 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) } }
调度决策做完之后,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。
左边是 scheduler 的收件箱(无过滤,来者不拒),右边是 kubelet 的收件箱(带字段选择器)。同一次 apiserver 写入,两边收到的东西可能完全不一样。
事件数据由 Python 脚本生成,精确复现上面两段真实源码里的过滤规则(kubelet 按 spec.nodeName 服务端过滤、scheduler 无过滤 + 客户端 assignedPod() 判断),并用一套独立重新实现的过滤逻辑逐事件重放校验过,断言全部通过后才用于渲染。
从 kubectl apply 到 Pod 真正 Running,中间没有任何一步是"A 组件调用 B 组件的接口"。每一步都是"某个组件观察到 apiserver 里的数据变了,做点什么,再把结果写回 apiserver"。这样设计的好处很直接:
只要新调度器也遵守"读 apiserver、写 Binding"这套协议,kubelet 完全无感——这也是自定义调度器、多调度器共存能够成立的原因。
没有组件在内存里维护"只有它自己知道"的关键状态——真相永远在 etcd 里,重启后重新 List 一遍就能接着干活,不需要复杂的组件间状态同步协议。