k8s排程器介紹(排程框架版本)

goofy_zheng發表於2021-10-15

從一個pod的建立開始

  1. 由kubectl解析建立pod的yaml,傳送建立pod請求到APIServer。
  2. APIServer首先做許可權認證,然後檢查資訊並把資料儲存到ETCD裡,建立deployment資源初始化。
  3. kube-controller通過list-watch機制,檢查發現新的deployment,將資源加入到內部工作佇列,檢查到資源沒有關聯pod和replicaset,然後建立rs資源,rs controller監聽到rs建立事件後再建立pod資源。
  4. scheduler 監聽到pod建立事件,執行排程演算法,將pod繫結到合適節點,然後告知APIServer更新pod的spec.nodeName
  5. kubelet 每隔一段時間通過其所在節點的NodeName向APIServer拉取繫結到它的pod清單,並更新本地快取。
  6. kubelet發現新的pod屬於自己,呼叫容器API來建立容器,並向APIService上報pod狀態。
  7. Kub-proxy為新建立的pod註冊動態DNS到CoreOS。為Service新增iptables/ipvs規則,用於服務發現和負載均衡。
  8. deploy controller對比pod的當前狀態和期望來修正狀態。

排程器介紹

從上述流程中,我們能大概清楚kube-scheduler的主要工作,負責整個k8s中pod選擇和繫結node的工作,這個選擇的過程就是應用排程策略,包括NodeAffinity、PodAffinity、節點資源篩選、排程優先順序、公平排程等等,而繫結便就是將pod資源定義裡的nodeName進行更新。

設計

kube-scheduler的設計有兩個歷史階段版本:

  1. 基於謂詞(predicate)和優先順序(priority)的篩選。
  2. 基於排程框架的排程器,新版本已經把所有的舊的設計都改造成擴充套件點外掛形式(1.19+)。

所謂的謂詞和優先順序都是對排程演算法的分類,在scheduler裡,謂詞排程演算法是來選擇出一組能夠繫結pod的node,而優先順序演算法則是在這群node中進行打分,得出一個最高分的node。

而排程框架的設計相比之前則更復雜一點,但確更加靈活和便於擴充套件,關於排程框架的設計細節可以檢視官方文件——624-scheduling-framework,當然我也有一遍文章對其做了翻譯還加了一些便於理解的補充——KEP: 624-scheduling-framework。總結來說排程框架的出現是為了解決以前webhooks擴充套件器的侷限性,一個是擴充套件點只有:篩選、打分、搶佔、繫結,而排程框架則在這之上又細分了11個擴充套件點;另一個則是通過http呼叫擴充套件程式的方式其實效率不高,排程框架的設計用的是靜態編譯的方式將擴充套件的程式程式碼和scheduler原始碼一起編譯成新的scheduler,然後通過scheduler配置檔案啟用需要的外掛,在程式內就能通過函式呼叫的方式執行外掛。

排程流程

現在網上大部分的kube-scheduler排程流程文章都不是基於新的排程框架所寫的,還是謂詞和優先順序的流程。基於排程框架實現的排程流程總的來說就是執行一個個外掛的過程,如下圖:

整個過程可以分為兩個週期:排程週期(scheduling cycle)、繫結週期(Binding Cycle),這兩個週期的區別不僅僅是包含外掛,還有每個週期的上下文(Cycle Context),這個上下文將貫穿各自的週期使週期內的每個外掛之間能夠進行資料的交流。Sort外掛是不屬於兩個週期任何一個,它的職責就是對排程佇列中的Pod進行排序。

一個pod的排程過程在排程外掛裡是線性執行下去的,但是繫結週期的執行是非同步的,也就是說scheduler在執行A Pod的繫結週期時,其實也同時開始了B Pod的排程週期。這也是比較合理的,畢竟Bind外掛是需要和APIServer進行通訊來更新排程pod的nodeName,這個網路IO過程存在著不可確定性。

排程週期:

Filter外掛的功能類似之前的謂詞排程,這個過程就是根據排程策略函式(在排程框架裡就是多個Filter外掛函式)進行node篩選,篩選的原理就是將被篩選的node和待排程的pod以及週期上下文等作為引數一併傳入這些函式,最後收集通過了所有篩選函式的node進入下一階段,在這個階段將會以node為單位進行併發處理。

PostFilter外掛雖說是發生在Filter之後,但是確只能在Filter外掛沒有返回合適的node才執行。在scheduler裡預設的PostFilter外掛只有一個功能,進行搶佔排程。搶佔排程的原理:首先會將node上低於待排程pod的優先順序的Pod全部剔除,當然這個只是模擬過程並不是真正將Pod從幹掉,然後再次執行Filter外掛,如果失敗了那就是搶佔排程失敗,成功了則將前面剔除的pod一個一個加回來,每一次都執行Filter外掛從而找出排程該Pod所需要剔除的最少的低優先順序Pod。

