0. 总结前置

这段总结在文末还有,不过我还是决定在开头放一份,方便第二次翻阅的读者快速找到结论。你可以选择跳到1. 概述开始顺序阅读本文。

看到这里,我开始疑惑为什么调度里关注的是 Job,Task 这些,不应该是关注 PodGroup 吗?然后我找 Volcano 社区的几个朋友聊了下,回过头来再理代码,发现 Scheduler 里的 Job、Task 和 Controller 里的 Job、Task 并不是一回事。

对于熟悉 K8s 源码的读者而言,很容易带着 Job 就是 CR 的 Job 这种先入为主的观点开始看代码,并且觉得 Task 就是 CR Job 内的 Task。看到最后才反应过来,其实上面调度器里多次出现的 jobs 里放的那个 job 是 JobInfo 类型,JobInfo 类型对象里面的 Tasks 本质是 TaskInfo 类型对象的 map,而这个 TaskInfo 类型的 Task 和 Pod 是一一对应的,也就是 Pod 的一层 wrapper。

回过来看 Volcano 引入的 CR 中的 VolcanoJob 也不是 Scheduler 里出现的这个 Job。VolcanoJob 里也有一个 Tasks 属性,对应的类型是 TaskSpec 类型,这个 TaskSpec 类似于 K8s 的 RS 级别资源,里面包含 Pod 模板和副本数等。

因此调度器里的 Task 其实对应 Pod,当做 Pod wrapper 理解;而 Task 的集合也就是 Pod 的集合,名字叫做 job,但是对应 PodGroup;而控制器里的 Job,也就是 VolcanoJob,它的属性里并没有 PodGroup;相反调度器那个 JobInfo 类型的 job 其实属性里包含了一个 PodGroup,其实也可以认为是一个 PodGroup 的 wrapper。

所以看代码的过程中会一直觉得 Scheduler 在面向 Job 和 Task 调度,和 PodGroup 没有太大关系。其实这里的 Job 就是 PodGroup wrapper,Task 就是 Pod wrapper。

1. 概述

Volcano 是一个开源的 Kubernetes 批处理系统,专为高性能计算任务设计。它提供了一种高效的方式来管理和调度资源密集型作业,比如大数据处理和机器学习任务。

在批处理领域,任务通常需要大量计算资源,但这些资源在 Kubernetes 集群中可能是有限的或者分布不均。Volcano 尝试通过一些高级调度功能来解决这些问题,尽可能确保资源被高效利用,同时最小化作业的等待时间。这对于需要快速处理大量数据的场景尤其重要,如科学研究、金融建模或任何需要并行处理大量任务的应用。

Volcano 的关键特性之一是它的 gang 调度机制。这个机制允许同时调度一组相关任务,确保它们要么全部启动,要么都不启动。这种方法对于那些需要多个任务协同工作的复杂作业来说至关重要,因为它避免了部分任务因资源不足而无法执行的情况。

