Kubernetes 排程器實現初探
作者| 阿里雲智慧事業群高階開發工程師 蕭元
Kubernetes作為一個分散式容器編排排程引擎,資源排程是它的最重要的功能。在 Kubernetes叢集中,排程器作為一個獨立模組執行。本文將介紹 Kubernetes 排程器的實現原理,工作流程, 以及未來發展。
Kubernetes 排程工作方式
Kubernetes 中的排程器,是作為單獨元件執行,一般執行在 Master 中,和 Master 數量保持一致。透過 Raft 協議選出一個例項作為 Leader 工作,其他例項 Backup。 當 Master 故障,其他例項之間繼續透過 Raft 協議選出新的 Master 工作。
其工作模式如下:
排程器內部維護一個排程的 pods 佇列 podQueue, 並監聽 APIServer;
當我們建立 Pod 時,首先透過 APIServer 往 ETCD 寫入 Pod 後設資料;
排程器透過 Informer 監聽 Pods 狀態,當有新增 Pod 時,將 Pod 加入到 podQueue 中;
排程器中的主程式,會不斷的從 podQueue 取出的 Pod,並將 Pod 進入排程分配節點環節;
排程環節分為兩個步奏, Filter 過濾滿足條件的節點 、 Prioritize 根據 Pod 配置,例如資源使用率,親和性等指標,給這些節點打分,最終選出分數最高的節點;
分配節點成功, 呼叫 apiServer 的 binding pod 介面, 將
pod.Spec.NodeName
設定為所分配的那個節點;節點上的 kubelet 同樣監聽 ApiServer,如果發現有新的 pod 被排程到所在節點,在節點上拉起對應的容器
假如排程器嘗試排程 Pod 不成功,如果開啟了優先順序和搶佔功能,會嘗試做一次搶佔,將節點中優先順序較低的 pod 刪掉,並將待排程的 pod 排程到節點上。 如果未開啟,或者搶佔失敗,會記錄日誌,並將 pod 加入 podQueue 隊尾。
實現細節
kube-scheduling 是一個獨立執行的元件,主要工作內容在 Run 函式 。 這裡面主要做幾件事情:
初始化一個 Scheduler 例項
sched
,傳入各種 Informer,為關心的資源變化建立監聽並註冊 handler,例如維護 podQuene;註冊 events 元件,設定日誌;
註冊 http/https 監聽,提供健康檢查和 metrics 請求;
執行主要的排程內容入口
sched.run()
。 如果設定--leader-elect=true
,代表啟動多個例項,透過Raft選主,例項只有當被選為master後執行主要工作函式sched.run
。
排程核心內容在 sched.run()
函式,它會啟動一個 go routine 不斷執行sched.scheduleOne
, 每次執行代表一個排程週期。
func (sched *Scheduler) Run() {
if !sched.config.WaitForCacheSync() {
return
}
go wait.Until(sched.scheduleOne, 0, sched.config.StopEverything)
}
我們看下 sched.scheduleOne
主要做什麼:
func (sched *Scheduler) scheduleOne() {
pod := sched.config.NextPod()
.... // do some pre check
scheduleResult, err := sched.schedule(pod)
if err != nil {
if fitError, ok := err.(*core.FitError); ok {
if !util.PodPriorityEnabled() || sched.config.DisablePreemption {
..... // do some log
} else {
sched.preempt(pod, fitError)
}
}
}
...
// Assume volumes first before assuming the pod.
allBound, err := sched.assumeVolumes(assumedPod, scheduleResult.SuggestedHost)
...
fo func() {
// Bind volumes first before Pod
if !allBound {
err := sched.bindVolumes(assumedPod)
if err != nil {
klog.Errorf("error binding volumes: %v", err)
metrics.PodScheduleErrors.Inc()
return
}
}
err := sched.bind(assumedPod, &v1.Binding{
ObjectMeta: metav1.ObjectMeta{Namespace: assumedPod.Namespace, Name: assumedPod.Name, UID: assumedPod.UID},
Target: v1.ObjectReference{
Kind: "Node",
Name: scheduleResult.SuggestedHost,
},
})
}
}
在sched.scheduleOne
中,主要會做幾件事情:
透過
sched.config.NextPod()
, 從 podQuene 中取出 pod;執行
sched.schedule
,嘗試進行一次排程;假如排程失敗,如果開啟了搶佔功能,會呼叫
sched.preempt
嘗試進行搶佔,驅逐一些 pod,為被排程的 pod 預留空間,在下一次排程中生效;如果排程成功,執行 bind 介面。在執行 bind 之前會為 pod volume 中宣告的的 PVC 做 provision。
sched.schedule
是主要的 pod 排程邏輯:
func (g *genericScheduler) Schedule(pod *v1.Pod, nodeLister algorithm.NodeLister) (result ScheduleResult, err error) {
// Get node list
nodes, err := nodeLister.List()
// Filter
filteredNodes, failedPredicateMap, err := g.findNodesThatFit(pod, nodes)
if err != nil {
return result, err
}
// Priority
priorityList, err := PrioritizeNodes(pod, g.cachedNodeInfoMap, metaPrioritiesInterface, g.prioritizers, filteredNodes, g.extenders)
if err != nil {
return result, err
}
// SelectHost
host, err := g.selectHost(priorityList)
return ScheduleResult{
SuggestedHost: host,
EvaluatedNodes: len(filteredNodes) + len(failedPredicateMap),
FeasibleNodes: len(filteredNodes),
}, err
}
排程主要分為三個步奏:
Filters: 過濾條件不滿足的節點;
PrioritizeNodes: 在條件滿足的節點中做 Scoring,獲取一個最終打分列表 priorityList;
selectHost: 在 priorityList 中選取分數最高的一組節點,從中根據 round-robin 方式選取一個節點。
接下來我們繼續拆解, 分別看下這三個步奏會怎麼做
Filters
Filters 相對比較容易,排程器預設註冊了一系列的 predicates 方法, 排程過程為併發呼叫每個節點的 predicates 方法。最終得到一個 node list,包含符合條件的節點物件。
func (g *genericScheduler) findNodesThatFit(pod *v1.Pod, nodes []*v1.Node) ([]*v1.Node, FailedPredicateMap, error) {
if len(g.predicates) == 0 {
filtered = nodes
} else {
allNodes := int32(g.cache.NodeTree().NumNodes())
numNodesToFind := g.numFeasibleNodesToFind(allNodes)
checkNode := func(i int) {
nodeName := g.cache.NodeTree().Next()
// 此處會呼叫這個節點的所有predicates 方法
fits, failedPredicates, err := podFitsOnNode(
pod,
meta,
g.cachedNodeInfoMap[nodeName],
g.predicates,
g.schedulingQueue,
g.alwaysCheckAllPredicates,
)
if fits {
length := atomic.AddInt32(&filteredLen, 1)
if length > numNodesToFind {
// 如果當前符合條件的節點數已經足夠,會停止計算。
cancel()
atomic.AddInt32(&filteredLen, -1)
} else {
filtered[length-1] = g.cachedNodeInfoMap[nodeName].Node()
}
}
}
// 併發呼叫checkNode 方法
workqueue.ParallelizeUntil(ctx, 16, int(allNodes), checkNode)
filtered = filtered[:filteredLen]
}
return filtered, failedPredicateMap, nil
}
值得注意的是, 1.13 中引入了 FeasibleNodes 機制,為了提高大規模叢集的排程效能。允許我們透過 bad-percentage-of-nodes-to-score 引數, 設定 filter 的計算比例(預設 50%), 當節點數大於 100 個, 在 filters的過程,只要滿足條件的節點數超過這個比例,就會停止 filter 過程,而不是計算全部節點。
舉個例子,當節點數為 1000, 我們設定的計算比例為 30%,那麼排程器認為 filter 過程只需要找到滿足條件的 300 個節點,filter 過程中當滿足條件的節點數達到 300 個,filter 過程結束。 這樣 filter 不用計算全部的節點,同樣也會降低 Prioritize 的計算數量。 但是帶來的影響是 pod 有可能沒有被排程到最合適的節點。
Prioritize
Prioritize 的目的是幫助 pod,為每個符合條件的節點打分,幫助 pod 找到最合適的節點。同樣排程器預設註冊了一系列 Prioritize 方法。這是 Prioritize 物件的資料結構:
// PriorityConfig is a config used for a priority function.
type PriorityConfig struct {
Name string
Map PriorityMapFunction
Reduce PriorityReduceFunction
// TODO: Remove it after migrating all functions to
// Map-Reduce pattern.
Function PriorityFunction
Weight int
}
每個 PriorityConfig 代表一個評分的指標,會考慮服務的均衡性,節點的資源分配等因素。 一個 PriorityConfig 的主要 Scoring 過程分為 Map 和 Reduce:
Map 過程計算每個節點的分數值
Reduce 過程會將當前 PriorityConfig 的所有節點的打分結果再做一次處理。
所有 PriorityConfig 計算完畢後,將每個 PriorityConfig 的數值乘以對應的權重,並按照節點再做一次聚合。
workqueue.ParallelizeUntil(context.TODO(), 16, len(nodes), func(index int) {
nodeInfo := nodeNameToInfo[nodes[index].Name]
for i := range priorityConfigs {
var err error
results[i][index], err = priorityConfigs[i].Map(pod, meta, nodeInfo)
}
})
for i := range priorityConfigs {
wg.Add(1)
go func(index int) {
defer wg.Done()
if err := priorityConfigs[index].Reduce(pod, meta, nodeNameToInfo, results[index]);
}(i)
}
wg.Wait()
// Summarize all scores.
result := make(schedulerapi.HostPriorityList, 0, len(nodes))
for i := range nodes {
result = append(result, schedulerapi.HostPriority{Host: nodes[i].Name, Score: 0})
for j := range priorityConfigs {
result[i].Score += results[j][i].Score * priorityConfigs[j].Weight
}
}
此外 Filter 和 Prioritize 都支援 extener scheduler 的呼叫,本文不做過多闡述。
現狀
目前 Kubernetes 排程器的排程方式是 Pod-by-Pod,也是當前排程器不足的地方。主要瓶頸如下:
Kubernetes 目前排程的方式,每個 pod 會對所有節點都計算一遍,當叢集規模非常大,節點數很多時,pod 的排程時間會非常慢。 這也是 percentage-of-nodes-to-score 嘗試要解決的問題;
pod-by-pod 的排程方式不適合一些機器學習場景。 Kubernetes 早期設計主要為線上任務服務,在一些離線任務場景,比如分散式機器學習中,我們需要一種新的演算法 gang scheduler,pod 也許對排程的即時性要求沒有那麼高,但是提交任務後,只有當一個批次計算任務的所有 workers 都執行起來時,才會開始計算任務。 pod-by-pod 方式在這個場景下,當資源不足時非常容易引起資源死鎖;
當前排程器的擴充套件性不是十分好,特定場景的排程流程都需要透過硬編碼實現在主流程中,比如我們看到的 bindVolume 部分, 同樣也導致 Gang Scheduler 無法在當前排程器框架下透過原生方式實現。
Kubernetes 排程期的發展
社群排程器的發展,也是為了解決這些問題:
排程器 V2 框架,增強了擴充套件性,也為在原生排程器中實現 Gang schedule 做準備;
Kube-batch: 一種 Gang schedule 的實現 ;
poseidon: Firmament 一種基於網路圖排程演算法的排程器,poseidon 是將 Firmament 接入 Kubernetes 排程器的實現 。
參考文獻
[1]
[2] https://jvns.ca/blog/2017/07/27/how-does-the-kubernetes-scheduler-work/
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31555606/viewspace-2637852/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- Kubernetes 排程器
- 改造 Kubernetes 自定義排程器
- kubernetes 排程
- kubernetes實踐之三十八:Pod排程
- Kubernetes之Pod排程
- Kubernetes叢集排程器原理剖析及思考
- kubernetes負載感知排程負載
- 深入 Java Timer 定時排程器實現原理Java
- Flink排程之排程器、排程策略、排程模式模式
- Kubernetes排程流程與安全(七)
- golang實現併發爬蟲三(用佇列排程器實現)Golang爬蟲佇列
- TKE 使用者故事 | 作業幫 Kubernetes 原生排程器優化實踐優化
- 深入 Java Timer 定時任務排程器實現原理Java
- kubernetes排程概念與工作流程
- 什麼是容器編排,Kubernetes如何實現
- Go排程器系列(2)巨集觀看排程器Go
- Kubernetes Pod排程:從基礎到高階實戰技巧
- 進擊的 Kubernetes 排程系統(一):Kubernetes scheduling frameworkFramework
- Quill編輯器實現原理初探UI
- OS_程式排程:C++實現C++
- Go排程器系列(3)圖解排程原理Go圖解
- 排程器簡介,以及Linux的排程策略Linux
- Go語言排程器之主動排程(20)Go
- Go runtime 排程器精講(五):排程策略Go
- Go runtime 排程器精講(二):排程器初始化Go
- Yarn的排程器Yarn
- kubernetes叢集內排程與負載均衡負載
- Kubernetes 資源拓撲感知排程優化優化
- Go語言排程器之排程main goroutine(14)GoAI
- Pod的排程是由排程器(kube-scheduler)
- 簡版排程中心搭建及實現思路
- 使用Java實現定時任務排程Java
- k8s排程器介紹(排程框架版本)K8S框架
- 也談goroutine排程器Go
- Linux I/O排程器Linux
- Go Runtime 的排程器Go
- Kubernetes高階排程- Taint和Toleration、Node Affinity分析AI
- Kubernetes 資源拓撲感知排程最佳化