Volcano 原理、原始碼分析(一)

胡說雲原生發表於2023-12-29

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年最後一個工作日了,肝不動了,節後繼續刷。(預知下文,記得關注微信公眾號:胡說雲原生,寶子們年後見!)

相關文章