举个例子:Kubernetes 原生的调度器只能实现一个 Pod 一个 Pod 顺序调度,对于小规模在线服务而言,也基本够用。不过当一个服务需要大量 Pod 一起启动才能正常运行时(比如一次模型训练任务需要用到100个 pods 时,如何保证这100个 pods 要么都成功调度,要么都不被调度呢?这时候就需要 Volcano 提供的 gang 调度能力了。

今天咱就来具体分析下 Volcano 的工作原理。

2. Volcano 核心概念

先认识下 Volcano 的几个核心概念。

2.1 认识 Queue、PodGroup 和 VolcanoJob

Volcano 引入了几个新概念:

  1. Queue
  2. PodGroup
  3. VolcanoJob

这些都是 K8s 里的自定义资源,也就是我们能够通过 kubectl 命令查到相应的资源对象,好比 Deployment、Service、Pod 这些。

在 Volcano 中,Queue 用于管理和优先级排序任务。它允许用户根据业务需求或优先级,将作业分组到不同的队列中。这有助于更好地控制资源分配和调度优先级,确保高优先级的任务可以优先获取资源。

PodGroup 一组相关的 Pod 集合。这主要解决了 Kubernetes 原生调度器中单个 Pod 调度的限制。通过将相关的 Pod 组织成 PodGroup,Volcano 能够更有效地处理那些需要多个 Pod 协同工作的复杂任务。

VolcanoJob 是 Volcano 中的一个核心概念,它扩展了 Kubernetes 的 Job 资源。VolcanoJob 不仅包括了 Kubernetes Job 的所有特性,还加入了对批处理作业的额外支持,使得 Volcano 能够更好地适应高性能和大规模计算任务的需求。

2.2. Queue、PodGroup 和 VolcanoJob 的关系

大致知道了 Volcano 中有 Queue、PodGroup 和 VolcanoJob 三种自定义资源后,我们接着具体看下这三种资源的作用、关系等。

首先,Queue 是一个 PodGroup 队列,PodGroup 是一组强关联的 Pod 集合。而 VolcanoJob 则是一个 K8s Job 升级版,对应的下一级资源是 PodGroup。换言之,就好比 ReplicaSet 的下一级资源是 Pod 一样。

所以 VolcanoJob 背后对应一个 K8s 里的自定义控制器(Operator 模式),这个控制器会根据 VolcanoJob 的具体配置去创建相应的 PodGroup 出来。而 PodGroup 最终会被当做一个整体被 Volcano Scheduler 调度。在调度的过程中,Volcano 还用到了 Queue 来实现 PodGroup 的排队、优先级控制等逻辑。

3. Volcano 调度框架概览

继续看 Volcano 调度逻辑的实现框架。

官方文档里有一张图,长这样:

第一眼看这张图会有点蒙,主要是如何理解 ActionPlugin 两个概念,以及具体的 actions 和 plugins 作用是啥。

简单来说,Volcano 调度过程中会执行一系列的动作,这些动作也就是 Action,主要是 enqueue、allocate、backfill 这些。具体有哪些 actions,默认执行哪些 actions,后面我们到源码里去寻找。然后每个具体的 Action 中执行什么算法逻辑,就取决于注册进去的 plugins。换言之,actions 是基本固定的,合计6个(刚翻源码看到的,文档落后了),可选执行其中某几个;而 plugins 就有点多了(十几个),具体哪些 plugins 在哪个 Action 中被调用呢?咱接下来翻源码扒一扒。

4. 源码分析

接下来开始带着问题读源码。

4.1 Action 实现在哪里?

Action 相关源码入口还是很好找,Volcano 在 pkg/scheduler 中放了调度器相关的代码,里面有一个 actions 目录。在 actions 目录里的 factory.go 源文件中包含了一个 init 函数:

  • pkg/scheduler/actions/factory.go:29
func init() {
framework.RegisterAction(reclaim.New())
framework.RegisterAction(allocate.New())
framework.RegisterAction(backfill.New())
framework.RegisterAction(preempt.New())
framework.RegisterAction(enqueue.New())
framework.RegisterAction(shuffle.New())
}

可以看到这里注册了6个 actions。RegisterAction 方法的实现也很简单:

  • pkg/scheduler/framework/plugins.go:102
var actionMap = map[string]Action{}

// RegisterAction register action
func RegisterAction(act Action) {
pluginMutex.Lock()
defer pluginMutex.Unlock() actionMap[act.Name()] = act
}

有一个 actionMap 来保存所有的 actions。这里的 Action 是一个 interface,定义如下:

  • pkg/scheduler/framework/interface.go:20
// Action is the interface of scheduler action.
type Action interface {
// The unique name of Action.
Name() string // Initialize initializes the allocator plugins.
Initialize() // Execute allocates the cluster's resources into each queue.
Execute(ssn *Session) // UnIntialize un-initializes the allocator plugins.
UnInitialize()
}

4.2 从 main 函数入手看调度器启动过程

接着我们从 main 函数入手看调度器启动过程,看能不能找到 Action 是从哪里被调用的,actions 的调用顺序等相关逻辑,进而后面我们可以按照 actions 执行顺序来逐个分析具体的 Action 行为。

4.2.1 入口逻辑

调度器源码入口很直观:

main 函数中主要逻辑是调用这个 Run() 方法:

  • cmd/scheduler/main.go:71
	if err := app.Run(s); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}

Run() 方法负责启动一个 Volcano 调度器,里面核心代码只有下列2行,先构造 Scheduler 对象,然后调用其 Run() 方法:

sched, err := scheduler.NewScheduler(config, opt)
// ……
sched.Run(ctx.Done())

4.2.2 NewScheduler() 方法

接着看 NewSchedulerRun() 两个方法:

  • pkg/scheduler/scheduler.go:59
// NewScheduler returns a scheduler
func NewScheduler(config *rest.Config, opt *options.ServerOption) (*Scheduler, error) {
// …… cache := schedcache.New(config, opt.SchedulerNames, opt.DefaultQueue, opt.NodeSelector, opt.NodeWorkerThreads)
scheduler := &Scheduler{
schedulerConf: opt.SchedulerConf,
fileWatcher: watcher,
cache: cache,
schedulePeriod: opt.SchedulePeriod,
dumper: schedcache.Dumper{Cache: cache},
} return scheduler, nil
}

这里主要涉及到一个 Scheduler 对象,看起来是调度过程的核心实现对象:

  • pkg/scheduler/scheduler.go:44
// Scheduler watches for new unscheduled pods for volcano. It attempts to find
// nodes that they fit on and writes bindings back to the api server.
type Scheduler struct {
cache schedcache.Cache
schedulerConf string
fileWatcher filewatcher.FileWatcher
schedulePeriod time.Duration
once sync.Once mutex sync.Mutex
actions []framework.Action
plugins []conf.Tier
configurations []conf.Configuration
metricsConf map[string]string
dumper schedcache.Dumper
}

4.2.3 Run() 方法

暂时不忙细看每个属性,继续来看 Run 方法:

// Run runs the Scheduler
func (pc *Scheduler) Run(stopCh <-chan struct{}) {
pc.loadSchedulerConf()
go pc.watchSchedulerConf(stopCh)
// Start cache for policy.
pc.cache.SetMetricsConf(pc.metricsConf)
pc.cache.Run(stopCh)
pc.cache.WaitForCacheSync(stopCh)
klog.V(2).Infof("scheduler completes Initialization and start to run")
go wait.Until(pc.runOnce, pc.schedulePeriod, stopCh)
if options.ServerOpts.EnableCacheDumper {
pc.dumper.ListenForSignal(stopCh)
}
go runSchedulerSocket()
}

这个就是 Scheduler 的启动逻辑了,我们先来看这里被周期性调用的 runOnce 方法,这个方法每隔1秒被执行一次:

  • pkg/scheduler/scheduler.go:99
