Go语言开发kube-scheduler整体架构深度剖析

2023-05-17 08:05:18 语言 开发 kube

k8s 的调度器 kube-scheduler

kube-scheduler 作为 k8s 的调度器,就好比人的大脑,将行动指定传递到手脚等器官,进而执行对应的动作,对于 kube-scheduler 则是将 Pod 分配(调度)到集群内的各个节点,进而创建容器运行进程,对于k8s来说至关重要。

为了深入学习 kube-scheduler,本系从源码和实战角度深度学 习kube-scheduler,该系列一共分6篇文章,如下:

  • kube-scheduler 整体架构
  • 初始化一个 scheduler
  • 一个 Pod 是如何被调度的
  • 如何开发一个属于自己的scheduler插件
  • 开发一个 prefilter 扩展点的插件
  • 开发一个 socre 扩展点的插件

本篇先熟悉 kube-scheduler 的整体架构设计,看清全局,做到心里有数,在后面的篇章再庖丁解牛,一步步挖掘细节。

官方描述scheduler

我们先看看官方是怎么描述 scheduler 的

The kubernetes scheduler is a control plane process which assigns Pods to nodes. The scheduler determines which Nodes are valid placements for each Pod in the scheduling queue according to constraints and available resources. The scheduler then ranks each valid Node and binds the Pod to a suitable Node. Multiple different schedulers may be used within a cluster; kube-scheduler is the reference implementation.

k8s scheduler 是一个控制面进程,它分配 Pod 到 Nodes。根据限制和可用资源,scheduler 确定哪些节点符合调度队列里的 Pod。然后对这些符合的节点进行打分,然后把Pod绑定到合适的节点上。一个集群内可以存在多个scheduler,而 kube-scheduler 是一个参考实现。

这段话简单概括下就是:当有 pod 需要 scheduler 调度的时候, scheduler 会根据一些列规则挑选出最符合的节点,然后将Pod绑定到这个Node。

所以 scheduler 主要要做的事就是根据 Nodes 当前状态和 pod 对资源的需求,按照顺序运行一系列指定的算法来挑选出一个Node。

我们可以通过下图,对上述说的列算法有一个初步的认识,后面我们在展开详细说

如图中所示,图中每一个绿色箭头在k8s中叫扩展点(extension point),从图中可以看到一共有10个扩展点,我们可以分个类,如下图

每一个扩展点可以运行一个或多个算法,在k8s中把这种算法叫做插件(Plugin)。顾名思义,扩展点就是可以扩展的,所以用户可以开发自己的插件嵌入扩展点中,我们既可以将自己开发的插件和系统默认插件同时运行,也可以关闭系统自带的插件只运行自己的插件,这部分在后面开发实践阶段会详细介绍。

各个类型扩展点

  • sort

sort 类型的扩展点只有一个:sort,而且这个扩展点下面只能有一个插件可以运行,如果同时 enable 多个 sort 插件,scheduler 会退出。

在 k8s 中,待调度的 Pod 会放在一个叫 activeQ 队列中,这个队列是一个基于堆实现的优先队列(priority queue),为什么是优先队列呢?

因为你可以对 Pod 设置优先级,将你认为需要优先调度的 Pod 优先级调大,如果队列里有多个 Pod 需要调度,就会出现抢占现象,优先级高的 Pod 会移动到队列头部,scheduler 会优先取出这个 Pod 进行调度。那么这个优先级怎么设置呢?有两种方法:

  • 如使用 k8s 默认 sort 插件,则可以给 Pod (deployment等方式) 设置 PriorityClass(创建 PriorityClass 资源并配置deployment);如果你的所有 Pod 都没有设置 PriorityClass,那么会根据 Pod 创建的时间先后顺序进行调度。PriorityClass 和 Pod 创建时间是系统默认的排序依据。

  • 实现自己的 sort 插件定制排序算法,根据该排序算法实现抢占,例如你可以将包含特定标签的 Pod 移到队头。后面会详细讲述如何实现自己的插件来改变系统默认行为。

  • filter

filter 类型扩展点有3个:prefilter,filter,postfilter。各个扩展点有多个插件组成的插件集合根据 Pod 的配置共同过滤 Node,如下图:

preFilter 扩展点主要有两个作用,一是为后面的扩展点计算 Pod 的一些信息,例如 preFilter 阶段的 NodeResourcesFit 算法不会去判断节点合适与否,而是计算这个Pod需要多少资源,然后存储这个信息,在 filter 扩展点的 NodeResourcesFit 插件中会把之前算出来的资源拿出来做判断;另外一个作用就是过滤一些明显不符合要求的节点,这样可以减少后续扩展点插件一些无意义的计算。

