正常情况下,当一个 pod 调度失败后,就会被暂时 “搁置” 处于 状态,直到 pod 被更新或者集群状态发生变化,调度器才会对这个 pod 进行重新调度。但在实际的业务场景中会存在在线与离线业务之分,若在线业务的 pod 因资源不足而调度失败时,此时就需要离线业务下掉一部分为在线业务提供资源,即在线业务要抢占离线业务的资源,此时就需要 scheduler 的优先级和抢占机制了,该机制解决的是 pod 调度失败时该怎么办的问题,若该 pod 的优先级比较高此时并不会被”搁置”,而是会”挤走”某个 node 上的一些低优先级的 pod,这样就可以保证高优先级的 pod 调度成功。

抢占发生的原因,一定是一个高优先级的 pod 调度失败,我们称这个 pod 为“抢占者”,称被抢占的 pod 为“牺牲者”(victims)。而 kubernetes 调度器实现抢占算法的一个最重要的设计,就是在调度队列的实现里,使用了两个不同的队列。

第一个队列叫作 activeQ,凡是在 activeQ 里的 pod,都是下一个调度周期需要调度的对象。所以,当你在 kubernetes 集群里新创建一个 pod 的时候,调度器会将这个 pod 入队到 activeQ 里面,调度器不断从队列里出队(pop)一个 pod 进行调度,实际上都是从 activeQ 里出队的。

第二个队列叫作 unschedulableQ,专门用来存放调度失败的 pod,当一个 unschedulableQ 里的 pod 被更新之后,调度器会自动把这个 pod 移动到 activeQ 里,从而给这些调度失败的 pod “重新做人”的机会。

当 pod 拥有了优先级之后,高优先级的 pod 就可能会比低优先级的 pod 提前出队,从而尽早完成调度过程。

k8s.io/kubernetes/pkg/scheduler/internal/queue/scheduling_queue.go

前面的文章已经说了 scheduleOne() 是执行调度算法的主逻辑,其主要功能有:

  • 调用 sched.schedule(),即执行 predicates 算法和 priorities 算法
  • 若执行失败,会返回 core.FitError
  • 若开启了抢占机制,则执行抢占机制
  • ……

k8s.io/kubernetes/pkg/scheduler/scheduler.go:516

  1. func (sched *Scheduler) scheduleOne() {
  2. ......
  3. scheduleResult, err := sched.schedule(pod, pluginContext)
  4. // predicates 算法和 priorities 算法执行失败
  5. if err != nil {
  6. if fitError, ok := err.(*core.FitError); ok {
  7. // 是否开启抢占机制
  8. if sched.DisablePreemption {
  9. .......
  10. } else {
  11. // 执行抢占机制
  12. preemptionStartTime := time.Now()
  13. sched.preempt(pluginContext, fwk, pod, fitError)
  14. ......
  15. }
  16. ......
  17. } else {
  18. ......
  19. }
  20. return
  21. }
  22. ......
  23. }

我们主要来看其中的抢占机制,sched.preempt() 是执行抢占机制的主逻辑,主要功能有:

  • 从 apiserver 获取 pod info
  • 调用 sched.Algorithm.Preempt()执行抢占逻辑,该函数会返回抢占成功的 node、被抢占的 pods(victims) 以及需要被移除已提名的 pods
  • 更新 scheduler 缓存,为抢占者绑定 nodeName,即设定 pod.Status.NominatedNodeName
  • 将 pod info 提交到 apiserver
  • 删除被抢占的 pods
  • 删除被抢占 pods 的 NominatedNodeName 字段

这样设计的一个重要原因是调度器只会通过标准的 DELETE API 来删除被抢占的 pod,所以,这些 pod 必然是有一定的“优雅退出”时间(默认是 30s)的。而在这段时间里,其他的节点也是有可能变成可调度的,或者直接有新的节点被添加到这个集群中来。所以,鉴于优雅退出期间集群的可调度性可能会发生的变化,把抢占者交给下一个调度周期再处理,是一个非常合理的选择。而在抢占者等待被调度的过程中,如果有其他更高优先级的 pod 也要抢占同一个节点,那么调度器就会清空原抢占者的 status.nominatedNodeName 字段,从而允许更高优先级的抢占者执行抢占,并且,这也使得原抢占者本身也有机会去重新抢占其他节点。以上这些都是设置 nominatedNodeName 字段的主要目的。