func (pc *Scheduler) runOnce() {
// …… actions := pc.actions
plugins := pc.plugins
configurations := pc.configurations
pc.mutex.Unlock() //Load configmap to check which action is enabled.
conf.EnabledActionMap = make(map[string]bool)
for _, action := range actions {
conf.EnabledActionMap[action.Name()] = true
} ssn := framework.OpenSession(pc.cache, plugins, configurations)
defer func() {
framework.CloseSession(ssn)
metrics.UpdateE2eDuration(metrics.Duration(scheduleStartTime))
}() for _, action := range actions {
actionStartTime := time.Now()
action.Execute(ssn)
metrics.UpdateActionDuration(action.Name(), metrics.Duration(actionStartTime))
}
}

可以看到在 runOnce 中的2个关键步骤:

  1. ssn := framework.OpenSession(pc.cache, plugins, configurations)
  2. 遍历 actions,调用 action.Execute(ssn)

这里的 actions 集合是什么呢?OpenSession 拿到的 plugins 又是啥呢?

进一步跟代码可以找到如下默认配置:

  • pkg/scheduler/util.go:31
var defaultSchedulerConf = `
actions: "enqueue, allocate, backfill"
tiers:
- plugins:
- name: priority
- name: gang
- name: conformance
- plugins:
- name: overcommit
- name: drf
- name: predicates
- name: proportion
- name: nodeorder
`

所以默认配置下,执行的 actions 是 enqueue, allocate, backfill 三个。再看默认方式部署后容器内的配置文件:

# cat /volcano.scheduler/volcano-scheduler.conf
actions: "enqueue, allocate, backfill"
tiers:
- plugins:
- name: priority
- name: gang
enablePreemptable: false
- name: conformance
- plugins:
- name: overcommit
- name: drf
enablePreemptable: false
- name: predicates
- name: proportion
- name: nodeorder
- name: binpack

plugins 稍有不同,一个是 glangdrf 多了 enablePreemptable,一个是多了 binpack。接下来我们先看 actions 和 plugins 的调用逻辑,再看具体的 actions 和 plugins 分别是什么含义。

4.3 寻找 actions 和 plugins 的调用逻辑

前面我们看到 runOnce() 方法里的2个关键步骤:

  1. ssn := framework.OpenSession(pc.cache, plugins, configurations)
  2. 遍历 actions,调用 action.Execute(ssn)

接下来咱顺着这两步来寻找 actions 和 plugins 的调用逻辑。

4.3.1 理解 Session 以及 plugins 被调用的本质

framework.OpenSession() 函数打开了一个 Session。不过什么是 Session 呢?来具体看下 OpenSession() 函数的实现:

  • pkg/scheduler/framework/framework.go:30
func OpenSession(cache cache.Cache, tiers []conf.Tier, configurations []conf.Configuration) *Session {
ssn := openSession(cache)
ssn.Tiers = tiers
ssn.Configurations = configurations
ssn.NodeMap = GenerateNodeMapAndSlice(ssn.Nodes)
ssn.PodLister = NewPodLister(ssn) for _, tier := range tiers {
for _, plugin := range tier.Plugins {
if pb, found := GetPluginBuilder(plugin.Name); !found {
klog.Errorf("Failed to get plugin %s.", plugin.Name)
} else {
plugin := pb(plugin.Arguments)
ssn.plugins[plugin.Name()] = plugin
onSessionOpenStart := time.Now()
plugin.OnSessionOpen(ssn)
metrics.UpdatePluginDuration(plugin.Name(), metrics.OnSessionOpen, metrics.Duration(onSessionOpenStart))
}
}
}
return ssn
}

这里的 Session 对象属性很多,不过还是值得浏览一遍,大概心里有个印象,知道哪些功能被封装进去了:

  • pkg/scheduler/framework/session.go:45
type Session struct {
UID types.UID kubeClient kubernetes.Interface
recorder record.EventRecorder
cache cache.Cache
restConfig *rest.Config
informerFactory informers.SharedInformerFactory TotalResource *api.Resource
// podGroupStatus cache podgroup status during schedule
// This should not be mutated after initiated
podGroupStatus map[api.JobID]scheduling.PodGroupStatus Jobs map[api.JobID]*api.JobInfo
Nodes map[string]*api.NodeInfo
CSINodesStatus map[string]*api.CSINodeStatusInfo
RevocableNodes map[string]*api.NodeInfo
Queues map[api.QueueID]*api.QueueInfo
NamespaceInfo map[api.NamespaceName]*api.NamespaceInfo // NodeMap is like Nodes except that it uses k8s NodeInfo api and should only
// be used in k8s compatable api scenarios such as in predicates and nodeorder plugins.
NodeMap map[string]*k8sframework.NodeInfo
PodLister *PodLister Tiers []conf.Tier
Configurations []conf.Configuration
NodeList []*api.NodeInfo plugins map[string]Plugin
eventHandlers []*EventHandler
jobOrderFns map[string]api.CompareFn
queueOrderFns map[string]api.CompareFn
taskOrderFns map[string]api.CompareFn
clusterOrderFns map[string]api.CompareFn
predicateFns map[string]api.PredicateFn
prePredicateFns map[string]api.PrePredicateFn
bestNodeFns map[string]api.BestNodeFn
nodeOrderFns map[string]api.NodeOrderFn
batchNodeOrderFns map[string]api.BatchNodeOrderFn
nodeMapFns map[string]api.NodeMapFn
nodeReduceFns map[string]api.NodeReduceFn
preemptableFns map[string]api.EvictableFn
reclaimableFns map[string]api.EvictableFn
overusedFns map[string]api.ValidateFn
allocatableFns map[string]api.AllocatableFn
jobReadyFns map[string]api.ValidateFn
jobPipelinedFns map[string]api.VoteFn
jobValidFns map[string]api.ValidateExFn
jobEnqueueableFns map[string]api.VoteFn
jobEnqueuedFns map[string]api.JobEnqueuedFn
targetJobFns map[string]api.TargetJobFn
reservedNodesFns map[string]api.ReservedNodesFn
victimTasksFns map[string][]api.VictimTasksFn
jobStarvingFns map[string]api.ValidateFn
}