filter 扩展点主要的作用就是根据各个插件定义的顺序依次执行,筛选出符合 Pod 的节点,这些插件会在 preFilter 后留下的每个 Node 上运行,如果能够通过所有插件的”考验“,那么这个节点就留下来了。如果某个插件判断这个节点不符合,那么剩余的所有插件都不会对该节点做计算。

postFilter 扩展点只会在filter结束后没有任何 Node 符合 Pod 的情况下才会运行,否则这个扩展点会被跳过。我们可以看到,这个扩展点在系统只有一个默认的插件, 这个默认插件的作用遍历这个 Pod 所在的命名空间下面的所有 Pod,查找是否有可以被抢占的 Pod,如果有的话选出一个最合适的 Pod 然后 delete 掉这个Pod,并在待调度的 Pod 的 status 字段下面配置 nominateNode 为这个被抢占的 Pod。

  • score

这个类型的扩展点的作用就是为上面 filter 扩展点筛选出来的所有 Node 进行打分,挑选出一个得分最高(最合适的),这个 Node 就是 Pod 要被调度上去的节点。这个这个类型的扩展有 preScore 和 score 两个,前者是为后者打分做前置准备的,preScore 的各个插件会计算一些信息供 score使用,这个和 prefilter 比较类似。

  • reserve

reserve 类型扩展点系统默认只实现了一个插件:VolumeBinding,更新 Pod 声明的 PVC 和对应的 PV缓存信息,表示该 PV 已经被 Pod占用。

  • permit

该类型扩展点,系统没有实现默认的插件,我们就不说了

  • bind

该类型扩展点有三个扩展点:preBind、bind和postBind。

preBind 扩展点有一个内置插件 VolumeBinding,这个插件会调用 pv controller 完成绑定操作,在前面的 reserve 也有同名插件,这个插件只是更新了本地缓存中的信息,没有实际做绑定。

bind 扩展点也只有一个默认的内置插件:DefaultBinder,这个插件只做了一件很简单的事,将 Pod.Spec.nodeName 更新为选出来的那个 node。后面的“故事”就是 kubelet 监听到了 nodeName=Kubelet所在nodename,然后开始创建Pod(容器)。 到了这里,整个调度流程就结束了。

从文章开头的那张图中我们能够看到 scheduler 分两个 cycle: scheduling cycle 和 binding cycle。区分这两个 cycle 的原因是为了提升调度效率。从上面的描述中我们能够看到,在 bind cycle 中,会有两次外部 api 调用:调用 pv controller 绑定 pv 和调用 kube-apiserver 绑定 Node,api调用是耗时的,所以将 bind 扩展点拆分出来,另起一个 Go 协程进行 bind。而在 scheduling cycle 中为了提升效率的一个重要原则就是 Pod、 Node 等信息从本地缓存中获取,而具体的实现原理就是先使用 list 获取所有 Node、Pod 的信息,然后再 watch 他们的变化更新本地缓存。

上面我们主要从扩展点和插件方面说明了 scheduler 的架构。下面我们从源码架构说说 scheduler 是怎么工作。

kube-scheduler 代码的主要框架

我们先来看看 kube-scheduler 中的几个关键组件

  • schedulerCache

schedulerCache 缓存 Pod,Node 等信息,各个扩展点的插件在计算时所需要的 Node 和 Pod 信息都是从 schedulerCache 获取。schedulerCache 具体在内部是一个实现了 Cache 接口的 结构体 cacheImpl,我们看下这个结构体:

type cacheImpl struct {
	stop   <-chan struct{}
	ttl    time.Duration
	period time.Duration
	// This mutex guards all fields within this cache struct.
	mu sync.RWMutex
	// a set of assumed pod keys.
	// The key could further be used to get an entry in podStates.
	assumedPods sets.String
	// a map from pod key to podState.
	podStates map[string]*podState
	nodes     map[string]*nodeInfoListItem
	// headNode points to the most recently updated NodeInfo in "nodes". It is the
	// head of the linked list.
	headNode *nodeInfoListItem
	nodeTree *nodeTree
	// A map from image name to its imageState.
	imageStates map[string]*imageState
}