k8s.io/kubernetes/pkg/scheduler/scheduler.go:352

  1. func (sched *Scheduler) preempt(pluginContext *framework.PluginContext, fwk framework.Framework, preemptor *v1.Pod, scheduleErr error) (string, error) {
  2. // 获取 pod info
  3. preemptor, err := sched.PodPreemptor.GetUpdatedPod(preemptor)
  4. if err != nil {
  5. klog.Errorf("Error getting the updated preemptor pod object: %v", err)
  6. return "", err
  7. }
  8. // 执行抢占算法
  9. node, victims, nominatedPodsToClear, err := sched.Algorithm.Preempt(pluginContext, preemptor, scheduleErr)
  10. if err != nil {
  11. ......
  12. }
  13. var nodeName = ""
  14. if node != nil {
  15. nodeName = node.Name
  16. // 更新 scheduler 缓存,为抢占者绑定 nodename,即设定 pod.Status.NominatedNodeName
  17. sched.SchedulingQueue.UpdateNominatedPodForNode(preemptor, nodeName)
  18. // 将 pod info 提交到 apiserver
  19. err = sched.PodPreemptor.SetNominatedNodeName(preemptor, nodeName)
  20. if err != nil {
  21. sched.SchedulingQueue.DeleteNominatedPodIfExists(preemptor)
  22. return "", err
  23. }
  24. // 删除被抢占的 pods
  25. for _, victim := range victims {
  26. if err := sched.PodPreemptor.DeletePod(victim); err != nil {
  27. return "", err
  28. }
  29. ......
  30. }
  31. }
  32. // 删除被抢占 pods 的 NominatedNodeName 字段
  33. for _, p := range nominatedPodsToClear {
  34. rErr := sched.PodPreemptor.RemoveNominatedNodeName(p)
  35. if rErr != nil {
  36. ......
  37. }
  38. }
  39. }

preempt()中会调用 sched.Algorithm.Preempt()来执行实际抢占的算法,其主要功能有:

  • 判断 err 是否为 FitError
  • 调用podEligibleToPreemptOthers()确认 pod 是否有抢占其他 pod 的资格,若 pod 已经抢占了低优先级的 pod,被抢占的 pod 处于 terminating 状态中,则不会继续进行抢占
  • 如果确定抢占可以发生,调度器会把自己缓存的所有节点信息复制一份,然后使用这个副本来模拟抢占过程
  • 过滤预选失败的 node 列表,此处会检查 predicates 失败的原因,若存在 NodeSelectorNotMatch、PodNotMatchHostName 这些 error 则不能成为抢占者,如果过滤出的候选 node 为空则返回抢占者作为 nominatedPodsToClear
  • 获取 PodDisruptionBudget 对象
  • 从预选失败的 node 列表中并发计算可以被抢占的 nodes,得到
  • 若声明了 extenders 则调用 extenders 再次过滤 nodeToVictims
  • 调用 pickOneNodeForPreemption()nodeToVictims 中选出一个节点作为最佳候选人
  • 移除低优先级 pod 的 Nominated,更新这些 pod,移动到 activeQ 队列中,让调度器为这些 pod 重新 bind node

k8s.io/kubernetes/pkg/scheduler/core/generic_scheduler.go:320

该函数中调用了多个函数:

  • nodesWherePreemptionMightHelp():过滤 predicates 算法执行失败的 node
  • selectNodesForPreemption():过滤出可以抢占的 node 列表
  • pickOneNodeForPreemption():选出最佳的 node
  • getLowerPriorityNominatedPods():移除低优先级 pod 的 Nominated

selectNodesForPreemption() 从 prediacates 算法执行失败的 node 列表中来寻找可以被抢占的 node,通过workqueue.ParallelizeUntil()并发执行checkNode()函数检查 node。