OpenSession() 函数中,plugins 被遍历,然后依次调用 plugin.OnSessionOpen(ssn) 方法。这个 OnSessionOpen(ssn) 方法的调用并不会执行具体的动作,只是注册了一堆的方法到 Session 里,比如上面这个 Session 对象的 preemptableFns 属性就会在 gangPluginOnSessionOpen() 方法被调用时初始化,执行一行类似 ssn.preemptableFns[gp.Name()] = preemptableFn 的逻辑。所以一堆的 plugins 的调用逻辑就是将算法注册到 Session 里。

接着看一眼 Plugin 对象的定义,其实很简洁:

  • pkg/scheduler/framework/interface.go:35
type Plugin interface {
Name() string OnSessionOpen(ssn *Session)
OnSessionClose(ssn *Session)
}

4.3.2 理解 actions 的执行逻辑

我们已经看到了 plugins 最终就是被绑到 Session 上的一堆算法,那么这些算法是怎样被调用的呢?在 runOnce() 方法中的第二个主要逻辑是:

	for _, action := range actions {
actionStartTime := time.Now()
action.Execute(ssn)
metrics.UpdateActionDuration(action.Name(), metrics.Duration(actionStartTime))
}

也就是 actions 被遍历,然后依次执行 Execute() 方法,这里传递了一个 ssn(*Session 类型)对象进去。所以下一步的重点就是看 Execute() 方法的执行逻辑。

前面提到默认被执行的 actions 只有三个:enqueue, allocate 和 backfill。到这里可以看到接着的逻辑就是逐个调用这些 actions 的 Execute() 方法,那么 Execute() 里放的应该就是 Action 的具体逻辑了。

到这里在回过头来看官网的图,主流程就很好理解了:

一个个 plugins 注册具体的算法函数到 Session 里,然后 actions 顺序执行的过程中,到 Session 里去取相应的算法函数来执行。

4.4 Action 分析:enqueue

enqueue Action 的 Execute() 方法骨架如下:

  • pkg/scheduler/actions/enqueue/enqueue.go:44
func (enqueue *Action) Execute(ssn *framework.Session) {
// ......
queues := util.NewPriorityQueue(ssn.QueueOrderFn)
queueSet := sets.NewString()
jobsMap := map[api.QueueID]*util.PriorityQueue{} for _, job := range ssn.Jobs {
// ......
} klog.V(3).Infof("Try to enqueue PodGroup to %d Queues", len(jobsMap)) for {
// ......
}
}

开头引入了3个局部变量 queues、queueSet 和 jobsMap,接着执行了2个 for 循环,接着我们逐个来分析。

4.4.1 queues、queueSet 和 jobsMap

1. queues

这里的 queues 是一个 Priority Queue,定义如下:

  • pkg/scheduler/util/priority_queue.go:26
type PriorityQueue struct {
queue priorityQueue
} type priorityQueue struct {
items []interface{}
lessFn api.LessFn
}

这个队列的实现用了 heap 包,实现了一个“最大堆”,也就是每次 Pop() 会拿到一个优先级最高的 item。另外需要注意的是这里的 queues 用了复数形式,其实是因为下文这个队列的用法中,item 是一个队列,也就是当前队列中存放的还是队列。后面我们具体来看。

2. queueSet

这个没啥好说的,一个 name set。

3. jobsMap

这是一个从 QueueID 到 PriorityQueue 的 map

4.4.2 for 循环遍历 jobs

这一段 for 循环的代码如下:

// 这个 Job 是 Volcano 自定义资源 Job,不是 K8s 里的 Job;这里开始遍历所有 jobs
for _, job := range ssn.Jobs {
if job.ScheduleStartTimestamp.IsZero() {
ssn.Jobs[job.UID].ScheduleStartTimestamp = metav1.Time{
Time: time.Now(),
}
}
// 如果 job 中定义的 Queue 在 Session 中存在,那就执行
// queueSet.Insert(string(queue.UID)) 和
// queues.Push(queue);注意这里 Push 进去的是 queue
if queue, found := ssn.Queues[job.Queue]; !found {
klog.Errorf("Failed to find Queue <%s> for Job <%s/%s>",
job.Queue, job.Namespace, job.Name)
continue
} else if !queueSet.Has(string(queue.UID)) {
klog.V(5).Infof("Added Queue <%s> for Job <%s/%s>",
queue.Name, job.Namespace, job.Name) // 这里构建了一个 queue UID 的 set 和一个 queue 队列(优先级队列,heap 实现)
queueSet.Insert(string(queue.UID))
queues.Push(queue)
} if job.IsPending() {
// 如果 job 指定的 queue 还没存到 jobsMap 里,则创建一个对应的 PriorityQueue
if _, found := jobsMap[job.Queue]; !found {
jobsMap[job.Queue] = util.NewPriorityQueue(ssn.JobOrderFn)
}
klog.V(5).Infof("Added Job <%s/%s> into Queue <%s>", job.Namespace, job.Name, job.Queue)
// 将 job 加到指定 queue 中
jobsMap[job.Queue].Push(job)
}
}