说他是缓存,从这个结构体可以看到,实际上就是map,用来存储 Pod 和 Node 的信息。那么这些数据是怎么来的呢?我们来看下一个组件infORMer

  • informer

informer 是 client-go 提供的能力,他的作用是监听目标资源的变化,同步到本地缓存。几乎,在 k8s 的所有组件包括 controller-manager,kube-proxy,kubelet 等都使用了 informer 来监听 kube-apiserver 来获取资源的变化。举个例子,比如你执行了 kubectl edit 命令改变了一个 deployment 的镜像版本,k8s 是怎么感知到这个变化,进一步做 Pod 的重建的工作的呢?就是 kube-scheduler 使用了 informer 来监听 Pod 的变化实现的。

具体来说,kube-scheduler 使用 informer 监听了:Node, Pod, CSINode, CSIDriver, CSIStorageCapacity, PersistentVolume, PersistentVolumeClaim, StorageClass。监听 Node,Pod 我们可以理解,那么为什么要监听后面那些资源呢?后面的那些资源都是跟存储有关,在 preFilter 和 filter 扩展点的插件里面有 Volumebinding 这么一个插件,是检查系统当前是否能够满足 Pod 声明的 PVC,如果不能满足,那么只能把 Pod 放入 unscheduleableQ 里。但是,后续如果系统如果可以满足 Pod 对存储的需要了,这个 Pod 需要第一时间能够被创建出来,所以系统必须要能够实时感知到系统 PVC 等资源的变化及时将 unscheduleableQ 里面调度失败的 Pod 进行重新调度。这就是 informer 存在的意义了。具体的 informer 的实现原理可以参考这篇文章。

  • schedulerQueue

schedulerQueue包含三个队列:activeQ, podBackoffQ,unschedulablePods。

activeQ 是一个优先队列,基于堆实现,用于存放待调度的 Pod,优先级高的会放在队列头部,优先被调度。该队列存放的 Pod 可能的情况有:刚创建未被调度的Pod;backOffPod 队列中转移过来的Pod;unschedule 队列里转移过来的 Pod。

podBackoffQ 也是一个优先队列,用于存放那些异常的Pod,这种 Pod 需要等待一定的时间才能够被再次调度,会有协程定期去读取这个队列,然后加入到 activeQ 队列然后重新调度。

unschedulablePods 严格上来说不属于队列,用于存放调度失败的 Pod。这个队列也会有协程定期(默认30s)去读取,然后判断当前时间距离上次调度时间的差是否超过5Min,如果超过这个时间则把 Pod 移动到 activeQ 重新调度。

func (p *PriorityQueue) Run() {
	go wait.Until(p.flushBackoffQCompleted, 1.0*time.Second, p.stop)
	go wait.Until(p.flushUnschedulablePodsLeftover, 30*time.Second, p.stop)
}

说完这几个组件,我们再来看看,当一个新的 Pod 创建出来后,这个流程是怎么走的

  • informer 监听到了有新建 Pod,根据 Pod 的优先级把 Pod 加入到 activeQ 中适当位置(即执行sort插件);
  • scheduler 从 activeQ 队头取一个Pod(如果队列没有Pod可取,则会一直阻塞;此时假设就是上述说的新建的 Pod),开始调度;
  • 执行 filter 类型扩展点(包括preFilter,filter,postFilter)插件,选出所有符合 Pod 的 Node,如果无法找到符合的 Node, 则把 Pod 加入 unscheduleableQ 中,此次调度结束;
  • 执行 score 扩展点插件,找出最符合 Pod 的 那个Node;
  • assume Pod。这一步就是乐观假设 Pod 已经调度成功,更新缓存中 Node 和 PodStats 信息,到了这里scheduling cycle就已经结束了,然后会开启新的一轮调度。至于真正的绑定,则会新起一个协程。
  • 执行 reserve 插件;
  • 启动协程绑定 Pod 到 Node上。实际上就是修改 Pod.spec.nodeName: 选定的node名字,然后调用 kube-apiserver 接口写入 etcd。如果绑定失败了,那么移除缓存中此前加入的信息,然后把 Pod 放入activeQ 中,后续重新调度。
  • 执行 postBinding,该步没有实现的插件没所以没有做任何事。

以上就是 kube-scheduler 的基本原理。

在后面的文章中,我们会继续聊聊 kube-scheduler 是怎么初始化出来的,要想开发一个自己的插件要做哪些事,更多关于Go kube-scheduler架构的资料请关注其它相关文章!

相关文章