Score外掛的功能類比以前的優先順序排程,這個過程是對前一階段得出的node列表進行再篩選,得出最終要排程的node。NormalizeScore再排程框架裡也不能算是一個單獨擴充套件點,它往往是配合著score外掛一起出現,為了將統一外掛打分的分數。在排程框架裡是作為Score外掛可選的實現介面,同樣的Score外掛的也是會併發的在每個node上執行。

Reserve 外掛有兩種函式,reserve函式在繫結前為Pod做準備動作,Unreserve函式則在繫結週期間發生錯誤的時候做恢復。預設的Reserve外掛使用情況是處理pod關聯裡pvc與pv的繫結和解綁。

繫結週期:

整個繫結週期都是在一個非同步的協程中,在執行進入繫結週期前會執行Pod的assume(假定)過程,這個過程做的主要是假設Pod已經繫結到目標node上,所以會更新scheduler的node快取資訊,這樣當排程下一個pod到前一個pod真正在node上建立的過程中,能夠用真正的node資訊進行排程。

Scheduler的啟動流程

現在我們瞭解了scheduler是如何執行排程演算法、pod繫結過程的,但是對於什麼時候執行排程和排程的pod怎麼獲得其實還並不清楚,所以我們需要深入到scheduler的程式碼來了解這一切。

上面是一個簡略版的排程器處理pod流程:

首先scheduler會啟動一個client-go的Informer來監聽Pod事件(不只Pod其實還有Node等資源變更事件),這時候註冊的Informer回撥事件會區分Pod是否已經被排程(spec.nodeName),已經排程過的Pod則只是更新排程器快取,而未被排程的Pod會加入到排程佇列,然後經過排程框架執行註冊的外掛,在繫結週期前會進行Pod的假定動作,從而更新排程器快取中該Pod狀態,最後在繫結週期執行完向ApiServer發起BindAPI,從而完成了一次排程過程。

先找到在/cmd/kube-scheduler/scheduler.go的入口函式

func main() {
	command := app.NewSchedulerCommand()
	code := cli.Run(command)
	os.Exit(code)
}

k8中元件通用的啟動模版,我們需要找到這個command定義的

func NewSchedulerCommand(registryOptions ...Option) *cobra.Command {
	...
  cmd := &cobra.Command{ // 定義了一個cobra的Comand結構體, cmd.Execute(),會執行定義的Run函式。
		Run: func(cmd *cobra.Command, args []string) {
			if err := runCommand(cmd, opts, registryOptions...); err != nil { 
				fmt.Fprintf(os.Stderr, "%v\n", err)
				os.Exit(1)
			}
		}
		...
	}
}

檢視runCommand定義

func runCommand(cmd *cobra.Command, opts *options.Options, registryOptions ...Option) error {
	...
	cc, sched, err := Setup(ctx, opts, registryOptions...) // 初始化配置、Scheduler
	...
	return Run(ctx, cc, sched)
}

檢視Run定義

func Run(ctx context.Context, cc *schedulerserverconfig.CompletedConfig, sched *scheduler.Scheduler) error {
	// To help debugging, immediately log version
	klog.V(1).Infof("Starting Kubernetes Scheduler version %+v", version.Get())

	// 全域性配置
	if cz, err := configz.New("componentconfig"); err == nil {
		cz.Set(cc.ComponentConfig)
	} else {
		return fmt.Errorf("unable to register configz: %s", err)
	}

	// 事件管理器
	cc.EventBroadcaster.StartRecordingToSink(ctx.Done())

	// 選舉檢查
	var checks []healthz.HealthChecker
	if cc.ComponentConfig.LeaderElection.LeaderElect {
		checks = append(checks, cc.LeaderElection.WatchDog)
	}

	// http和metric服務
	if cc.InsecureServing != nil {
		...
	}
	if cc.InsecureMetricsServing != nil {
		... 
	}
	// https服務
	if cc.SecureServing != nil {
		...
	}

	// 啟動所有Informer
	cc.InformerFactory.Start(ctx.Done())

	// 等待informer快取完畢
	cc.InformerFactory.WaitForCacheSync(ctx.Done())

	// 選舉機制啟動
	if cc.LeaderElection != nil {
		...
	}

	// 非選舉機制啟動過, 無論是選舉和非選舉啟動都會呼叫最後處理邏輯都會到sched.Run()
	sched.Run(ctx)
	return fmt.Errorf("finished without leader elect")
}

sched.Run在/pkg/scheduler/scheduler.go

func (sched *Scheduler) Run(ctx context.Context) {
	...
	sched.SchedulingQueue.Run()
	wait.UntilWithContext(ctx, sched.scheduleOne, 0) 
	sched.SchedulingQueue.Close()
}

其中wait.UntilWithContext將會不間斷的呼叫sched.scheduleOne函式,這麼看schedulerOne就是處理Pod排程的工作函式了,到這裡我們得回到上面New出sched的地方cc, sched, err := Setup(...)

func Setup(ctx context.Context, opts *options.Options, outOfTreeRegistryOptions ...Option) (*schedulerserverconfig.CompletedConfig, *scheduler.Scheduler, error) {
	c, err := opts.Config() // 從Options(命令列收集)初始化schedler的配置
	
	cc := c.Complete() // 補充配置

	// Create the scheduler.
	sched, err := scheduler.New(...), // 初始化Scheduler
	)
	return &cc, sched, nil
}