这个 for 循环主要做2件事情,一个是遍历 jobs 的过程中判断用到了哪些 Queue(K8s 自定义资源对象),将这些 Queue 保存到 queueSet 和 queues 中;另外一个就是将处于 Pending 状态的 jobs 加入到 jobsMap 中。这里涉及到自定义资源 Queue 和局部变量 queue、queues 这些,看起来有点绕。

4.4.3 无限循环 for

for {
// 没有队列,退出循环
if queues.Empty() {
break
} // 从优先级队列 queues 中 Pop 一个高优的队列出来
queue := queues.Pop().(*api.QueueInfo) // 如果这个高优队列在 jobsMap 里没有保存相应的 jobs,也就是为空,那就继续下一轮循环
jobs, found := jobsMap[queue.UID]
if !found || jobs.Empty() {
continue
}
// jobs 也是一个优先级队列,Pop 一个高优 job 出来
job := jobs.Pop().(*api.JobInfo) if job.PodGroup.Spec.MinResources == nil || ssn.JobEnqueueable(job) {
ssn.JobEnqueued(job)
// Phase 更新为 "Inqueue"
job.PodGroup.Status.Phase = scheduling.PodGroupInqueue
// 将当前 job 加入到 ssn.Jobs map
ssn.Jobs[job.UID] = job
} // 将前面 Pop 出来的 queue 加回到 queues 中,直到 queue 中没有 job,这样逐步 queues 为空空,上面的 Empty() 方法就会返回 true,然后循环退出。
queues.Push(queue)
}

这个循环的逻辑是消化队列里的 jobs。首先将全局队列按照优先级 Pop 一个高优队列出来,然后根据这个队列的 UID 找到本地 jobsMap 里对应的 jobs 队列,这又是一个优先级队列。最后从这个优先级队列中 Pop 一个高优 Job 出来,将其状态设置成 Inqueue。

总的来说,enqueue 过程就是按照队列的优先级顺序,将队列中的 jobs 再按照优先级依次标记为 "Inqueue" 状态(job.PodGroup.Status.Phase = "Inqueue")。

4.5 Action 分析:allocate

接着来看 allocate 过程。

4.5.1 allocate.Execute() 整体逻辑

allocate.Execute() 方法的实现如下:

  • pkg/scheduler/actions/allocate/allocate.go:44
