笔者在《SchedulingQueue》的文章中提到了PodNominator,因为调度队列继承了PodNominator,但是当时为了避免不必要的内容扩展,并没有对PodNominator做任何说明。本文将对PodNominator做较全面的解析,从它的定义、实现直到在kube-scheduler中的应用。
本文采用的Kubenetes源码的release-1.20分支。
首先需要了解PodNominator到底是干啥的,英文翻译就是Pod提名器,那什么又是提名呢?这个可以从Pod的API定义的部分注释得到结果。源码链接:https://github.com/kubernetes/kubernetes/blob/release-1.20/staging/src/k8s.io/api/core/v1/types.go#L3594
// nominatedNodeName is set only when this pod preempts other pods on the node, but it cannot be
// scheduled right away as preemption victims receive their graceful termination periods.
// This field does not guarantee that the pod will be scheduled on this node. Scheduler may decide
// to place the pod elsewhere if other nodes become available sooner. Scheduler may also decide to
// give the resources on this node to a higher priority pod that is created after preemption.
// As a result, this field may be different than PodSpec.nodeName when the pod is
// scheduled.
// +optional
NominatedNodeName string `json:"nominatedNodeName,omitempty" protobuf:"bytes,11,opt,name=nominatedNodeName"`
简单总结源码注释就是当Pod抢占Node上其他Pod的时候会设置Pod.Status.NominatedNodeName为Node的名字。调度器不会立刻将Pod调度到Node上,因为需要等到被抢占的Pod优雅退出。到这里就可以了,其他内容属于调度相关的,我们只需要知道提名到底是干啥的就可以了。
现在知道提名就是可以将Pod调度到提名的Node上,但是需要等被抢占的Pod退出后腾出资源才能执行调度。而PodNominator就是记录哪些Pod获得Node提名,那么问题来了,Pod.Status.NominatedNodeName不是已经记录了么,还要PodNominator干什么呢?笔者先不回答这个问题,在总结的章节中会给出答案。
先来看看PodNominator的定义,源码链接:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/framework/interface.go#L562
type PodNominator interface {
// 提名pod调度到nodeName的Node上
AddNominatedPod(pod *v1.Pod, nodeName string)
// 删除pod提名的nodeName
DeleteNominatedPodIfExists(pod *v1.Pod)
// PodNominator处理pod更新事件
UpdateNominatedPod(oldPod, newPod *v1.Pod)
// 获取提名到nodeName上的所有Pod,这个接口是不是感觉到PodNominator存在的意义了?
// 毕竟PodNominator有统计功能,否则就需要遍历所有的Pod.Status.NominatedNodeName才能统计出来
NominatedPodsForNode(nodeName string) []*v1.Pod
}
从PodNominator的定义来看非常简单,感觉用map就可以实现了,事实上也确实如此。
PodNominator的实现在调度队列中,这其中的用意值得研究一下。咱们先看代码实现,在研究为什么在调度队列中实现。源码链接:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/queue/scheduling_queue.go#L723
// 确实非常简单,用map加读写锁实现的
type nominatedPodMap struct {
// nominatedPods的key是提名的Node的名字,value是所有的Pod,这就是为NominatedPodsForNode()接口专门设计的
nominatedPods map[string][]*v1.Pod
// nominatedPodToNode的key是Pod的UID,value是提名Node的名字,这是为增、删、改接口设计的
// 为什么调度队列用NS+NAME,而这里用UID,其中可能有版本更新造成的历史原因,关键要看是否有用名字访问的需求。
// 当然,当前版本调度队列的接口没有直接用NS+NAME访问Pod,但是不排除以前是有这个设计考虑的。
// 以上只是笔者猜测,仅供参考,有哪位小伙伴有更靠谱的答案欢迎在留言区回复。
nominatedPodToNode map[ktypes.UID]string
sync.RWMutex
}
// nominatedPodMap一共就三个核心函数,add、delete、和UpdateNominatedPod
func (npm *nominatedPodMap) add(p *v1.Pod, nodeName string) {
// 避免Pod已经存在先执行删除,确保同一个Pod不会存在两个实例,这个主要是为了nominatedPods考虑的,毕竟它的value是slice,没有去重能力。
npm.delete(p)
// NominatedNodeName()函数就是获取p.Status.NominatedNodeName,
// 那么下面的代码的意思就是nodeName和p.Status.NominatedNodeName优先用nodeName,如果二者都没有指定则返回
// 这里需要知道的是nodeName代表着最新的状态,所以需要优先,这一点在PodNominator应用章节会进一步说明
nnn := nodeName
if len(nnn) == 0 {
nnn = NominatedNodeName(p)
if len(nnn) == 0 {
return
}
}
// 下面的代码没什么难度,就是把Pod放到两个map中
npm.nominatedPodToNode[p.UID] = nnn
for _, np := range npm.nominatedPods[nnn] {
if np.UID == p.UID {
klog.V(4).Infof("Pod %v/%v already exists in the nominated map!", p.Namespace, p.Name)
return
}
}
npm.nominatedPods[nnn] = append(npm.nominatedPods[nnn], p)
}
func (npm *nominatedPodMap) delete(p *v1.Pod) {
// 如果Pod不存在就返回
nnn, ok := npm.nominatedPodToNode[p.UID]
if !ok {
return
}
// 然后从两个map中删除
for i, np := range npm.nominatedPods[nnn] {
if np.UID == p.UID {
npm.nominatedPods[nnn] = append(npm.nominatedPods[nnn][:i], npm.nominatedPods[nnn][i+1:]...)
if len(npm.nominatedPods[nnn]) == 0 {
delete(npm.nominatedPods, nnn)
}
break
}
}
delete(npm.nominatedPodToNode, p.UID)
}
func (npm *nominatedPodMap) UpdateNominatedPod(oldPod, newPod *v1.Pod) {
npm.Lock()
defer npm.Unlock()
// 首选需要知道一个知识点,kube-scheduler什么时候会调用UpdateNominatedPod()?这个问题貌似应该是PodNominator应用章节的内容。
// 为了便于理解下面的代码,笔者需要提前剧透一下,答案是在调度队列的更新接口中,感兴趣的同学可以回看《kube-scheduler的SchedulingQueue解析》的源码注释
// 而调度队列的更新是kube-scheduler在watch apiserver的Pod的时候触发调用的,所以此时默认是没有提名Node的
nodeName := ""
// 有一些情况,在Pod刚好提名了Node之后收到了Pod的更新事件并且新Pod.Status.NominatedNodeName="",此时需要保留提名的Node。
// 以下几种情况更新事件是不会保留提名的Node
// 1.设置Status.NominatedNodeName:表现为NominatedNodeName(oldPod) == "" && NominatedNodeName(newPod) != ""
// 2.更新Status.NominatedNodeName:表现为NominatedNodeName(oldPod) != "" && NominatedNodeName(newPod) != ""
// 3.删除Status.NominatedNodeName:表现为NominatedNodeName(oldPod) != "" && NominatedNodeName(newPod) == ""
if NominatedNodeName(oldPod) == "" && NominatedNodeName(newPod) == "" {
if nnn, ok := npm.nominatedPodToNode[oldPod.UID]; ok {
// 这是唯一一种情况保留提名
nodeName = nnn
}
}
// 无论提名的Node名字是否修改都需要更新,因为需要确保Pod的指针也被更新
npm.delete(oldPod)
npm.add(newPod, nodeName)
}
除了更新保留原有提名Node部分稍微有点复杂,整个PodNominator的实现可以说简单到不能再简单,甚至可以毫不夸张的说,在看到PodNominator的定义的时候就可以想象得到它的实现了。虽然PodNominator看似非常简单,但是很多复杂的功能就是又这些简单的模块组建而成。PodNominator是kube-scheduler实现抢占调度的关键所在,此时是不是感觉他就没那么简单了呢?现在笔者就带着大家看看PodNominator在kube-scheduler是如何应用,进而实现抢占调度的。
在kube-scheduler中,有两个类型直接继承了PodNominator,它们分别是SchedulingQueue和PreemptHandle。前者笔者在《SchedulingQueue》文章中已经提到了,但是直接忽略了,所以本文算是调度队列的一个延续;PreemptHandle是抢占句柄,本文不会过多介绍,还是老套路,留给后续文章解析,但是从类型名字可以知道是用来实现抢占的。所以说PodNominator与抢占调度是有关系的。
虽然说SchedulingQueue和PreemptHandle都继承了PodNominator,但是他们都指向了同一个对象(这也是golang语言的特点,指针继承),所以kube-scheduler中只有一个PodNominator对象。这也是nominatedPodMap加锁的一个原因,因为有多协程并发访问的需求。
调度队列有三种情况会调用PodNominator.AddNominatedPod():
- PriorityQueue.Add():源码链接https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/queue/scheduling_queue.go#L265,此处的目的是将添加的Pod如果提名了Node,那就添加到PodNominator中。笔者认为正常的逻辑可能不会出现这种情况,因为一个新建的Pod何来提名?还记得SharedInformer的ResyncPeriod么?SharedInformer会定时全量同步一次,此时的事件是Add,以上仅是笔者的猜测。
- PriorityQueue.AddUnschedulableIfNotPresent():源码链接https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/queue/scheduling_queue.go#L326,此处的目的是恢复Pod的提名状态,因为该函数会将Pod加入到unschedulableQ或者backoffQ,均为不可调度状态,原有的提名需要删除,恢复到Pod.Status.NominatedNodeName。
- PriorityQueue.Update():源码链接https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/queue/scheduling_queue.go#L460,此处的目的与PriorityQueue.Add()相同,因为更新的时候如果Pod不在任何子队列就当Add处理。 调度队列在PriorityQueue.Update()函数中如果Pod已经在队列中存在,会调用PodNominator。UpdateNominatedPod(),相应的代码笔者不再拷贝了,读者可以通过上面的连接找到。毕竟Pod已经更新了,相应的状态也需要更细难道PodNominator中,就是更新Pod的指针。同理,在PriorityQueue.Delete()函数中调用了PodNominator.DeleteNominatedPodIfExists(),笔者就不再解释了。
当kube-scheduler需要通过抢占的方式为Pod提名某个Node时,此时的Pod仍然处于未调度状态,因为Pod需要等到Node上被抢占的Pod退出。所以此时对于Pod而言是调度失败的,也就是PodNominator.AddNominatedPod()出现在recordSchedulingFailure()函数中的原因。代码链接:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/scheduler.go#L328
func (sched *Scheduler) recordSchedulingFailure(fwk framework.Framework, podInfo *framework.QueuedPodInfo, err error, reason string, nominatedNode string) {
// sched.Error是Pod调度出错后的处理,即便是抢占成功并提名,依然是资源不满足错误,因为等待被抢占Pod退出
// 这个函数会调用PriorityQueue.AddUnschedulableIfNoPresent()函数将Pod放入不可调度子队列
sched.Error(podInfo, err)
// Update the scheduling queue with the nominated pod information. Without
// this, there would be a race condition between the next scheduling cycle
// and the time the scheduler receives a Pod Update for the nominated pod.
// Here we check for nil only for tests.
if sched.SchedulingQueue != nil {
sched.SchedulingQueue.AddNominatedPod(podInfo.Pod, nominatedNode)
}
......
}
而在调度器中调用recordSchedulingFailure()函数传入有效Node名字(非"")的地方只有https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/scheduler.go#L500,表示抢占成功,其他的地方都是传入空的字符串,表示调度失败。本文不对调度器做过多分析,因为会有专门的文章分析,感兴趣的同学可以自行分析源码,当然也可以等待笔者的相关文章发布。笔者此处简单描述一下调度器抢占的原理:当没有任何一个Node满足Pod的需求的时候,调度器开始抢占Pod优先级低的Pod,如果抢占成功则提名被抢占Pod所在的Node,然后再将Pod放入不可调度队列,等待被抢占Pod退出。
当然,放入不可调度队列的Pod过一段时间还会被重新放入activeQ,此时如果有满足Pod需求的Node,kube-scheduler会将Pod调度到该Node上,然后清除Pod提名的Node。代码链接:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/scheduler.go#L384
// 还记得《kube-scheduler的Cache解析》对于assume的解释么?如果忘记了请复习一遍
func (sched *Scheduler) assume(assumed *v1.Pod, host string) error {
// 设置Pod调度的Node
assumed.Spec.NodeName = host
// Cache中假定Pod已经调度到了Node上
if err := sched.SchedulerCache.AssumePod(assumed); err != nil {
klog.Errorf("scheduler cache AssumePod failed: %v", err)
return err
}
// 已经假定Pod调度到了Node上,应该移除以前提名的Node了(如果有提名的Node)
if sched.SchedulingQueue != nil {
sched.SchedulingQueue.DeleteNominatedPodIfExists(assumed)
}
return nil
}
Pod通过PodNominator提名Node就可以么?下一轮调度新的Pod如何确保不会被重复抢占(Pod1抢占了Pod0等待的过程中,Pod2优先级不高于Pod1但高于Pod0的情况下不抢占Pod0)?这就是PodNominator.NominatedPodsForNode()的作用了。调度器在遍历Node时都会把这部分资源累加到Node上,这样就避免重复抢占的问题,这也是kube-scheduler里面比较常见的一个名词"Reserve"(预留)。因为这部分代码相对比较复杂,笔者此处不做注释。
以上就是PodNominator在kube-scheduler中的应用,如果感觉知识点有点零散不系统,那么请看下文的总结。
- PodNominator是kube-scheduler为了实现抢占调度定义的一种“提名”管理器,他记录了所有抢占成功但需要等被抢占Pod退出的Pod;
- 通过PodNominator.NominatedPodsForNode(),kube-scheduler获取提名到指定Node上的所有Pod,在计算调度的时候这部分Pod申请的资源视为Node预留给抢占Pod的资源;
- 当没有Node满足Pod的需求时,kube-scheduler开始执行抢占调度,如果抢占成功则利用PodNominator提名被抢占Pod所在的Node为Pod运行的Node;
- 提名成功不代表已调度,所以此时Pod仍然是不可调度状态,放在调度队列的unschedulableQ子队列中;
- 如果Pod从unschedulableQ迁移到activeQ,并且正好有Node满足Pod的需求,则Pod被调度到该Node上,并且删除以前提名的Node;
- 此时再回来品一下“提名”,就是调度器预先在Node上为Pod预留一部分当前正在被别的Pod占用资源。此时再来看本文开始提出的问题,PodNominator有什么作用?我想笔者应该不用再做多余的解释了。
- 最后,还有一个问题,为什么将PodNominator放在调度队列中实现?如果是笔者也会这么做,笔者的原因很简单:Pod虽然提名了Node,但是依然是未调度状态,所以放在调度队列中实现最合适。至于作者是不是这么考虑的就不知道了。