檢視New方法

func New(...) (*Scheduler, error) {
  options := defaultSchedulerOptions // 設定預設配置項
  ...
	configurator := &Configurator{  // 建立配置器
    ...
	}
 
	sched, err := configurator.create()  // 通過配置起器建立scheduler
	if err != nil {
		return nil, fmt.Errorf("couldn't create scheduler: %v", err)
	}
  // 為informer設定監聽事件,包括pod(已排程(欄位NodeName)-新增到SchedulerCache, 為排程則新增到SchedulingQueue佇列中。
  // Node、PV、PVC、SC、CSINode、Service
  addAllEventHandlers(sched, informerFactory, podInformer)
  return sched, nil
}

檢視配置起Configuratorcreate

func (c *Configurator) create() (*Scheduler, error) {
  // 建立提名佇列,用於儲存發生搶佔的Pod
	nominator := internalqueue.NewPodNominator(c.informerFactory.Core().V1().Pods().Lister())
	profiles, err := profile.NewMap(...) // 排程框架配置

	podQueue := internalqueue.NewSchedulingQueue()  // 建立排程框架
  
	algo := NewGenericScheduler() // 建立排程演算法,這裡面主要是執行篩選和打分外掛

	return &Scheduler{
		SchedulerCache:  c.schedulerCache,  // 排程快取
		Algorithm:       algo, // 排程演算法
		Extenders:       extenders,  // webhook擴充套件
		Profiles:        profiles,  // 排程框架配置
		NextPod:         internalqueue.MakeNextPodFunc(podQueue), // 獲取排程Pod
		Error:           MakeDefaultErrorFunc(),  // 排程失敗處理
		StopEverything:  c.StopEverything,  // 停止器
		SchedulingQueue: podQueue,  // 排程佇列
	}, nil
}

這裡我們發現了SchedulingQueue是 由NewSchedulingQueue宣告的一個物件。

/pkg/scheduler/internal/queue/scheduling_queue.go

func NewPriorityQueue(
	lessFn framework.LessFunc,
	opts ...Option,
) *PriorityQueue {
	...
	pq := &PriorityQueue{  // 定義了3種佇列,activeQ、unschedulableQ、podBackoffQ
		PodNominator:              options.podNominator,
		clock:                     options.clock,
		stop:                      make(chan struct{}),
		podInitialBackoffDuration: options.podInitialBackoffDuration,
		podMaxBackoffDuration:     options.podMaxBackoffDuration,
		activeQ:                   heap.NewWithRecorder(), 
		unschedulableQ:            newUnschedulablePodsMap(),
		moveRequestCycle:          -1,
	}
  pq.podBackoffQ = heap.NewWithRecorder()
	return pq
}

SchedulingQueue的結構

type SchedulingQueue interface {
	...
	Pop() (*framework.QueuedPodInfo, error)
	Update(oldPod, newPod *v1.Pod) error
	Delete(pod *v1.Pod) error
	MoveAllToActiveOrBackoffQueue(event string)
}

找到了sched的屬性SchedulingQueue實際上是一個PriorityQueue物件,我們找到它的Run方法。

func (p *PriorityQueue) Run() {
	// 每一秒從podBackoffQ拿出最近的pod檢查是否可以加入到activeQ
	go wait.Until(p.flushBackoffQCompleted, 1.0*time.Second, p.stop) 
	// 沒30秒從無法排程pod的佇列拿出pod檢查是否可以加入到activeQ
	go wait.Until(p.flushUnschedulableQLeftover, 30*time.Second, p.stop)
}

現在我們找到了整個sched的啟動和排程佇列管理的功能,接下來檢視具體排程一個pod的詳細經過。

sched.Run中我們找打了scheduleOne方法:/pkg/scheduler/scheduler.go

func (sched *Scheduler) scheduleOne(ctx context.Context) {
	podInfo := sched.NextPod() // 獲取activeQ的下一個pod
  fwk, err := sched.frameworkForPod(pod) // 從Pod裡獲取設定排程框架,預設`default-schdeler`
	...
	scheduleResult, err := sched.Algorithm.Schedule()  // 執行排程演算法:Filter和Score等外掛
	...
	err = sched.assume()  // 假定pod
	...
	go func() { // 非同步執行bind
		...
		err := sched.bind()
		...
	}
}

這個函式正是處理pod排程的主函式,而獲取需要排程的pod是執行sched.NextPod(),然後就是執行排程框架裡的各個註冊外掛,至此這就是所有的scheduler的工作程式碼了,如果要看詳細的流程,可以檢視我寫的思維導圖。
github思維導圖地址:https://github.com/goofy-z/k8s-learning/blob/master/K8s原始碼學習/kube-scheduler/scheduler.xmind
線上思維導圖:https://www.processon.com/view/link/6167925d5653bb1336dca0ca

相關文章