func (alloc *Action) Execute(ssn *framework.Session) {
klog.V(5).Infof("Enter Allocate ...")
defer klog.V(5).Infof("Leaving Allocate ...") // the allocation for pod may have many stages
// 1. pick a queue named Q (using ssn.QueueOrderFn)
// 2. pick a job named J from Q (using ssn.JobOrderFn)
// 3. pick a task T from J (using ssn.TaskOrderFn)
// 4. use predicateFn to filter out node that T can not be allocated on.
// 5. use ssn.NodeOrderFn to judge the best node and assign it to T // queues sort queues by QueueOrderFn.
queues := util.NewPriorityQueue(ssn.QueueOrderFn)
// jobsMap is used to find job with the highest priority in given queue.
jobsMap := map[api.QueueID]*util.PriorityQueue{} for _, job := range ssn.Jobs {
// ......
} klog.V(3).Infof("Try to allocate resource to %d Queues", len(jobsMap)) pendingTasks := map[api.JobID]*util.PriorityQueue{} allNodes := ssn.NodeList
predicateFn := func(task *api.TaskInfo, node *api.NodeInfo) ([]*api.Status, error){
// ......
} for {
// ......
}

我把三个相对独立的逻辑模块替换成了省略号,剩下的内容就不到十行了,相对好理解很多。我们先看这不到十行的方法主体,再看省略的三部分逻辑。

首先这里还是引入了一个优先级队列 queues 和一个从 queue id 到一个优先级队列的 map jobsMap。

  • queues:一个元素为优先级队列的优先级队列,也就是一个保存 queue 的“最大堆”,从而方便获取一个优先级最高的 queue;
  • jobsMap:一个 map,key 是 queue 的 id,value 是一个优先级队列,也就是一个特定的 queue,queue 中存着 jobs;通过这个 map 可以方便获取指定 queue 中的一个优先 job;

4.5.2 第一个 for 循环的逻辑

for _, job := range ssn.Jobs {
// ......
jobsMap[job.Queue].Push(job)
}

这个 for 看着长,不过除了一些健壮性逻辑之外,核心逻辑只有这样一行,也就是遍历 jobs,将其按照 queue 不同存到 jobsMap 中。

4.5.3 预选函数 predicateFn

接着来看预选函数 predicateFn 的实现逻辑。

predicateFn := func(task *api.TaskInfo, node *api.NodeInfo) ([]*api.Status, error) {
// Check for Resource Predicate
if ok, resources := task.InitResreq.LessEqualWithResourcesName(node.FutureIdle(), api.Zero); !ok {
return nil, api.NewFitError(task, node, api.WrapInsufficientResourceReason(resources))
}
var statusSets util.StatusSets
statusSets, err := ssn.PredicateFn(task, node)
if err != nil {
return nil, api.NewFitError(task, node, err.Error())
} if statusSets.ContainsUnschedulable() || statusSets.ContainsUnschedulableAndUnresolvable() ||
statusSets.ContainsErrorSkipOrWait() {
return nil, api.NewFitError(task, node, statusSets.Message())
}
return nil, nil
}

这里的逻辑是接收一个 task 和 node 作为参数,然后判断这个 node 上能否跑起来这个 task。返回值 Status 类型是一个结构体,定义如下:

type Status struct {
Code int
Reason string
}

Code 的可选值有5个:SuccessErrorUnschedulableUnschedulableAndUnresolvableWaitSkip。这里主要需要理解三个状态:

  1. Success:可调度
  2. Unschedulable:不可调度,但是驱逐后可能可调度
  3. UnschedulableAndUnresolvable:不可调度且驱逐也不可调度

接着我们去看这个 predicateFn 是如何被调用的。

4.5.4 第二个 for 循环的逻辑

这个 for 循环行数超过 160,真是,,,不优雅。

  • pkg/scheduler/actions/allocate/allocate.go:120
for {
if queues.Empty() {
break
} // Pop 一个最高优的 queue 出来
queue := queues.Pop().(*api.QueueInfo)
// ......
// jobs 也就是这个高优 queue 中的所有 jobs
jobs, found := jobsMap[queue.UID]
if !found || jobs.Empty() {
klog.V(4).Infof("Can not find jobs for queue %s.", queue.Name)
continue
} // job 就是 jobs 这个优先级队列中的最高优条目
job := jobs.Pop().(*api.JobInfo)
if _, found = pendingTasks[job.UID]; !found {
// tasks 也是一个优先级队列,里面保存一个 job 下的所有 tasks
tasks := util.NewPriorityQueue(ssn.TaskOrderFn)
for _, task := range job.TaskStatusIndex[api.Pending] {
// Skip BestEffort task in 'allocate' action.
if task.Resreq.IsEmpty() {
klog.V(4).Infof("Task <%v/%v> is BestEffort task, skip it.",
task.Namespace, task.Name)
continue
}
// 将 task Push 到 tasks 队列中
tasks.Push(task)
}
// 这个 map 的 key 是 job 的 id,value 是 tasks 队列
pendingTasks[job.UID] = tasks
}
tasks := pendingTasks[job.UID] // Added Queue back until no job in Namespace.
queues.Push(queue) if tasks.Empty() {
continue
} klog.V(3).Infof("Try to allocate resource to %d tasks of Job <%v/%v>",
tasks.Len(), job.Namespace, job.Name) stmt := framework.NewStatement(ssn)
ph := util.NewPredicateHelper()
// tasks 不为空时,开一个循环来消化这些 tasks;这里的 tasks 属于同一个 job
for !tasks.Empty(){
// ......
} if ssn.JobReady(job) {
stmt.Commit()
} else {
if !ssn.JobPipelined(job) {
stmt.Discard()
}
}
}

继续来看内部循环,也就是 tasks 不 Empty 的时候相应的处理逻辑:

  • pkg/scheduler/actions/allocate/allocate.go:169
for !tasks.Empty() {
// 取出最高优的 task
task := tasks.Pop().(*api.TaskInfo) // ...... // 跑一次预选算法,具体算法内容后面再分析
if err := ssn.PrePredicateFn(task); err != nil {
klog.V(3).Infof("PrePredicate for task %s/%s failed for: %v", task.Namespace, task.Name, err)
fitErrors := api.NewFitErrors()
for _, ni := range allNodes {
fitErrors.SetNodeError(ni.Name, err)
}
job.NodesFitErrors[task.UID] = fitErrors
break
} // 拿到预选通过的节点列表
predicateNodes, fitErrors := ph.PredicateNodes(task, allNodes, predicateFn, true)
if len(predicateNodes) == 0 {
job.NodesFitErrors[task.UID] = fitErrors
break
} // 候选节点列表,注意这里是二维切片,后面会依次直接保存 idleCandidateNodes 和 futureIdleCandidateNodes 两个切片本身进去
var candidateNodes [][]*api.NodeInfo
// 空闲候选节点列表
var idleCandidateNodes []*api.NodeInfo
// 未来空闲候选节点列表(预期即将有资源会被释放出来的节点)
var futureIdleCandidateNodes []*api.NodeInfo
for _, n := range predicateNodes {
if task.InitResreq.LessEqual(n.Idle, api.Zero) {
idleCandidateNodes = append(idleCandidateNodes, n)
} else if task.InitResreq.LessEqual(n.FutureIdle(), api.Zero) {
futureIdleCandidateNodes = append(futureIdleCandidateNodes, n)
} else {
klog.V(5).Infof("Predicate filtered node %v, idle: %v and future idle: %v do not meet the requirements of task: %v",
n.Name, n.Idle, n.FutureIdle(), task.Name)
}
}
// 填充候选节点列表
candidateNodes = append(candidateNodes, idleCandidateNodes)
candidateNodes = append(candidateNodes, futureIdleCandidateNodes) // 准备寻找最优节点
var bestNode *api.NodeInfo
// for 循环变量里用的是 nodes,也就是先拿到 idleCandidateNodes,再拿 futureIdleCandidateNodes
for index, nodes := range candidateNodes {
// ......
switch {
case len(nodes) == 0:
klog.V(5).Infof("Task: %v, no matching node is found in the candidateNodes(index: %d) list.", task.Name, index)
case len(nodes) == 1: // If only one node after predicate, just use it.
bestNode = nodes[0]
case len(nodes) > 1: // If more than one node after predicate, using "the best" one
// 优选算法来打分
nodeScores := util.PrioritizeNodes(task, nodes, ssn.BatchNodeOrderFn, ssn.NodeOrderMapFn, ssn.NodeOrderReduceFn) bestNode = ssn.BestNodeFn(task, nodeScores)
if bestNode == nil {
bestNode = util.SelectBestNode(nodeScores)
}
} // 如果在 idleCandidateNodes 中找到合适的节点,那就不看 futureIdleCandidateNodes 了
if bestNode != nil {
break
}
} // 将前面找到的最佳节点相应资源分配给当前 task
if task.InitResreq.LessEqual(bestNode.Idle, api.Zero) {
// ......
if err := stmt.Allocate(task, bestNode); err != nil {
// ......
}
// ......
} else {
// 将 node 上预期要释放的资源分配给当前 task
if task.InitResreq.LessEqual(bestNode.FutureIdle(), api.Zero) {
// ......
if err := stmt.Pipeline(task, bestNode.Name); err != nil {
klog.Errorf("Failed to pipeline Task %v on %v in Session %v for %v.",
task.UID, bestNode.Name, ssn.UID, err)
}
// ......
}
} if ssn.JobReady(job) && !tasks.Empty() {
jobs.Push(job)
break
}
}

这个 for 循环的逻辑主要是按照优先级依次给 tasks 寻找最合适的 node,找到后“预占”资源,于是按顺序逐步给所有的 tasks 都找到了最佳节点。

到这里我们没有具体去深究最后 pods 是如何被绑定到节点上的,也没有去看 Pipeline、Summit 这些逻辑;先放放,往后看完最后一个 Action backfill 之后,对整体框架熟悉了,再进一步分析细节。

4.6 Action 分析:backfill

backfill 的逻辑是遍历待调度 jobs(Inqueue 状态),然后将没有没有指明资源申请大小的 task 调度掉。不过这里没有处理一个 job 中部分 task 指明了资源大小,部分没有指定的场景。总之看起来不是核心逻辑,考虑到本文篇幅已经过长,这块暂时不赘述。

5. 总结

看到这里,我开始疑惑为什么调度里关注的是 Job,Task 这些,不应该是关注 PodGroup 吗?然后我找 Volcano 社区的几个朋友聊了下,回过头来再理代码,发现 Scheduler 里的 Job、Task 和 Controller 里的 Job、Task 并不是一回事。

对于熟悉 K8s 源码的读者而言,很容易带着 Job 就是 CR 的 Job 这种先入为主的观点开始看代码,并且觉得 Task 就是 CR Job 内的 Task。看到最后才反应过来,其实上面调度器里多次出现的 jobs 里放的那个 job 是 JobInfo 类型,JobInfo 类型对象里面的 Tasks 本质是 TaskInfo 类型对象的 map,而这个 TaskInfo 类型的 Task 和 Pod 是一一对应的,也就是 Pod 的一层 wrapper。

回过来看 Volcano 引入的 CR 中的 VolcanoJob 也不是 Scheduler 里出现的这个 Job。VolcanoJob 里也有一个 Tasks 属性,对应的类型是 TaskSpec 类型,这个 TaskSpec 类似于 K8s 的 RS 级别资源,里面包含 Pod 模板和副本数等。

因此调度器里的 Task 其实对应 Pod,当做 Pod wrapper 理解;而 Task 的集合也就是 Pod 的集合,名字叫做 job,但是对应 PodGroup;而控制器里的 Job,也就是 VolcanoJob,它的属性里并没有 PodGroup;相反调度器那个 JobInfo 类型的 job 其实属性里包含了一个 PodGroup,其实也可以认为是一个 PodGroup 的 wrapper。

所以看代码的过程中会一直觉得 Scheduler 在面向 Job 和 Task 调度,和 PodGroup 没有太大关系。其实这里的 Job 就是 PodGroup wrapper,Task 就是 Pod wrapper。

6. 结尾

在大致知道 Scheduler 的工作过程后,还有很多的细节等着我们进一步分析。比如:

  1. 从 PodGroup 的创建入手,Scheduler 如何接手 PodGroup 完成调度过程的呢?(这条路一定走得通,不然其他框架,比如 Kubeflow 等就无法和 Volcano 整合了。)
  2. PodGroup 里不包含 pods 信息,那 Scheduler 如何找到对应的 Pod 完成节点绑定呢?(粗看应该是通过 Pod 的 annotation 来过滤特定 PodGroup 名下的 pods,然后完成的调度。
  3. Job(vcjob)和 PodGroup 控制器的主要工作逻辑是什么?
  4. ……

2023年最后一个工作日了,肝不动了,节后继续刷。(预知下文,记得关注微信公众号:胡说云原生,宝子们年后见!)

Volcano 原理、源码分析(一)的更多相关文章

  1. guava eventbus 原理+源码分析

    前言: guava提供的eventbus可以很方便的处理一对多的事件问题, 最近正好使用到了,做个小结,使用的demo网上已经很多了,不再赘述,本文主要是源码分析+使用注意点+新老版本eventbus ...

  2. [五]类加载机制双亲委派机制 底层代码实现原理 源码分析 java类加载双亲委派机制是如何实现的

      Launcher启动类 本文是双亲委派机制的源码分析部分,类加载机制中的双亲委派模型对于jvm的稳定运行是非常重要的 不过源码其实比较简单,接下来简单介绍一下   我们先从启动类说起 有一个Lau ...

  3. Mybatis Interceptor 拦截器原理 源码分析

    Mybatis采用责任链模式,通过动态代理组织多个拦截器(插件),通过这些拦截器可以改变Mybatis的默认行为(诸如SQL重写之类的),由于插件会深入到Mybatis的核心,因此在编写自己的插件前最 ...

  4. AtomicInteger原理&源码分析

    转自https://www.cnblogs.com/rever/p/8215743.html 深入解析Java AtomicInteger原子类型 在进行并发编程的时候我们需要确保程序在被多个线程并发 ...

  5. Flink中Periodic水印和Punctuated水印实现原理(源码分析)

    在用户代码中,我们设置生成水印和事件时间的方法assignTimestampsAndWatermarks()中这里有个方法的重载 我们传入的对象分为两种 AssignerWithPunctuatedW ...

  6. SharedPreferences 原理 源码 进程间通信 MD

    Markdown版本笔记 我的GitHub首页 我的博客 我的微信 我的邮箱 MyAndroidBlogs baiqiantao baiqiantao bqt20094 baiqiantao@sina ...

  7. 源码分析—ThreadPoolExecutor线程池三大问题及改进方案

    前言 在一次聚会中,我和一个腾讯大佬聊起了池化技术,提及到java的线程池实现问题,我说这个我懂啊,然后巴拉巴拉说了一大堆,然后腾讯大佬问我说,那你知道线程池有什么缺陷吗?我顿时哑口无言,甘拜下风,所 ...

  8. Flink中Idle停滞流机制(源码分析)

    前几天在社区群上,有人问了一个问题 既然上游最小水印会决定窗口触发,那如果我上游其中一条流突然没有了数据,我的窗口还会继续触发吗? 看到这个问题,我蒙了???? 对哈,因为我是选择上游所有流中水印最小 ...

  9. ABP源码分析三十五:ABP中动态WebAPI原理解析

    动态WebAPI应该算是ABP中最Magic的功能之一了吧.开发人员无须定义继承自ApiController的类,只须重用Application Service中的类就可以对外提供WebAPI的功能, ...

  10. HashMap实现原理及源码分析

    哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如memcached)的核心其实就是在内存中维护一张大的哈希表,而HashMap的实现原理也常常出 ...

随机推荐

  1. 各快 100 倍?4G、5G、6G 相差这么多吗

    二狗子今天晚上有点 emo,为什么呢? 原来是二狗子心心念很久的一个手游上线了,二狗子兴冲冲地下载了 40 多分钟,终于下载完了游戏.结果打开游戏一看,发现游戏内部的更新写着预计 30 分钟完成更新. ...

  2. linux安装clickhouse

    linux安装clickhouse 1. 系统要求 ClickHouse可以在任何具有x86_64,AArch64或PowerPC64LE CPU架构的Linux,FreeBSD或Mac OS X上运 ...

  3. 一种对数据库友好的GUID的变种使用方法

    概述 .NET生成的GUID唯一性很好,用之方便,但是,缺少像雪花算法那样的有序性.虽然分布式系统中做不到绝对的有序,但是,相对的有序对于目前数据库而言,索引效率等方面的提升还是有明显效果的(当然,我 ...

  4. replace批量替换、表删除数据查询用法

    一. select * from baec_file where bacti='1'order by baec01; select baec02,REPLACE(baec02,'白班','A班') f ...

  5. Java 深度优先搜索 and 广度优先搜索的算法原理和代码展示

    111. 二叉树的最小深度 题目:给定一个二叉树,找出其最小深度.最小深度是从根节点到最近叶子节点的最短路径上的节点数量. 说明:叶子节点是指没有子节点的节点. 方法1:深度优先搜索 原理:深度优先搜 ...

  6. C、C++函数和类库详解(VC++版)(2016-06-26更新)

    C.C++函数和类库详解(VC++版)(未完成) 整理者:赤勇玄心行天道 QQ:280604597 Email:280604597@qq.com 大家有什么不明白的地方,或者想要详细了解的地方可以联系 ...

  7. Ubuntu18.04环境下安装redis 6.2.0,配置文件的部分参数说明

    环境是win11的Linux子系统Ubuntu-18.04,安装方式是源码安装,也可以用apt安装(见本文最后参考资料),用的用户是默认用户(所以一些关键命令要注意用sudo,不用会报错) 安装: j ...

  8. calico网络异常,不健康

    解决calico/node is not ready: BIRD is not ready: BGP not established withxxx calico有一个没有ready,查了一下是没有发 ...

  9. 数据类型python

    type()语句的用法 运行结果

  10. k8s-1.23.6 安装部署文档(超详细)

    一.文档简介 作者:lanjiaxuan 邮箱:lanheader@163.com 博客地址:https://www.cnblogs.com/lanheader/ 更新时间:2022-09-09 二. ...