k8s.io/kubernetes/pkg/scheduler/core/generic_scheduler.go:996

  1. func (g *genericScheduler) selectNodesForPreemption(
  2. ......
  3. ) (map[*v1.Node]*schedulerapi.Victims, error) {
  4. nodeToVictims := map[*v1.Node]*schedulerapi.Victims{}
  5. var resultLock sync.Mutex
  6. meta := metadataProducer(pod, nodeNameToInfo)
  7. // checkNode 函数
  8. checkNode := func(i int) {
  9. nodeName := potentialNodes[i].Name
  10. var metaCopy predicates.PredicateMetadata
  11. if meta != nil {
  12. metaCopy = meta.ShallowCopy()
  13. }
  14. // 调用 selectVictimsOnNode 函数进行检查
  15. pods, numPDBViolations, fits := g.selectVictimsOnNode(pluginContext, pod, metaCopy, nodeNameToInfo[nodeName], fitPredicates, queue, pdbs)
  16. if fits {
  17. resultLock.Lock()
  18. victims := schedulerapi.Victims{
  19. Pods: pods,
  20. NumPDBViolations: numPDBViolations,
  21. }
  22. nodeToVictims[potentialNodes[i]] = &victims
  23. resultLock.Unlock()
  24. }
  25. }
  26. // 启动 16 个 goroutine 并发执行
  27. workqueue.ParallelizeUntil(context.TODO(), 16, len(potentialNodes), checkNode)
  28. return nodeToVictims, nil
  29. }

其中调用的selectVictimsOnNode()是来获取每个 node 上 victims pod 的,首先移除所有低优先级的 pod 尝试抢占者是否可以调度成功,如果能够调度成功,然后基于 pod 是否有 PDB 被分为两组 violatingVictimsnonViolatingVictims,再对每一组的 pod 按优先级进行排序。PDB(pod 中断预算)是 kubernetes 保证副本高可用的一个对象。

然后开始逐一”删除“ pod 即要删掉最少的 pod 数来完成这次抢占即可,先从 violatingVictims(有PDB)的一组中进行”删除“ pod,并且记录删除有 PDB pod 的数量,然后再“删除” nonViolatingVictims 组中的 pod,每次”删除“一个 pod 都要检查一下抢占者是否能够运行在该 node 上即执行一次预选策略,若执行预选策略失败则该 node 当前不满足抢占需要继续”删除“ pod 并将该 pod 加入到 victims 中,直到”删除“足够多的 pod 可以满足抢占,最后返回 victims 以及删除有 PDB pod 的数量。

