序文
この記事を読む前に、「kube-schedulerのSchedulingQueueの分析」から始めることをお勧めします。これは、作成者がkube-schedulerのソースコード実装を、ブレークスルーとしてスケジューリングキューを使用して段階的かつ詳細に分析し始めたためです。「kube-schedulerのSchedulingQueueの分析」に要約されているように、スケジューリングキュー(SchedulingQueue)は、保留状態のすべてのポッド、つまりスケジュールされていないポッドです。この記事で分析されるキャッシュは、すべてスケジュールされたポッドです(想定されるスケジュールされたポッドを含む)。また、キャッシュは、検索を容易にするためにスケジュールされたポッドを保存するだけでなく、キャッシュ自体の定義の範囲を超えても、スケジュールのための非常に重要なステータス情報を提供します。
キャッシュとして定義されているため、次の質問に答える必要があります。
- キャッシュは誰ですか?kubernetesの情報はetcdに保存され、kubernetesのetcdにアクセスする唯一の方法はapiserverを使用するため、etcd情報を正確にキャッシュできます。
- どのような情報がキャッシュされますか?スケジューラーは、需要を満たすノードにポッドをスケジュールする必要があるため、kube-schedulerのapiserverへのアクセスのパフォーマンスを向上させるために、キャッシュは少なくともポッドとノードの情報をキャッシュする必要があります。
- なぜキャッシュするのですか?client-go(作成者はclient-goに関するいくつかの記事を持っているので、無視すればこれらの記事を読むことができます)はすでにキャッシュ機能を提供しているので、kube-schedulerにキャッシュのレイヤーを追加する目的は何ですか?答えは、スケジューリングのために簡単です。この記事のキャッシュは、ポッドとノードの情報をキャッシュするだけでなく、さらに重要なことに、この記事の焦点であるスケジューリングを容易にするためにスケジューリング結果を集約します。
ノードの翻訳が元の意味を失うのを防ぐために、この記事では、ノードをノードやサーバーなどに翻訳するのではなく、ノードを直接引用しています。同時に、Bindの翻訳のあいまいさを回避し、翻訳せずに直接引用します。
この記事で使用されているソースコードは、kubenretesのrelease-1.20ブランチであり、最新のKubernetesバージョンのドキュメントリンクです:https://github.com/jindezgm/k8s-src-analysis/blob/master/kube-scheduler/Cache.md
キャッシュ
キャッシュの抽象化
著者は前の記事ですでにキャッシュの3つの質問に答えていますが、最初にキャッシュのインターフェイス設計からいくつかの答えを見つけることができますか?ソースリンク:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/cache/interface.go#L58
type Cache interface {
// 获取node的数量,用于单元测试使用,本文不做说明
NodeCount() int
// 获取Pod的数量,用于单元测试使用,本文不做说明
PodCount() (int, error)
// 此处需要给出一个概念:假定Pod,就是将Pod假定调度到指定的Node,但还没有Bind完成。
// 为什么要这么设计?因为kube-scheduler是通过异步的方式实现Bind,在Bind完成前,
// 调度器还要调度新的Pod,此时就先假定Pod调度完成了。至于什么是Bind?为什么Bind?
// 怎么Bind?笔者会在其他文章中解析,此处简单理解为:需要将Pod的调度结果写入etcd,
// 持久化调度结果,所以也是相对比较耗时的操作。
// AssumePod会将Pod的资源需求累加到Node上,这样kube-scheduler在调度其他Pod的时候,
// 就不会占用这部分资源。
AssumePod(pod *v1.Pod) error
// 前面提到了,Bind是一个异步过程,当Bind完成后需要调用这个接口通知Cache,
// 如果完成Bind的Pod长时间没有被确认(确认方法是AddPod),那么Cache就会清理掉假定过期的Pod。
FinishBinding(pod *v1.Pod) error
// 删除假定的Pod,kube-scheduler在调用AssumePod后如果遇到其他错误,就需要调用这个接口
ForgetPod(pod *v1.Pod) error
// 添加Pod既确认了假定的Pod,也会将假定过期的Pod重新添加回来。
AddPod(pod *v1.Pod) error
// 更新Pod,其实就是删除再添加
UpdatePod(oldPod, newPod *v1.Pod) error
// 删除Pod.
RemovePod(pod *v1.Pod) error
// 获取Pod.
GetPod(pod *v1.Pod) (*v1.Pod, error)
// 判断Pod是否假定调度
IsAssumedPod(pod *v1.Pod) (bool, error)
// 添加Node的全部信息
AddNode(node *v1.Node) error
// 更新Node的全部信息
UpdateNode(oldNode, newNode *v1.Node) error
// 删除Node的全部信息
RemoveNode(node *v1.Node) error
// 其实就是产生Cache的快照并输出到nodeSnapshot中,那为什么是更新呢?
// 因为快照比较大,产生快照也是一个比较重的任务,如果能够基于上次快照把增量的部分更新到上一次快照中,
// 就会变得没那么重了,这就是接口名字是更新快照的原因。文章后面会重点分析这个函数,
// 因为其他接口非常简单,理解了这个接口基本上就理解了Cache的精髓所在。
UpdateSnapshot(nodeSnapshot *Snapshot) error
// Dump会快照Cache,用于调试使用,不是重点,所以本文不会对该函数做说明。
Dump() *Dump
}
キャッシュのインターフェース設計から、キャッシュはポッドとノードの情報のみをキャッシュし、ポッドとノードの情報はetcdに保存されていることがわかります(kubectlを使用して追加、削除、変更、チェックできます)。したがって、キャッシュがポッドをキャッシュしていることを確認できます。およびetcdのノード情報。
NodeInfoの定義
SchedulingQueueでは、スケジューリングキューはQueuedPodInfoタイプを定義し、PodAPIに基づいてスケジューリングキューに関連する属性を展開します。同様に、Node APIはNodeのパブリック属性にすぎず、キャッシュ内のノードはキャッシュに関連する属性を拡張する必要があるため、NodeInfoタイプがあります。ソースリンク:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/framework/types.go#L189
// NodeInfo是Node层的汇聚信息
type NodeInfo struct {
// Node API对象,无需过多解释
node *v1.Node
// 运行在Node上的所有Pod,PodInfo的定义读者自己查看,本文不再扩展了
Pods []*PodInfo
// PodsWithAffinity是Pods的子集,所有的Pod都声明了亲和性
PodsWithAffinity []*PodInfo
// PodsWithRequiredAntiAffinity是Pods子集,所有的Pod都声明了反亲和性
PodsWithRequiredAntiAffinity []*PodInfo
// 本文无关,忽略
UsedPorts HostPortInfo
// 此Node上所有Pod的总Request资源,包括假定的Pod,调度器已发送该Pod进行绑定,但可能尚未对其进行调度。
Requested *Resource
// Pod的容器资源请求有的时候是0,kube-scheduler为这类容器设置默认的资源最小值,并累加到NonZeroRequested.
// 也就是说,NonZeroRequested等于Requested加上所有按照默认最小值累加的零资源
// 这并不反映此节点的实际资源请求,而是用于避免将许多零资源请求的Pod调度到一个Node上。
NonZeroRequested *Resource
// Node的可分配的资源量
Allocatable *Resource
// 镜像状态,比如Node上有哪些镜像,镜像的大小,有多少Node相应的镜像等。
ImageStates map[string]*ImageStateSummary
// 与本文无关,忽略
TransientInfo *TransientSchedulerInfo
// 类似于版本,NodeInfo的任何状态变化都会使得Generation增加,比如有新的Pod调度到Node上
// 这个Generation很重要,可以用于只复制变化的Node对象,后面更新镜像的时候会详细说明
Generation int64
}
nodeTree
NodeTreeは、ノードをゾーンごとにツリー構造に編成します。ゾーンごとにリストしたり、ゾーンごとに完全に並べ替えたりする必要がある場合は、nodeTreeが使用されます。なぜそのような要求、またはその文、スケジューリングの必要性があるのですか?適切でない可能性のある例を挙げてください。たとえば、複数のポッドコピーを同じエリアまたは異なるエリアに展開する必要があります。
ソース接続:https://github.com/kubernetes/kubernetes/blob/master/pkg/scheduler/internal/cache/node_tree.go#L32
type nodeTree struct {
// map的键是zone名字,map的值是该区域内所有Node的名字。
tree map[string][]string
// 所有的zone的名字
zones []string
// Node的数量
numNodes int
}
nodeTreeは、ノードの名前をツリーに編成するだけです。NodeInfoが必要な場合は、ノードの名前に基づいてNodeInfoを見つける必要があります。
スナップショット
スナップショットは、特定の時点でのキャッシュのコピーです。時間が経つにつれて、キャッシュの状態は継続的に更新されます。kube-schedulerがポッドをスケジュールするとき、キャッシュのスナップショットを取得する必要があります。キャッシュに直接アクセスする場合と比較して、スナップショットは次の問題を解決できます。
- スナップショットへの変更はありません。これは読み取り専用と理解できるため、原子性を確保するためにスナップショットへのアクセスをロックする必要はありません。
- スナップショットとキャッシュは読み取りと書き込みを分離します。これにより、大規模なロックによってキャッシュアクセスのパフォーマンスが低下するのを防ぐことができます。
スナップショットのステータスは(キャッシュはいつでも更新される可能性があるため)キャッシュの作成当初から遅れていますが、kube-schedulerがポッドをスケジュールすることは問題ありません。理由については、分析とスケジューリングのプロセス。
ソースリンク:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/cache/snapshot.go#L29
// 从定义上看,快照只有Node信息,没有Pod信息,其实Node信息中已经有Pod信息了,这个在NodeInfo中已经说明了
type Snapshot struct {
// nodeInfoMap用于根据Node的key(NS+Name)快速查找Node
nodeInfoMap map[string]*framework.NodeInfo
// nodeInfoList是Cache中Node全集列表(不包含已删除的Node),按照nodeTree排序.
nodeInfoList []*framework.NodeInfo
// 只要Node上有任何Pod声明了亲和性,那么该Node就要放入havePodsWithAffinityNodeInfoList。
// 为什么要有这个变量?当然是为了调度,比如PodA需要和PodB调度在一个Node上。
havePodsWithAffinityNodeInfoList []*framework.NodeInfo
// havePodsWithRequiredAntiAffinityNodeInfoList和havePodsWithAffinityNodeInfoList相似,
// 只是Pod声明了反亲和,比如PodA不能和PodB调度在一个Node上
havePodsWithRequiredAntiAffinityNodeInfoList []*framework.NodeInfo
// generation是所有NodeInfo.Generation的最大值,因为所有NodeInfo.Generation都源于一个全局的Generation变量,
// 那么Cache中的NodeInfo.Gerneraion大于该值的就是在快照产生后更新过的Node。
// kube-scheduler调用Cache.UpdateSnapshot的时候只需要更新快照之后有变化的Node即可
generation int64
}
キャッシュの実装
前の舗装で十分です。次に、キーの内容を入力します。キャッシュ実装クラスschedulerCacheの定義を見てみましょう。ソースリンク:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/cache/cache.go#L57
// schedulerCache实现了Cache接口
type schedulerCache struct {
// 这个比较好理解,用来通知schedulerCache停止的chan,说明schedulerCache有自己的协程
stop <-chan struct{}
// 假定Pod一旦完成绑定,就要在指定的时间内确认,否则就会超时,ttl就是指定的过期时间,默认30秒
ttl time.Duration
// 定时清理“假定过期”的Pod,period就是定时周期,默认是1秒钟
// 前面提到了schedulerCache有自己的协程,就是定时清理超时的假定Pod.
period time.Duration
// 锁,说明schedulerCache利用互斥锁实现协程安全,而不是用chan与其他协程交互。
// 这一点实现和SchedulingQueue是一样的。
mu sync.RWMutex
// 假定Pod集合,map的key与podStates相同,都是Pod的NS+NAME,值为true就是假定Pod
// 其实assumedPods的值没有false的可能,感觉assumedPods用set类型(map[string]struct{}{})更合适
assumedPods map[string]bool
// 所有的Pod,此处用的是podState,后面有说明,与SchedulingQueue中提到的QueuedPodInfo类似,
// podState继承了Pod的API定义,增加了Cache需要的属性
podStates map[string]*podState
// 所有的Node,键是Node.Name,值是nodeInfoListItem,后面会有说明,只需要知道map类型就可以了
nodes map[string]*nodeInfoListItem
// 所有的Node再通过双向链表连接起来
headNode *nodeInfoListItem
// 节点按照zone组织成树状,前面提到用nodeTree中Node的名字再到nodes中就可以查找到NodeInfo.
nodeTree *nodeTree
// 镜像状态,本文不做重点说明,只需要知道Cache还统计了镜像的信息就可以了。
imageStates map[string]*imageState
}
// podState与继承了Pod的API类型定义,同时扩展了schedulerCache需要的属性.
type podState struct {
pod *v1.Pod
// 假定Pod的超时截止时间,用于判断假定Pod是否过期。
deadline *time.Time
// 调用Cache.AssumePod的假定Pod不是所有的都需要判断是否过期,因为有些假定Pod可能还在Binding
// bindingFinished就是用于标记已经Bind完成的Pod,然后开始计时,计时的方法就是设置deadline
// 还记得Cache.FinishBinding接口么?就是用来设置bindingFinished和deadline的,后面代码会有解析
bindingFinished bool
}
// nodeInfoListItem定义了nodeInfoList双向链表的item,nodeInfoList的实现非常简单,不多解释。
type nodeInfoListItem struct {
info *framework.NodeInfo
next *nodeInfoListItem
prev *nodeInfoListItem
}
問題は、すでにノード(マップタイプ)変数があるのに、なぜ別のheadNode(リストタイプ)変数を追加するのかということです。これは余計なことではありませんか?実際にはそうではありません。ノードはノードの名前に基づいてノードをすばやく見つけることができますが、headNodeは特定のルールに従ってソートされます。この点は、SchedulingQueueで導入されたキューを実装するためのマップ/スライスの使用と同じです。スライスの代わりにリストが使用される理由については、ソート方法のリンクリストの効率がスライスの効率よりも高い必要があります。後でheadNodeの更新で説明しますが、ここでは除外します。
SchedulerCacheの定義から、基本的にほとんどのキャッシュインターフェイスの実装を推測できます。この記事では、比較的単純なインターフェイスの実装について簡単に説明し、いくつかの主要な関数にテキストを配置します。PodCountとNodeCountの2つの関数は単体テストに使用されるため、この記事ではそれらについては説明しません。
AssumePod
kube-schedulerは、最適なノードスケジューリングポッドを見つけると、ポッドスケジューリングを想定してAssumePodを呼び出し、別のコルーチンを介して非同期バインドします。それが実際にリソースを占有していると仮定すると、確認メッセージAddPodがスケジューリングの成功を確認するか、バインドが失敗するまで、kube-schedulerは次のポッドをスケジュールするときにリソースのこの部分を取得しません。ForgetPodは仮想スケジューリングをキャンセルします。コードリンク:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/cache/cache.go#L361
func (cache *schedulerCache) AssumePod(pod *v1.Pod) error {
// 获取Pod的唯一key,就是NS+Name,因为kube-scheduler调度整个集群的Pod
key, err := framework.GetPodKey(pod)
if err != nil {
return err
}
cache.mu.Lock()
defer cache.mu.Unlock()
// 如果Pod已经存在,则不能假定调度。因为在Cache中的Pod要么是假定调度的,要么是完成调度的
if _, ok := cache.podStates[key]; ok {
return fmt.Errorf("pod %v is in the cache, so can't be assumed", key)
}
// 见下面代码注释
cache.addPod(pod)
ps := &podState{
pod: pod,
}
// 把Pod添加到map中,并标记为assumed
cache.podStates[key] = ps
cache.assumedPods[key] = true
return nil
}
func (cache *schedulerCache) addPod(pod *v1.Pod) {
// 查找Pod调度的Node,如果不存在则创建一个虚Node,虚Node只是没有Node API对象。
// 为什么会这样?可能kube-scheduler调度Pod的时候Node被删除了,可能很快还会添加回来
// 也可能就彻底删除了,此时先放在这个虚的Node上,如果Node不存在后期还会被迁移。
n, ok := cache.nodes[pod.Spec.NodeName]
if !ok {
n = newNodeInfoListItem(framework.NewNodeInfo())
cache.nodes[pod.Spec.NodeName] = n
}
// AddPod就是把Pod的资源累加到NodeInfo中,本文不做详细说明,感兴趣的读者自行查看源码
// 但需要知道的是n.info.AddPod(pod)会更新NodeInfo.Generation,表示NodeInfo是最新的
n.info.AddPod(pod)
// 将Node放到schedulerCache.headNode队列头部,因为NodeInfo当前是最新的,所以放在头部。
// 此处可以解答为什么用list而不是slice,因为每次都是将Node直接放在第一个位置,明显list效率更高
// 所以headNode是按照最近更新排序的
cache.moveNodeInfoToHead(pod.Spec.NodeName)
}
ForgetPod
ポッドが事前にいくつかのリソースを占有していると仮定すると、後続の操作(バインドなど)でエラーが発生した場合は、仮想のスケジューリングをキャンセルしてリソースを解放する必要があります。コードリンク:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/cache/cache.go#L406
func (cache *schedulerCache) ForgetPod(pod *v1.Pod) error {
// 获取Pod唯一key
key, err := framework.GetPodKey(pod)
if err != nil {
return err
}
cache.mu.Lock()
defer cache.mu.Unlock()
// 这里有意思了,也就是说Cache假定Pod的Node名字与传入的Pod的Node名字不一致,则返回错误
// 这种情况会不会发生呢?有可能,但是可能性不大,毕竟多协程修改Pod调度状态会有各种可能性。
// 此处留挖一个坑,在解析kube-scheduler调度流程的时候看看到底什么极致的情况会触发这种问题。
currState, ok := cache.podStates[key]
if ok && currState.pod.Spec.NodeName != pod.Spec.NodeName {
return fmt.Errorf("pod %v was assumed on %v but assigned to %v", key, pod.Spec.NodeName, currState.pod.Spec.NodeName)
}
switch {
// 只有假定Pod可以被Forget,因为Forget就是为了取消假定Pod的。
case ok && cache.assumedPods[key]:
// removePod()就是把假定Pod的资源从NodeInfo中减去,见下面代码注释
err := cache.removePod(pod)
if err != nil {
return err
}
// 删除Pod和假定状态
delete(cache.assumedPods, key)
delete(cache.podStates, key)
// 要么Pod不存在,要么Pod已确认调度,这两者都不能够被Forget
default:
return fmt.Errorf("pod %v wasn't assumed so cannot be forgotten", key)
}
return nil
}
func (cache *schedulerCache) removePod(pod *v1.Pod) error {
// 找到假定Pod调度的Node
n, ok := cache.nodes[pod.Spec.NodeName]
if !ok {
klog.Errorf("node %v not found when trying to remove pod %v", pod.Spec.NodeName, pod.Name)
return nil
}
// 减去假定Pod的资源,并从NodeInfo的Pod列表移除假定Pod
// 和n.info.AddPod相同,也会更新NodeInfo.Generation
if err := n.info.RemovePod(pod); err != nil {
return err
}
// 如果NodeInfo的Pod列表没有任何Pod并且Node被删除,则Node从Cache中删除
// 否则将NodeInfo移到列表头,因为NodeInfo被更新,需要放到表头
// 这里需要知道的是,Node被删除Cache不会立刻删除该Node,需要等到Node上所有的Pod从Node中迁移后才删除,
// 具体实现逻辑后续文章会给出,此处先知道即可。
if len(n.info.Pods) == 0 && n.info.Node() == nil {
cache.removeNodeInfoFromList(pod.Spec.NodeName)
} else {
cache.moveNodeInfoToHead(pod.Spec.NodeName)
}
return nil
}
FinishBinding
想定されるポッドのバインドが完了したら、FinishBindingを呼び出して、想定されるポッドが期限切れになるまでタイミングを開始するようにキャッシュに通知する必要があります。それでもAddPodリクエストが受信されない場合、期限切れの想定されるポッドは削除されます。コードリンク:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/cache/cache.go#L382
func (cache *schedulerCache) FinishBinding(pod *v1.Pod) error {
// 取当前时间
return cache.finishBinding(pod, time.Now())
}
func (cache *schedulerCache) finishBinding(pod *v1.Pod, now time.Time) error {
// 获取Pod唯一key
key, err := framework.GetPodKey(pod)
if err != nil {
return err
}
cache.mu.RLock()
defer cache.mu.RUnlock()
klog.V(5).Infof("Finished binding for pod %v. Can be expired.", key)
// Pod存在并且是假定状态才行
currState, ok := cache.podStates[key]
if ok && cache.assumedPods[key] {
// 标记为完成Binding,并且设置过期时间,还记得ttl默认是多少么?30秒。
dl := now.Add(cache.ttl)
currState.bindingFinished = true
currState.deadline = &dl
}
return nil
}
AddPod
ポッドバインドが成功すると、kube-schedulerはメッセージを受信し、AddPodを呼び出してスケジューリング結果を確認します。コードリンク:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/cache/cache.go#L476
func (cache *schedulerCache) AddPod(pod *v1.Pod) error {
// 获取Pod唯一key
key, err := framework.GetPodKey(pod)
if err != nil {
return err
}
cache.mu.Lock()
defer cache.mu.Unlock()
// 以下是根据Pod在Cache中的状态决定需要如何处理
currState, ok := cache.podStates[key]
switch {
// Pod是假定调度
case ok && cache.assumedPods[key]:
// Pod实际调度的Node和假定的不一致?
if currState.pod.Spec.NodeName != pod.Spec.NodeName {
klog.Warningf("Pod %v was assumed to be on %v but got added to %v", key, pod.Spec.NodeName, currState.pod.Spec.NodeName)
// 如果不一致,先从假定调度的NodeInfo中减去Pod占用的资源,然后在累加到新NodeInfo中
// 这种情况会在什么时候发生?还是留给后续文章分解吧
if err = cache.removePod(currState.pod); err != nil {
klog.Errorf("removing pod error: %v", err)
}
cache.addPod(pod)
}
// 删除假定状态
delete(cache.assumedPods, key)
// 清空假定过期时间,理论上从cache.assumedPods删除,假定过期时间自然也就失效了
cache.podStates[key].deadline = nil
// 这里有意思了,为什么要在赋值一次?currState中不是已经在AssumePod的时候设置了么?
// 道理很简单,这是同一个Pod的两个副本,而当前参数‘pod’版本更新
cache.podStates[key].pod = pod
// Pod不存在
case !ok:
// Pod可能已经假定过期被删除了,需要重新添加回来
cache.addPod(pod)
ps := &podState{
pod: pod,
}
cache.podStates[key] = ps
// Pod已经执行过AddPod,有句高大上名词叫什么来着?对了,幂等!
default:
return fmt.Errorf("pod %v was already in added state", key)
}
return nil
}
RemovePod
kube-schedulerは、ポッドを削除するリクエストを受け取ります。ポッドがキャッシュ内にある場合は、RemovePodを呼び出す必要があります。コードリンク:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/cache/cache.go#L541
func (cache *schedulerCache) RemovePod(pod *v1.Pod) error {
// 获取Pod唯一key
key, err := framework.GetPodKey(pod)
if err != nil {
return err
}
cache.mu.Lock()
defer cache.mu.Unlock()
// 根据Pod在Cache中的状态执行相应的操作
currState, ok := cache.podStates[key]
switch {
// 只有执行AddPod的Pod才能够执行RemovePod,假定Pod是不会执行RemovePod的,为什么?
// 我只能说就是这么设计的,假定Pod是不会执行这个函数的,这涉及到Pod删除的全流程,
// 已经超纲了。。。,我肯定会有文章解析,此处再挖一个坑。
case ok && !cache.assumedPods[key]:
// 卧槽,Pod的Node和AddPod时的Node不一样?这回的选择非常直接,奔溃,已经超时异常解决范围了
// 如果再继续下去可能会造成调度状态的混乱,不如重启再来。
if currState.pod.Spec.NodeName != pod.Spec.NodeName {
klog.Errorf("Pod %v was assumed to be on %v but got added to %v", key, pod.Spec.NodeName, currState.pod.Spec.NodeName)
klog.Fatalf("Schedulercache is corrupted and can badly affect scheduling decisions")
}
// 从NodeInfo中减去Pod的资源
err := cache.removePod(currState.pod)
if err != nil {
return err
}
// 从Cache中删除Pod
delete(cache.podStates, key)
default:
return fmt.Errorf("pod %v is not found in scheduler cache, so cannot be removed from it", key)
}
return nil
}
AddNode
新しいノードがクラスターに追加されると、kube-schedulerはこのインターフェースを呼び出してキャッシュに通知します。コードリンク:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/cache/cache.go#L605
func (cache *schedulerCache) AddNode(node *v1.Node) error {
cache.mu.Lock()
defer cache.mu.Unlock()
n, ok := cache.nodes[node.Name]
if !ok {
// 如果NodeInfo不存在则创建
n = newNodeInfoListItem(framework.NewNodeInfo())
cache.nodes[node.Name] = n
} else {
// 已存在,先删除镜像状态,因为后面还会在添加回来
cache.removeNodeImageStates(n.info.Node())
}
// 将Node放到列表头
cache.moveNodeInfoToHead(node.Name)
// 添加到nodeTree中
cache.nodeTree.addNode(node)
// 添加Node的镜像状态,感兴趣的读者自行了解,本文不做重点
cache.addNodeImageStates(node, n.info)
// 只有SetNode的NodeInfo才是真实的Node,否则就是前文提到的虚的Node
return n.info.SetNode(node)
}
RemoveNode
ノードがクラスターから削除され、kube-schedulerがこのインターフェースを呼び出してキャッシュに通知します。コードリンク:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/cache/cache.go#L648
func (cache *schedulerCache) RemoveNode(node *v1.Node) error {
cache.mu.Lock()
defer cache.mu.Unlock()
// 如果Node不存在返回错误
n, ok := cache.nodes[node.Name]
if !ok {
return fmt.Errorf("node %v is not found", node.Name)
}
// RemoveNode就是将*v1.Node设置为nil,此时Node就是虚的了
n.info.RemoveNode()
// 当Node上没有运行Pod的时候删除Node,否则把Node放在列表头,因为Node状态更新了
// 熟悉etcd的同学会知道,watch两个路径(Node和Pod)是两个通道,这样会造成两个通道的事件不会按照严格时序到达
// 这应该是存在虚Node的原因之一。
if len(n.info.Pods) == 0 {
cache.removeNodeInfoFromList(node.Name)
} else {
cache.moveNodeInfoToHead(node.Name)
}
// 虽然nodes只有在NodeInfo中Pod数量为零的时候才会被删除,但是nodeTree会直接删除
// 说明nodeTree中体现了实际的Node状态,kube-scheduler调度Pod的时候也是利用nodeTree
// 这样就不会将Pod调度到已经删除的Node上了。
if err := cache.nodeTree.removeNode(node); err != nil {
return err
}
cache.removeNodeImageStates(node)
return nil
}
クリーンアップ後のコルーチン関数の実行
前述のように、キャッシュには独自のコルーチンがあり、期限切れになると思われるポッドをクリーンアップするために使用されます。コードリンク:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/cache/cache.go#L724
func (cache *schedulerCache) run() {
// 定时1秒钟执行一次cleanupExpiredAssumedPods
go wait.Until(cache.cleanupExpiredAssumedPods, cache.period, cache.stop)
}
func (cache *schedulerCache) cleanupExpiredAssumedPods() {
// 取当前时间
cache.cleanupAssumedPods(time.Now())
}
func (cache *schedulerCache) cleanupAssumedPods(now time.Time) {
cache.mu.Lock()
defer cache.mu.Unlock()
defer cache.updateMetrics()
// 遍历假定Pod
for key := range cache.assumedPods {
// 获取Pod
ps, ok := cache.podStates[key]
if !ok {
klog.Fatal("Key found in assumed set but not in podStates. Potentially a logical error.")
}
// 如果Pod没有标记为结束Binding,则忽略,说明Pod还在Binding中
// 说白了就是没有调用FinishBinding的Pod不用处理
if !ps.bindingFinished {
klog.V(5).Infof("Couldn't expire cache for pod %v/%v. Binding is still in progress.",
ps.pod.Namespace, ps.pod.Name)
continue
}
// 如果当前时间已经超过了Pod假定过期时间,说明Pod假定时间已过期
if now.After(*ps.deadline) {
// 此类情况属于异常情况,所以日志等级是waning
klog.Warningf("Pod %s/%s expired", ps.pod.Namespace, ps.pod.Name)
// 清理假定过期的Pod
if err := cache.expirePod(key, ps); err != nil {
klog.Errorf("ExpirePod failed for %s: %v", key, err)
}
}
}
}
func (cache *schedulerCache) expirePod(key string, ps *podState) error {
// 从NodeInfo中减去Pod资源、镜像等状态
if err := cache.removePod(ps.pod); err != nil {
return err
}
// 从Cache中删除Pod
delete(cache.assumedPods, key)
delete(cache.podStates, key)
return nil
}
実際、もっと深刻な問題があります。期限切れのポッドリソースが解放されると想定され、新しいポッドが、期限切れのポッドと同じノードにスケジュールされている場合、ポッドはAddPodによって追加されます。ノードリソースが過負荷になる可能性があります。著者はこの質問を将来の記事に残して答えます。
UpdateSnapshot
キャッシュの主な目的は、ノードの状態に応じて新しいポッドをスケジュールできるように、ノードのミラーリングをkube-schedulerに提供することであるため、前の記事での多くの予兆はUpdateSnapshotに関するものです。キャッシュ内のポッドは、ノードのリソースステータスを計算するために存在します。結局のところ、2つはetcd内の2つのパスです。言うことはあまりありませんが、コードに移動するだけです。コードリンク:https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/scheduler/internal/cache/cache.go#L203
// UpdateSnapshot更新的是参数nodeSnapshot,不是更新Cache.
// 也就是Cache需要找到当前与nodeSnapshot的差异,然后更新它,这样nodeSnapshot就与Cache状态一致了
// 至少从函数执行完毕后是一致的。
func (cache *schedulerCache) UpdateSnapshot(nodeSnapshot *Snapshot) error {
cache.mu.Lock()
defer cache.mu.Unlock()
// 与本文关系不大,鉴于不增加复杂性原则,先忽略他,从命名上看很容易立理解
balancedVolumesEnabled := utilfeature.DefaultFeatureGate.Enabled(features.BalanceAttachedNodeVolumes)
// 获取nodeSnapshot的版本,笔者习惯叫版本,其实就是版本的概念。
// 此处需要多说一点:kube-scheudler为Node定义了全局的generation变量,每个Node状态变化都会造成generation+=1然后赋值给该Node
// nodeSnapshot.generation就是最新NodeInfo.Generation,就是表头的那个NodeInfo。
snapshotGeneration := nodeSnapshot.generation
// 介绍Snapshot的时候提到了,快照中有三个列表,分别是全量、亲和性和反亲和性列表
// 全量列表在没有Node添加或者删除的时候,是不需要更新的
updateAllLists := false
// 当有Node的亲和性状态发生了变化(以前没有任何Pod有亲和性声明现在有了,抑或反过来),
// 则需要更新快照中的亲和性列表
updateNodesHavePodsWithAffinity := false
// 同上
updateNodesHavePodsWithRequiredAntiAffinity := false
// 遍历Node列表,为什么不遍历Node的map?因为Node列表是按照Generation排序的
// 只要找到大于nodeSnapshot.generation的所有Node然后把他们更新到nodeSnapshot中就可以了
for node := cache.headNode; node != nil; node = node.next {
// 说明Node的状态已经在nodeSnapshot中了,因为但凡Node有任何更新,那么NodeInfo.Generation
// 肯定会大于snapshotGeneration,同时该Node后面的所有Node也不用在遍历了,因为他们的版本更低
if node.info.Generation <= snapshotGeneration {
break
}
// 先忽略
if balancedVolumesEnabled && node.info.TransientInfo != nil {
// Transient scheduler info is reset here.
node.info.TransientInfo.ResetTransientSchedulerInfo()
}
// node.info.Node()获取*v1.Node,前文说了,如果Node被删除,那么该值就是为nil
// 所以只有未被删除的Node才会被更新到nodeSnapshot,因为快照中的全量Node列表是按照nodeTree排序的
// 而nodeTree都是真实的node
if np := node.info.Node(); np != nil {
// 如果nodeSnapshot中没有该Node,则在nodeSnapshot中创建Node,并标记更新全量列表,因为创建了新的Node
existing, ok := nodeSnapshot.nodeInfoMap[np.Name]
if !ok {
updateAllLists = true
existing = &framework.NodeInfo{}
nodeSnapshot.nodeInfoMap[np.Name] = existing
}
// 克隆NodeInfo,这个比较好理解,肯定不能简单的把指针设置过去,这样会造成多协程读写同一个对象
// 因为克隆操作比较重,所以能少做就少做,这也是利用Generation实现增量更新的原因
clone := node.info.Clone()
// 如果Pod以前或者现在有任何亲和性声明,则需要更新nodeSnapshot中的亲和性列表
if (len(existing.PodsWithAffinity) > 0) != (len(clone.PodsWithAffinity) > 0) {
updateNodesHavePodsWithAffinity = true
}
// 同上,需要更新非亲和性列表
if (len(existing.PodsWithRequiredAntiAffinity) > 0) != (len(clone.PodsWithRequiredAntiAffinity) > 0) {
updateNodesHavePodsWithRequiredAntiAffinity = true
}
// 将NodeInfo的拷贝更新到nodeSnapshot中
*existing = *clone
}
}
// Cache的表头Node的版本是最新的,所以也就代表了此时更新镜像后镜像的版本了
if cache.headNode != nil {
nodeSnapshot.generation = cache.headNode.info.Generation
}
// 如果nodeSnapshot中node的数量大于nodeTree中的数量,说明有node被删除
// 所以要从快照的nodeInfoMap中删除已删除的Node,同时标记需要更新node的全量列表
if len(nodeSnapshot.nodeInfoMap) > cache.nodeTree.numNodes {
cache.removeDeletedNodesFromSnapshot(nodeSnapshot)
updateAllLists = true
}
// 如果需要更新Node的全量或者亲和性或者反亲和性列表,则更新nodeSnapshot中的Node列表
if updateAllLists || updateNodesHavePodsWithAffinity || updateNodesHavePodsWithRequiredAntiAffinity {
cache.updateNodeInfoSnapshotList(nodeSnapshot, updateAllLists)
}
// 如果此时nodeSnapshot的node列表与nodeTree的数量还不一致,需要再做一次node全列表更新
// 此处应该是一个保险操作,理论上不会发生,谁知道会不会有Bug发生呢?多一些容错没有坏处
if len(nodeSnapshot.nodeInfoList) != cache.nodeTree.numNodes {
errMsg := fmt.Sprintf("snapshot state is not consistent, length of NodeInfoList=%v not equal to length of nodes in tree=%v "+
", length of NodeInfoMap=%v, length of nodes in cache=%v"+
", trying to recover",
len(nodeSnapshot.nodeInfoList), cache.nodeTree.numNodes,
len(nodeSnapshot.nodeInfoMap), len(cache.nodes))
klog.Error(errMsg)
// We will try to recover by re-creating the lists for the next scheduling cycle, but still return an
// error to surface the problem, the error will likely cause a failure to the current scheduling cycle.
cache.updateNodeInfoSnapshotList(nodeSnapshot, true)
return fmt.Errorf(errMsg)
}
return nil
}
// 先思考一个问题:为什么有Node添加或者删除需要更新快照中的全量列表?如果是Node删除了,
// 需要找到Node在全量列表中的位置,然后删除它,最悲观的复杂度就是遍历一遍列表,然后再挪动它后面的Node
// 因为快照的Node列表是用slice实现,所以一旦快照中Node列表有任何更新,复杂度都是Node的数量。
// 那如果是有新的Node添加呢?并不知道应该插在哪里,所以重新创建一次全量列表最为简单有效。
// 亲和性和反亲和性列表道理也是一样的。
func (cache *schedulerCache) updateNodeInfoSnapshotList(snapshot *Snapshot, updateAll bool) {
// 快照创建亲和性和反亲和性列表
snapshot.havePodsWithAffinityNodeInfoList = make([]*framework.NodeInfo, 0, cache.nodeTree.numNodes)
snapshot.havePodsWithRequiredAntiAffinityNodeInfoList = make([]*framework.NodeInfo, 0, cache.nodeTree.numNodes)
// 如果更新全量列表
if updateAll {
// 创建快照全量列表
snapshot.nodeInfoList = make([]*framework.NodeInfo, 0, cache.nodeTree.numNodes)
nodesList, err := cache.nodeTree.list()
if err != nil {
klog.Error(err)
}
// 遍历nodeTree的Node
for _, nodeName := range nodesList {
// 理论上快照的nodeInfoMap与nodeTree的状态是一致,此处做了判断用来检测BUG,下面的错误日志也是这么写的
if nodeInfo := snapshot.nodeInfoMap[nodeName]; nodeInfo != nil {
// 追加全量、亲和性(按需)、反亲和性列表(按需)
snapshot.nodeInfoList = append(snapshot.nodeInfoList, nodeInfo)
if len(nodeInfo.PodsWithAffinity) > 0 {
snapshot.havePodsWithAffinityNodeInfoList = append(snapshot.havePodsWithAffinityNodeInfoList, nodeInfo)
}
if len(nodeInfo.PodsWithRequiredAntiAffinity) > 0 {
snapshot.havePodsWithRequiredAntiAffinityNodeInfoList = append(snapshot.havePodsWithRequiredAntiAffinityNodeInfoList, nodeInfo)
}
} else {
klog.Errorf("node %q exist in nodeTree but not in NodeInfoMap, this should not happen.", nodeName)
}
}
} else {
// 如果更新全量列表,只需要遍历快照中的全量列表就可以了
for _, nodeInfo := range snapshot.nodeInfoList {
// 按需追加亲和性和反亲和性列表
if len(nodeInfo.PodsWithAffinity) > 0 {
snapshot.havePodsWithAffinityNodeInfoList = append(snapshot.havePodsWithAffinityNodeInfoList, nodeInfo)
}
if len(nodeInfo.PodsWithRequiredAntiAffinity) > 0 {
snapshot.havePodsWithRequiredAntiAffinityNodeInfoList = append(snapshot.havePodsWithRequiredAntiAffinityNodeInfoList, nodeInfo)
}
}
}
}
スナップショット内のノードのリストが何に使用されるかを考えてみてください。マップには順序がなく、リストはnodeTreeでソートされているため、スケジューリングに役立ちます。読者はそれを理解できるはずです。
総括する
-
キャッシュはポッドとノードの情報をキャッシュし、ノード情報はノードで実行されているすべてのポッドのリソース量とミラーリング情報を集約します。ノードは実数と仮想に分割され、削除されたノードはキャッシュによってすぐには削除されませんが、引き続き仮想ノードは、ノード上のポッドがクリアされるまで削除されませんが、実際のノードはnodeTreeに維持され、スケジュールにnodeTreeを使用すると、仮想ノード上のポッドのスケジュールを回避できます。
-
kube-schedulerはclient-goを使用してポッドとノードのステータスを監視(監視)します。イベントが発生すると、キャッシュのAddPod、RemovePod、UpdatePod、AddNode、RemoveNode、UpdateNodeを呼び出して、キャッシュ内のポッドとノードのステータスを更新します。そのkube-schedulerが新しいものを開始します。最新のステータスは、スケジューリングのラウンド中に取得できます。
-
kube-schedulerはUpdateSnapshotを呼び出して、スケジューリングの各ラウンドでローカル(ローカル変数)ノードのステータスを更新します。キャッシュ内のノードは最新の更新で並べ替えられるため、キャッシュ内のNode.Generationのノードのみを更新する必要があります。 kube-schedulerのローカルスナップショットの生成よりも大きいので、スナップショット内で、これにより多数の不要なコピーを回避できます。
-
kube-schedulerは、適切なノードスケジューリングポッドを見つけた後、ポッドがスケジュールされていると想定してCache.AssumePodを呼び出し、ノードへのコルーチン非同期バインドポッドを開始する必要があります。ポッドがバインドを完了すると、Cache.FinishBindingを呼び出します。キャッシュに通知する。
-
kube-scheudlerは、後続のすべてのアクションに対してCache.AssumePodを呼び出します。エラーが発生すると、Cache.ForgetPodを呼び出して、想定されるポッドを削除し、リソースを解放します。
-
バインドポッドのデフォルトのタイムアウトは30秒です。キャッシュには、バインドタイムアウトポッドをクリーンアップするためのコルーチンタイマー(1秒)があります。タイムアウト後にポッド確認メッセージ(AddPodの呼び出し)が受信されない場合、タイムアウトポッドは次のようになります。削除してから、Cache.AssumePodが占有していたリソースを解放します。
-
キャッシュのコア機能は、ノードのスケジューリングステータス(ポッドのリソース量の累積、統計ミラーリングなど)をカウントし、それをミラーリングの形式でkube-schedulerに出力することです。そして、kube-schedulerはポッドの待機を取り出します。ミラーリング計算に最適なノードに従って、スケジューリングキュー(SchedulingQueue)からスケジュールされます。
この時点で、ソースコードのポッドステートマシンに関するコメントを見ると、非常に簡単に理解できます。
// State Machine of a pod's events in scheduler's cache:
//
//
// +-------------------------------------------+ +----+
// | Add | | |
// | | | | Update
// + Assume Add v v |
//Initial +--------> Assumed +------------+---> Added <--+
// ^ + + | +
// | | | | |
// | | | Add | | Remove
// | | | | |
// | | | + |
// +----------------+ +-----------> Expired +----> Deleted
// Forget Expire
//
上記の要約では、kube-schedulerがポッドを大まかにスケジュールするプロセスについて説明しています。実際、kube-schedulerがポッドをスケジュールするプロセスは非常に複雑です。kube-schedulerでのキャッシュの位置と役割を理解しやすくするために、内容が台無しになっています。著者は、フォローアップ記事でkube-schedulerスケジューリングポッドの詳細なプロセスを詳細に分析します。
この記事の内容は少し急いで少しラフですが、後で最適化する時間があれば、質問がある読者はメッセージを残して伝えることができます。