k8s.io/kubernetes/pkg/scheduler/core/generic_scheduler.go:1086

  1. func (g *genericScheduler) selectVictimsOnNode(
  2. ......
  3. ) ([]*v1.Pod, int, bool) {
  4. if nodeInfo == nil {
  5. return nil, 0, false
  6. }
  7. potentialVictims := util.SortableList{CompFunc: util.MoreImportantPod}
  8. nodeInfoCopy := nodeInfo.Clone()
  9. removePod := func(rp *v1.Pod) {
  10. nodeInfoCopy.RemovePod(rp)
  11. if meta != nil {
  12. meta.RemovePod(rp, nodeInfoCopy.Node())
  13. }
  14. }
  15. addPod := func(ap *v1.Pod) {
  16. nodeInfoCopy.AddPod(ap)
  17. if meta != nil {
  18. meta.AddPod(ap, nodeInfoCopy)
  19. }
  20. }
  21. // 先删除所有的低优先级 pod 检查是否能满足抢占 pod 的调度需求
  22. podPriority := util.GetPodPriority(pod)
  23. for _, p := range nodeInfoCopy.Pods() {
  24. if util.GetPodPriority(p) < podPriority {
  25. potentialVictims.Items = append(potentialVictims.Items, p)
  26. removePod(p)
  27. }
  28. }
  29. // 如果删除所有低优先级的 pod 不符合要求则直接过滤掉该 node
  30. if fits, _, _, err := g.podFitsOnNode(pluginContext, pod, meta, nodeInfoCopy, fitPredicates, queue, false); !fits {
  31. if err != nil {
  32. ......
  33. return nil, 0, false
  34. }
  35. var victims []*v1.Pod
  36. numViolatingVictim := 0
  37. potentialVictims.Sort()
  38. // 尝试尽量多地“删除”这些 pods,先从 PDB violating victims 中“删除”,再从 PDB non-violating victims 中“删除”
  39. violatingVictims, nonViolatingVictims := filterPodsWithPDBViolation(potentialVictims.Items, pdbs)
  40. // reprievePod 是“删除” pods 的函数
  41. reprievePod := func(p *v1.Pod) bool {
  42. addPod(p)
  43. // 同样也会调用 podFitsOnNode 再次执行 predicates 算法
  44. fits, _, _, _ := g.podFitsOnNode(pluginContext, pod, meta, nodeInfoCopy, fitPredicates, queue, false)
  45. if !fits {
  46. removePod(p)
  47. // 加入到 victims 中
  48. victims = append(victims, p)
  49. }
  50. return fits
  51. }
  52. // 删除 violatingVictims 中的 pod,同时也记录删除了多少个
  53. for _, p := range violatingVictims {
  54. if !reprievePod(p) {
  55. numViolatingVictim++
  56. }
  57. }
  58. // 删除 nonViolatingVictims 中的 pod
  59. for _, p := range nonViolatingVictims {
  60. reprievePod(p)
  61. }
  62. return victims, numViolatingVictim, true
  63. }
  • PDB violations 值最小的 node
  • 挑选具有高优先级较少的 node
  • 对每个 node 上所有 victims 的优先级进项累加,选取最小的
  • 如果多个 node 优先级总和相等,选择具有最小 victims 数量的 node
  • 如果多个 node 优先级总和相等,选择具有高优先级且 pod 运行时间最短的
  • 如果依据以上策略仍然选出了多个 node 则直接返回第一个 node

k8s.io/kubernetes/pkg/scheduler/core/generic_scheduler.go:867

以上就是对抢占机制代码的一个通读。

1、创建 PriorityClass 对象:

  1. apiVersion: scheduling.k8s.io/v1
  2. kind: PriorityClass
  3. metadata:
  4. name: high-priority
  5. value: 1000000
  6. globalDefault: false
  7. description: "This priority class should be used for XYZ service pods only."

2、在 deployment、statefulset 或者 pod 中声明使用已有的 priorityClass 对象即可

在 pod 中使用:

  1. apiVersion: v1
  2. kind: Pod
  3. metadata:
  4. labels:
  5. app: nginx-a
  6. name: nginx-a
  7. spec:
  8. containers:
  9. - image: nginx:1.7.9
  10. imagePullPolicy: IfNotPresent
  11. name: nginx-a
  12. ports:
  13. - containerPort: 80
  14. protocol: TCP
  15. resources:
  16. requests:
  17. memory: "64Mi"
  18. cpu: 5
  19. limits:
  20. memory: "128Mi"
  21. cpu: 5
  22. priorityClassName: high-priority

在 deployment 中使用:

3、测试过程中可以看到高优先级的 nginx-a 会抢占 nginx-5754944d6c 的资源:

  1. $ kubectl get pod -o wide -w
  2. NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
  3. nginx-5754944d6c-9mnxa 1/1 Running 0 37s 10.244.1.4 test-worker <none> <none>
  4. nginx-a 0/1 Pending 0 0s <none> <none> <none> <none>
  5. nginx-a 0/1 Pending 0 0s <none> <none> <none> <none>
  6. nginx-a 0/1 Pending 0 0s <none> <none> test-worker <none>
  7. nginx-5754944d6c-9mnxa 1/1 Terminating 0 45s 10.244.1.4 test-worker <none> <none>
  8. nginx-5754944d6c-9mnxa 0/1 Terminating 0 46s 10.244.1.4 test-worker <none> <none>
  9. nginx-5754944d6c-9mnxa 0/1 Terminating 0 47s 10.244.1.4 test-worker <none> <none>
  10. nginx-5754944d6c-9mnxa 0/1 Terminating 0 47s 10.244.1.4 test-worker <none> <none>
  11. nginx-a 0/1 Pending 0 2s <none> test-worker test-worker <none>

这篇文章主要讲述 kube-scheduler 中的优先级与抢占机制,可以看到抢占机制比 predicates 与 priorities 算法都要复杂,其中的许多细节仍然没有提到,本文只是通读了大部分代码,某些代码的实现需要精读,限于笔者时间的关系,对于 kube-scheduler 的代码暂时分享到此处。

参考:

https://kubernetes.io/docs/concepts/configuration/pod-priority-preemption/