第十五课 k8s源码学习和二次开发原理篇-调度插件原理分析
tags:
- k8s
- 源码学习
categories:
- 源码学习
- 二次开发
文章目录
第一节 调度插件执行原理
1.1 初始化调度器时Algorithm
- 前面我们是在整体上对 Pod 的调度流程进行了分析,但是真正核心的调度操作才是我们最需要关心的,也就是上面提到的
sched.Algorithm.Schedule()
函数的实现。 - 这里需要关注 Scheduler 下面的 Algorithm 属性,该属性是在初始化调度器的时候传入的:
// pkg/scheduler/scheduler.go
type Scheduler struct {
Algorithm core.ScheduleAlgorithm
......
}
// pkg/scheduler/factory.go
// 初始化 Scheduler
func (c *Configurator) create() (*Scheduler, error) {
......
algo := core.NewGenericScheduler(
c.schedulerCache,
c.nodeInfoSnapshot,
extenders,
c.informerFactory.Core().V1().PersistentVolumeClaims().Lister(),
c.disablePreemption,
c.percentageOfNodesToScore,
)
return &Scheduler{
Algorithm: algo,
......
}, nil
}
// pkg/scheduler/core/generic_scheduler.go
// ScheduleAlgorithm 是一个知道如何将 Pod 调度到节点上去的接口
type ScheduleAlgorithm interface {
Schedule(context.Context, *profile.Profile, *framework.CycleState, *v1.Pod) (scheduleResult ScheduleResult, err error)
Extenders() []framework.Extender
}
// NewGenericScheduler 创建一个 genericScheduler 对象
func NewGenericScheduler(
cache internalcache.Cache,
nodeInfoSnapshot *internalcache.Snapshot,
extenders []framework.Extender,
pvcLister corelisters.PersistentVolumeClaimLister,
disablePreemption bool,
percentageOfNodesToScore int32) ScheduleAlgorithm {
return &genericScheduler{
cache: cache,
extenders: extenders,
nodeInfoSnapshot: nodeInfoSnapshot,
pvcLister: pvcLister,
disablePreemption: disablePreemption,
percentageOfNodesToScore: percentageOfNodesToScore,
}
}
- 从定义上来看可以知道 Algorithm 是一个
ScheduleAlgorithm
接口,在初始化的时候我们使用的core.NewGenericScheduler()
来初始化 Algorithm,证明这个函数返回的 genericScheduler 对象一定会实现ScheduleAlgorithm
接口,所以我们在 scheduleOne 函数里面真正去调度的时候调用的sched.Algorithm.Schedule()
函数是genericScheduler
中的 Schedule() 方法。
1.2 Schedule函数
- 下面我们来分析下
genericScheduler
中的 Schedule() 函数的实现:
// pkg/scheduler/core/generic_scheduler.go
// Schedule 尝试将指定的 Pod 调度到一系列节点中的一个节点上去。
// 如果调度成功,将返回该节点名称
// 如果调度失败,将返回一个带有失败原因的 FitError
func (g *genericScheduler) Schedule(ctx context.Context, prof *profile.Profile, state *framework.CycleState, pod *v1.Pod) (result ScheduleResult, err error) {
// 检查最基本的条件
if err := podPassesBasicChecks(pod, g.pvcLister); err != nil {
return result, err
}
// 完成调度器缓存和节点信息的快照
if err := g.snapshot(); err != nil {
return result, err
}
// 判断当前快照中的节点数是否为0
if g.nodeInfoSnapshot.NumNodes() == 0 {
return result, ErrNoNodesAvailable
}
// 预选,先找到一些符合基本条件的节点
feasibleNodes, filteredNodesStatuses, err := g.findNodesThatFitPod(ctx, prof, state, pod)
if err != nil {
return result, err
}
// 没有找到合适的
if len(feasibleNodes) == 0 {
return result, &FitError{
Pod: pod,
NumAllNodes: g.nodeInfoSnapshot.NumNodes(),
FilteredNodesStatuses: filteredNodesStatuses,
}
}
// 如果预选过后只有1个节点,那么就直接返回这个节点信息就行了
if len(feasibleNodes) == 1 {
return ScheduleResult{
SuggestedHost: feasibleNodes[0].Name,
EvaluatedNodes: 1 + len(filteredNodesStatuses),
FeasibleNodes: 1,
}, nil
}
// 如果不止1个节点,那么就需要进行优选,给每个节点进行打分
priorityList, err := g.prioritizeNodes(ctx, prof, state, pod, feasibleNodes)
if err != nil {
return result, err
}
// 选择分数最高的作为最终的节点
host, err := g.selectHost(priorityList)
return ScheduleResult{
SuggestedHost: host,
EvaluatedNodes: len(feasibleNodes) + len(filteredNodesStatuses),
FeasibleNodes: len(feasibleNodes),
}, err
}
- 整个核心调度的实现流程很简单:
- 进行一些最基本的调度检查
- 对调度器缓存和节点信息快照操作
- 首先进行预选操作,找到一批合适的待调度的节点
- 如果没有找到节点,返回 FitError 错误
- 如果只找到一个节点,则直接返回这个节点的信息
- 如果找到多个节点,则进行优选操作,为每个节点进行打分,选择一个分数最高的作为待调度的节点进行返回
- 这里我们重点关注的是预选和优选两个阶段的实现
1.3 预选阶段分析
- 预选阶段调用
g.findNodesThatFitPod()
函数来获取一批合适的带调度的节点。函数实现如下所示:
// pkg/scheduler/core/generic_scheduler.go
// 根据框架的过滤插件和过滤扩展器对节点进行过滤,找到适合 Pod 的节点
func (g *genericScheduler) findNodesThatFitPod(ctx context.Context, prof *profile.Profile, state *framework.CycleState, pod *v1.Pod) ([]*v1.Node, framework.NodeToStatusMap, error) {
filteredNodesStatuses := make(framework.NodeToStatusMap)
// 运行 "prefilter" 插件
s := prof.RunPreFilterPlugins(ctx, state, pod)
if !s.IsSuccess() {
if !s.IsUnschedulable() {
return nil, nil, s.AsError()
}
// 更新节点的状态
allNodes, err := g.nodeInfoSnapshot.NodeInfos().List()
if err != nil {
return nil, nil, err
}
for _, n := range allNodes {
filteredNodesStatuses[n.Node().Name] = s
}
return nil, filteredNodesStatuses, nil
}
// 通过 Filter 插件找到合适的节点
feasibleNodes, err := g.findNodesThatPassFilters(ctx, prof, state, pod, filteredNodesStatuses)
if err != nil {
return nil, nil, err
}
// 通过 Extenders 过滤合适的节点
feasibleNodes, err = g.findNodesThatPassExtenders(pod, feasibleNodes, filteredNodesStatuses)
if err != nil {
return nil, nil, err
}
return feasibleNodes, filteredNodesStatuses, nil
}
- 首先会运行
prefilter
插件,然后运行所有的 filter 插件,最后是如果存在 Extender,则运行Extender
的 Filter 函数,当然 Extender 这种方式我们不关心,这里的重点仍然是调度框架的使用。 - 其中调用
prof.RunPreFilterPlugins()
执行所有 prefilter 插件的PreFilter
函数,需要所有的插件都执行成功才算成功:
// pkg/scheduler/framework/runtime/framework.go
// RunPreFilterPlugins 运行配置的所有 PreFilter 插件
// 它返回 *Status,如果任何一个插件返回不是 Success,则就会终止调度周期
func (f *frameworkImpl) RunPreFilterPlugins(ctx context.Context, state *framework.CycleState, pod *v1.Pod) (status *framework.Status) {
// 循环运行所有配置的 prefilter 插件
for _, pl := range f.preFilterPlugins {
// 执行一个具体的 prefilter 插件
status = f.runPreFilterPlugin(ctx, pl, state, pod)
if !status.IsSuccess() {
if status.IsUnschedulable() {
return status
}
msg := fmt.Sprintf("prefilter plugin %q failed for pod %q: %v", pl.Name(), pod.Name, status.Message())
klog.Error(msg)
return framework.NewStatus(framework.Error, msg)
}
}
return nil
}
func (f *frameworkImpl) runPreFilterPlugin(ctx context.Context, pl framework.PreFilterPlugin, state *framework.CycleState, pod *v1.Pod) *framework.Status {
if !state.ShouldRecordPluginMetrics() {
return pl.PreFilter(ctx, state, pod)
}
startTime := time.Now()
// 调用插件的 PreFilter 函数
status := pl.PreFilter(ctx, state, pod)
f.metricsRecorder.observePluginDurationAsync(preFilter, pl.Name(), status, metrics.SinceInSeconds(startTime))
return status
}
- 默认情况下,调度器已经启用了一系列的 prefilter 插件,也就是在
getDefaultConfig()
函数中定义的:
// pkg/scheduler/algorithmprovider/registry.go
func getDefaultConfig() *schedulerapi.Plugins {
return &schedulerapi.Plugins{
......
PreFilter: &schedulerapi.PluginSet{
Enabled: []schedulerapi.Plugin{
{Name: noderesources.FitName}, // 检查节点是否拥有 Pod 请求的所有资源
{Name: nodeports.Name}, // 检查 Pod 请求的端口在节点上是否可用
{Name: podtopologyspread.Name}, // 检查 Pod 拓扑分布
{Name: interpodaffinity.Name}, // 检查 Pod 间亲和性与反亲和性
{Name: volumebinding.Name}, // 检查节点是否有请求的卷,或是否可以绑定请求的卷
},
},
......
}
}
- 默认情况下系统启用了
NodeResourcesFit
、NodePorts
、PodTopologySpread
、InterPodAffinity
、VolumeBinding
这几个插件。
1.4 优选阶段分析
- 经过上面的预选阶段过后得到符合调度条件的节点列表,然后会调用
prioritizeNodes
函数为每个节点进行打分,最后调用selectHost
函数选择一个分数最高的节点作为最终调度的节点:
// 如果不止1个节点,那么就需要进行优选,给每个节点进行打分
priorityList, err := g.prioritizeNodes(ctx, prof, state, pod, feasibleNodes)
// 选择分数最高的作为最终的节点
host, err := g.selectHost(priorityList)
- 为每个节点进行打分的函数实现如下所示:
// pkg/scheduler/core/generic_scheduler.go
// prioritizeNodes 通过执行 score 插件来对节点进行优先级排序,
// 这些插件从 RunScorePlugins() 的调用中返回每个节点的得分。
// 每个插件的分数加在一起,就成了该节点的分数。
// 最后将所有的分数合并(相加),得到所有节点的加权总分。
func (g *genericScheduler) prioritizeNodes(
ctx context.Context,
prof *profile.Profile,
state *framework.CycleState,
pod *v1.Pod,
nodes []*v1.Node,
) (framework.NodeScoreList, error) {
// 如果没有提供优先级配置,那么所有节点的分数都是 1
......
// 执行所有 PreScore 插件
preScoreStatus := prof.RunPreScorePlugins(ctx, state, pod, nodes)
if !preScoreStatus.IsSuccess() {
return nil, preScoreStatus.AsError()
}
// 执行所有 Score 插件
scoresMap, scoreStatus := prof.RunScorePlugins(ctx, state, pod, nodes)
if !scoreStatus.IsSuccess() {
return nil, scoreStatus.AsError()
}
if klog.V(10).Enabled() {
for plugin, nodeScoreList := range scoresMap {
klog.Infof("Plugin %s scores on %v/%v => %v", plugin, pod.Namespace, pod.Name, nodeScoreList)
}
}
// 总结所有分数
result := make(framework.NodeScoreList, 0, len(nodes))
for i := range nodes {
result = append(result, framework.NodeScore{Name: nodes[i].Name, Score: 0})
for j := range scoresMap {
result[i].Score += scoresMap[j][i].Score
}
}
......
return result, nil
}
- 同样首先通过调用
RunPreScorePlugins
函数执行所有 PreScore 插件,然后调用RunScorePlugins
函数执行所有的 Score 插件,最后把所有节点的分数合并得到对应节点的最终分数。
// pkg/scheduler/framework/runtime/framework.go
// RunScorePlugins 执行配置的所有 score 插件
// 它返回一个列表,为每个评分插件的名称存储响应的 NodeScoreList(s)
// 它还返回 *Status,如果任何一个插件返回非成功状态,它将被设置为 non-success。
func (f *frameworkImpl) RunScorePlugins(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodes []*v1.Node) (ps framework.PluginToNodeScores, status *framework.Status) {
......
// 初始化插件节点分数对象
pluginToNodeScores := make(framework.PluginToNodeScores, len(f.scorePlugins))
for _, pl := range f.scorePlugins {
pluginToNodeScores[pl.Name()] = make(framework.NodeScoreList, len(nodes))
}
......
// 对每个节点并行运行 Score 方法
parallelize.Until(ctx, len(nodes), func(index int) {
for _, pl := range f.scorePlugins {
nodeName := nodes[index].Name
// 调用评分插件的 Score 函数
s, status := f.runScorePlugin(ctx, pl, state, pod, nodeName)
......
// 为当前插件设置对应节点的分数
pluginToNodeScores[pl.Name()][index] = framework.NodeScore{
Name: nodeName,
Score: int64(s),
}
}
})
......
// 为每个 ScorePlugin 并行运行 NormalizeScore 方法
parallelize.Until(ctx, len(f.scorePlugins), func(index int) {
pl := f.scorePlugins[index]
// 得到插件对应的所有节点分数
nodeScoreList := pluginToNodeScores[pl.Name()]
if pl.ScoreExtensions() == nil {
return
}
// 调用插件的 NormalizeScore 函数
status := f.runScoreExtension(ctx, pl, state, pod, nodeScoreList)
......
})
......
// 并为每个 ScorePlugin 应用评分默认权重
parallelize.Until(ctx, len(f.scorePlugins), func(index int) {
pl := f.scorePlugins[index]
weight := f.pluginNameToWeightMap[pl.Name()]
nodeScoreList := pluginToNodeScores[pl.Name()]
for i, nodeScore := range nodeScoreList {
......
// 为节点的分数作用上定义的权重
nodeScoreList[i].Score = nodeScore.Score * int64(weight)
}
})
......
return pluginToNodeScores, nil
}
- RunPreScorePlugins
函数就是循环调用所有注册的 PreScore 插件的 PreScore 函数,这里重点是
RunScorePlugins函数的实现,在该函数中首先会对每个节点并行运行注册插件的 Score 方法,然后会为为每个插件并行运行
NormalizeScore方法,由于每个插件的权重不一样,所以最后还有一步非常重要是为每个插件作用上定义的**权重**得到最终的分数。最后通过调用
selectHost` 函数来获得一个得分最高的节点:
// pkg/scheduler/core/generic_scheduler.go
// selectHost 所有节点的优先级列表,然后选择一个分数最高的节点
func (g *genericScheduler) selectHost(nodeScoreList framework.NodeScoreList) (string, error) {
if len(nodeScoreList) == 0 {
return "", fmt.Errorf("empty priorityList")
}
// 将第一个节点作为选择的节点
maxScore := nodeScoreList[0].Score
selected := nodeScoreList[0].Name
cntOfMaxScore := 1
// 然后循环后面的节点进行比较
for _, ns := range nodeScoreList[1:] {
if ns.Score > maxScore {
// 如果当前节点分数更大,则选择该节点
maxScore = ns.Score
selected = ns.Name
cntOfMaxScore = 1
} else if ns.Score == maxScore {
// 如果分数相同,cntOfMaxScore+1
cntOfMaxScore++
if rand.Intn(cntOfMaxScore) == 0 {
// 以 1/cntOfMaxScore 的概率取代候选节点
// 因为分数都一样,就无所谓选择哪个节点了
selected = ns.Name
}
}
}
return selected, nil
}
- 主要用于处理Pod在Filter阶段失败后的操作,如抢占、Autoscale触发等。DefaultPreemption:当高优先级的Pod没有找到合适的Node时,会执行Preempt抢占算法,抢占的流程:
- 一个Pod进入抢占的时候,首先会判断Pod是否拥有抢占的资格,有可能上次已经抢占过一次。
- 如果符合抢占资格,会先对所有的节点进行一次过滤,过滤出符合这次抢占要求的节点。然后
- 模拟一次调度,把优先级低的Pod先移除出去,再尝试能否把待抢占的Pod放置到此节点上。然后通过这个过程从过滤剩下的节点中选出一批节点进行抢占。
- PocessPreemptionWithExtenders是一个扩展的钩子,用户可以在这里加一些自己抢占节点的策略。如果没有扩展的钩子,这里面不做任何动作。
- PickOneNodeForPreemption,从上面选出的节点里挑选出最合适的一个节点,策略包括:
- 先选择打破PDB最少的节点;
- 其次选择待抢占Pods中最大优先级最小的节点;
- 再次选择待抢占Pods优先级加和最小的节点;
- 接下来选择待抢占Pods数目最小的节点;
- 最后选择拥有最晚启动Pod的节点; - 通过过滤之后,会选出一个最合适的节点。对这个节点上待抢占的Pod进行delete,完成抢占过程。
- 阶段会运行PreScore+Score所有插件进行打分
第二节 调度插件之 NodeResourcesFit
2.1 PreFilter
- prefilter插件的主要作用进行一些预置的检查和为后面的扩展点提前准备数据,后续插件需要的状态数据都是通过
CycleState
来进行存储和检索的,一个插件存储的状态数据可以被另一个插件读取、修改或删除。 - 比如这里我们选择
NodeResourcesFit
这个插件来进行说明,该插件的核心方法就是实现 PreFilter 函数:
// pkg/scheduler/framework/plugins/noderesources/fit.go
const (
// 定义的插件名称
FitName = "NodeResourcesFit"
// preFilterStateKey 是存放在 CycleState 中的关于 NodeResourcesFit 预计算数据的 key
preFilterStateKey = "PreFilter" + FitName
)
// computePodResourceRequest 返回一个涵盖每个资源维度中最大宽度的 framework.Resource。
// 因为 initContainers 是按照顺序运行的,所以我们循环收集每个维度中的最大值;
// 相反,由于普通容器是同时运行的,所以我们对它们的资源向量是进行求和计算。
// 此外如果启用了 PodOverhead 这个特性并且指定了 Pod Overhead,
// 则也需要为 Overhead 定义的资源将被添加到计算的 Resource 请求总和上。
//
// 示例:
//
// Pod:
// InitContainers 初始化容器
// IC1:
// CPU: 2
// Memory: 1G
// IC2:
// CPU: 2
// Memory: 3G
// Containers 普通容器
// C1:
// CPU: 2
// Memory: 1G
// C2:
// CPU: 1
// Memory: 1G
//
// Result: CPU: 3, Memory: 3G
// 初始化容器:IC1和IC2是顺序执行,所以获取两个中最大的资源,即:CPU:2,Memory:3G
// 普通容器:C1和C2是同时运行的,所以需要的资源是两者之和:CPU:2+1=3,Memory:1+1=2G
// 最后需要的资源请求是初始化容器和普通容器中最大的:CPU:3,Memory:3G
func computePodResourceRequest(pod *v1.Pod) *preFilterState {
result := &preFilterState{}
// 普通容器Requests资源相加
for _, container := range pod.Spec.Containers {
result.Add(container.Resources.Requests)
}
// take max_resource(sum_pod, any_init_container)
for _, container := range pod.Spec.InitContainers {
result.SetMaxResource(container.Resources.Requests)
}
// 如果正在使用 Overhead 特性,则也需要计算到总和里面
if pod.Spec.Overhead != nil && utilfeature.DefaultFeatureGate.Enabled(features.PodOverhead) {
result.Add(pod.Spec.Overhead)
}
return result
}
// 在 prefilter 扩展点被调用
func (f *Fit) PreFilter(ctx context.Context, cycleState *framework.CycleState, pod *v1.Pod) *framework.Status {
// 计算Pod请求所需的资源,然后存储到 CycleState 中,方便后续其他插件获取数据
cycleState.Write(preFilterStateKey, computePodResourceRequest(pod))
return nil
}
- 也就是在 prefilter 这个扩展点的时候会获取到当前我们要调度的 Pod 需要的 Requests 资源,然后将其存入
CycleState
。然后其他插件中如果需要用到这个数据就可以直接获取了,简单来说 CycleState 就是用于调度周期上下文数据传递共享的。 - prefilter 扩展点注册的插件执行完成后,接着就是执行 filter 扩展点的插件了,同样默认启用的插件通过 getDefaultConfig() 函数进行了配置:
// pkg/scheduler/algorithmprovider/registry.go
func getDefaultConfig() *schedulerapi.Plugins {
return &schedulerapi.Plugins{
......
Filter: &schedulerapi.PluginSet{
Enabled: []schedulerapi.Plugin{
{Name: nodeunschedulable.Name},
{Name: noderesources.FitName},
{Name: nodename.Name},
{Name: nodeports.Name},
{Name: nodeaffinity.Name},
{Name: volumerestrictions.Name},
{Name: tainttoleration.Name},
{Name: nodevolumelimits.EBSName},
{Name: nodevolumelimits.GCEPDName},
{Name: nodevolumelimits.CSIName},
{Name: nodevolumelimits.AzureDiskName},
{Name: volumebinding.Name},
{Name: volumezone.Name},
{Name: podtopologyspread.Name},
{Name: interpodaffinity.Name},
},
},
......
}
}
2.2 Filter
- 由于插件较多,这里我们也暂时挑选一个进行简单说明,例如我们可以看到在 Filter 中也注册了一个
noderesources.FitName
的插件,这其实就是上面的 prefilter 阶段使用过的NodeResourcesFit
插件,这其实也说明了某些插件是可能在任何一个扩展点出现了,现在是在 filter 扩展点,那么我们重点要看的就是该插件的Filter()
函数的实现:
// pkg/scheduler/framework/plugins/noderesources/fit.go
// 在 filter 扩展点调用。
// 检查一个节点是否有足够的资源,如cpu、内存、gpu 等来运行一个 Pod。
// 它返回一个资源不足的列表,如果为空,则说明该节点拥有 Pod 请求的所有资源。
func (f *Fit) Filter(ctx context.Context, cycleState *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
// 获取 prefilter 阶段存储在 CycleState 中的数据
s, err := getPreFilterState(cycleState)
if err != nil {
return framework.NewStatus(framework.Error, err.Error())
}
insufficientResources := fitsRequest(s, nodeInfo, f.ignoredResources, f.ignoredResourceGroups)
// 不足的资源大小不为0
if len(insufficientResources) != 0 {
// 保留所有的失败原因
failureReasons := make([]string, 0, len(insufficientResources))
for _, r := range insufficientResources {
failureReasons = append(failureReasons, r.Reason)
}
// 直接返回调度失败
return framework.NewStatus(framework.Unschedulable, failureReasons...)
}
return nil
}
func fitsRequest(podRequest *preFilterState, nodeInfo *framework.NodeInfo, ignoredExtendedResources, ignoredResourceGroups sets.String) []InsufficientResource {
insufficientResources := make([]InsufficientResource, 0, 4)
// 当前节点允许的 Pod 数量,默认110
allowedPodNumber := nodeInfo.Allocatable.AllowedPodNumber
// 如果现有的 Pod 数+1(当前调度的Pod) > 节点允许的Pod数
// 则提示太多Pods
if len(nodeInfo.Pods)+1 > allowedPodNumber {
insufficientResources = append(insufficientResources, InsufficientResource{
v1.ResourcePods,
"Too many pods",
1,
int64(len(nodeInfo.Pods)),
int64(allowedPodNumber),
})
}
// 没有配置Requests资源,则直接返回
if podRequest.MilliCPU == 0 &&
podRequest.Memory == 0 &&
podRequest.EphemeralStorage == 0 &&
len(podRequest.ScalarResources) == 0 {
return insufficientResources
}
// 节点可分配的CPU不够
if nodeInfo.Allocatable.MilliCPU < podRequest.MilliCPU+nodeInfo.Requested.MilliCPU {
insufficientResources = append(insufficientResources, InsufficientResource{
v1.ResourceCPU,
"Insufficient cpu",
podRequest.MilliCPU,
nodeInfo.Requested.MilliCPU,
nodeInfo.Allocatable.MilliCPU,
})
}
// 可分配的内存不够
if nodeInfo.Allocatable.Memory < podRequest.Memory+nodeInfo.Requested.Memory {
insufficientResources = append(insufficientResources, InsufficientResource{
v1.ResourceMemory,
"Insufficient memory",
podRequest.Memory,
nodeInfo.Requested.Memory,
nodeInfo.Allocatable.Memory,
})
}
// 临时存储不够
if nodeInfo.Allocatable.EphemeralStorage < podRequest.EphemeralStorage+nodeInfo.Requested.EphemeralStorage {
insufficientResources = append(insufficientResources, InsufficientResource{
v1.ResourceEphemeralStorage,
"Insufficient ephemeral-storage",
podRequest.EphemeralStorage,
nodeInfo.Requested.EphemeralStorage,
nodeInfo.Allocatable.EphemeralStorage,
})
}
// 查看其他标量资源
for rName, rQuant := range podRequest.ScalarResources {
// 如果这个资源是应该被忽略的一种扩展扩展资源,则跳过检查
if v1helper.IsExtendedResourceName(rName) {
var rNamePrefix string
if ignoredResourceGroups.Len() > 0 {
rNamePrefix = strings.Split(string(rName), "/")[0]
}
if ignoredExtendedResources.Has(string(rName)) || ignoredResourceGroups.Has(rNamePrefix) {
continue
}
}
// 对应资源在节点上不足
if nodeInfo.Allocatable.ScalarResources[rName] < rQuant+nodeInfo.Requested.ScalarResources[rName] {
insufficientResources = append(insufficientResources, InsufficientResource{
rName,
fmt.Sprintf("Insufficient %v", rName),
podRequest.ScalarResources[rName],
nodeInfo.Requested.ScalarResources[rName],
nodeInfo.Allocatable.ScalarResources[rName],
})
}
}
// 返回所有的不足资源信息
return insufficientResources
}
- 上面的过滤函数整体比较简单易懂,拿到 prefilter 阶段存储在 CycleState 里面的 Pod 请求资源数据,然后和节点上剩余的可分配资源进行比较,如果没有设置 Requests 资源则直接返回,但是也会检查当前节点是否还有 Pod 数量(默认110),然后就是比较 CPU、内存、临时存储、标量资源等是否还有可分配的,所谓标量资源就是我们在定义 Pod 的时候可以自己指定一种资源来进行分配,比如 GPU,我们就可以当成一种标量资源进行分配,同样也需要判断节点上是否有可分配的标量资源。
第三节 调度插件之PodTopologySpread使用介绍
3.1 Pod 拓扑分布约束
- 在 k8s 集群调度中,“亲和性”相关的概念本质上都是控制 Pod 如何被调度——**堆叠或是打散。**目前 k8s 提供了
podAffinity
以及podAntiAffinity
两个特性对 Pod 在不同拓扑域的分布进行了一些控制,podAffinity 可以将无数个 Pod 调度到特定的某一个拓扑域,这是堆叠的体现;podAntiAffinity 则可以控制一个拓扑域只存在一个 Pod,这是打散的体现。但这两种情况都太极端了,在不少场景下都无法达到理想的效果,例如为了实现容灾和高可用,将业务 Pod 尽可能均匀的分布在不同可用区就很难实现。 PodTopologySpread
特性的提出正是为了对 Pod 的调度分布提供更精细的控制,以提高服务可用性以及资源利用率,PodTopologySpread 由EvenPodsSpread
特性门所控制,在 v1.16 版本第一次发布,并在 v1.18 版本进入 beta 阶段默认启用。再了解这个插件是如何实现之前,我们首先需要搞清楚这个特性是如何使用的。
3.2 使用规范
- 在 Pod 的 Spec 规范中新增了一个
topologySpreadConstraints
字段:
spec:
topologySpreadConstraints:
- maxSkew: <integer>
topologyKey: <string>
whenUnsatisfiable: <string>
labelSelector: <object>
- 由于这个新增的字段是在 Pod spec 层面添加,因此更高层级的控制 (Deployment、DaemonSet、StatefulSet) 也能使用
PodTopologySpread
功能。
- 让我们结合上图来理解
topologySpreadConstraints
中各个字段的含义和作用:
- labelSelector: 用来查找匹配的 Pod,我们能够计算出每个拓扑域中匹配该 label selector 的 Pod 数量,在上图中,假如 label selector 是
app:foo
,那么 zone1 的匹配个数为 2, zone2 的匹配个数为 0。 - topologyKey: 是 Node label 的 key,如果两个 Node 的 label 同时具有该 key 并且 label 值相同,就说它们在同一个拓扑域。在上图中,指定 topologyKey 为 zone, 具有
zone=zone1
标签的 Node 被分在一个拓扑域,具有zone=zone2
标签的 Node 被分在另一个拓扑域。 - maxSkew:描述了 Pod 在不同拓扑域中, 不均匀分布的最大程度 , maxSkew 的取值必须大于 0。每个拓扑域都有一个 skew,计算的公式是:
skew[i] = 拓扑域[i]中匹配的 Pod 个数 - min{其他拓扑域中匹配的 Pod 个数}
。在上图中,我们新建一个带有app=foo
标签的 Pod:
- 如果该 Pod 被调度到 zone1,那么 zone1 中 Node 的 skew 值变为 3,zone2 中 Node 的 skew 值变为 0 (zone1 有 3 个匹配的 Pod,zone2 有 0 个匹配的 Pod )- 如果该 Pod 被调度到 zone2,那么 zone1 中 Node 的 skew 值变为 1,zone2 中 Node 的 skew 值变为 0 (zone2 有 1 个匹配的 Pod,拥有全局最小匹配 Pod 数的拓扑域正是 zone2 自己 )
- whenUnsatisfiable:描述了如果 Pod 不满足分布约束条件该采取何种策略:
- DoNotSchedule (默认) 告诉调度器不要调度该 Pod,因此也可以叫作硬策略;
- ScheduleAnyway 告诉调度器根据每个 Node 的 skew 值打分排序后仍然调度,因此也可以叫作软策略。
- 下面我们用两个实际的示例来进一步说明。
3.3 单个 TopologySpreadConstraint
- 假设你拥有一个 4 节点集群,其中标记为
foo:bar
的 3 个 Pod 分别位于 node1、node2 和 node3 中:
- 如果希望新来的 Pod 均匀分布在现有的可用区域,则可以按如下设置其约束:
kind: Pod
apiVersion: v1
metadata:
name: mypod
labels:
foo: bar
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
foo: bar
containers:
- name: pause
image: k8s.gcr.io/pause:3.1
topologyKey: zone
意味着均匀分布将只应用于存在标签键值对为zone:
的节点。whenUnsatisfiable: DoNotSchedule
告诉调度器如果新的 Pod 不满足约束,则不可调度。如果调度器将新的 Pod 放入 “zoneA”,Pods 分布将变为[3, 1]
,因此实际的偏差为2(3 - 1)
,这违反了maxSkew: 1
的约定。此示例中,新 Pod 只能放置在 “zoneB” 上:
或者
5. 你可以调整 Pod 约束以满足各种要求:
- 将 maxSkew
更改为更大的值,比如 “2”,这样新的 Pod 也可以放在 “zoneA” 上。
- 将 topologyKey
更改为 “node”,以便将 Pod 均匀分布在节点上而不是区域中。 在上面的例子中,如果 maxSkew
保持为 “1”,那么传入的 Pod 只能放在 “node4” 上。
- 将 whenUnsatisfiable: DoNotSchedule
更改为 whenUnsatisfiable: ScheduleAnyway
, 以确保新的 Pod 可以被调度。
3.4 多个 TopologySpreadConstraint
- 上面是单个 Pod 拓扑分布约束的情况,下面的例子建立在前面例子的基础上来对多个 Pod 拓扑分布约束进行说明。假设你拥有一个 4 节点集群,其中 3 个标记为
foo:bar
的 Pod 分别位于 node1、node2 和 node3 上:
- 可以使用 2 个
TopologySpreadConstraint
来控制 Pod 在 区域和节点两个维度上的分布:
# two-constraints.yaml
**kind**: Pod
**apiVersion**: v1
**metadata**:
**name**: mypod
**labels**:
**foo**: bar
**spec**:
**topologySpreadConstraints**:
- **maxSkew**: 1
**topologyKey**: zone
**whenUnsatisfiable**: DoNotSchedule
**labelSelector**:
**matchLabels**:
**foo**: bar
- **maxSkew**: 1
**topologyKey**: node
**whenUnsatisfiable**: DoNotSchedule
**labelSelector**:
**matchLabels**:
**foo**: bar
**containers**:
- **name**: pause
**image**: k8s.gcr.io/pause:3.1
- 在这种情况下,为了匹配第一个约束,新的 Pod 只能放置在 “zoneB” 中;而在第二个约束中, 新的 Pod 只能放置在 “node4” 上,最后两个约束的结果加在一起,唯一可行的选择是放置 在 “node4” 上。
- 多个约束之间可能存在冲突,假设有一个跨越 2 个区域的 3 节点集群:
- 如果对集群应用
two-constraints.yaml
,会发现 “mypod” 处于Pending
状态,这是因为为了满足第一个约束,“mypod” 只能放在 “zoneB” 中,而第二个约束要求 “mypod” 只能放在 “node2” 上,Pod 调度无法满足这两种约束,所以就冲突了。 - 为了克服这种情况,你可以增加
maxSkew
或修改其中一个约束,让其使用whenUnsatisfiable: ScheduleAnyway
。
3.5 集群默认约束
- 除了为单个 Pod 设置拓扑分布约束,也可以为集群设置默认的拓扑分布约束,默认拓扑分布约束在且仅在以下条件满足 时才会应用到 Pod 上:
- Pod 没有在其
.spec.topologySpreadConstraints
设置任何约束; - Pod 隶属于某个服务、副本控制器、ReplicaSet 或 StatefulSet。
- Pod 没有在其
- 你可以在 调度方案(Schedulingg Profile)中将默认约束作为
PodTopologySpread
插件参数的一部分来进行设置。 约束的设置采用和前面 Pod 中的规范一致,只是labelSelector
必须为空。配置的示例可能看起来像下面这个样子:
**apiVersion**: kubescheduler.config.k8s.io/v1beta1
**kind**: KubeSchedulerConfiguration
**profiles**:
- **pluginConfig**:
- **name**: PodTopologySpread
**args**:
**defaultConstraints**:
- **maxSkew**: 1
**topologyKey**: topology.kubernetes.io/zone
**whenUnsatisfiable**: ScheduleAnyway
**defaultingType**: List
第四节 调度器中PodTopologySpread插件具体实现
4.1 PodTopologySpread预选阶段PreFilter
- 前面了解了如何使用 Pod 拓扑分布约束,接下来我们就可以来看下调度器中对应插件是如何实现的了。
- 首先也是去查看这个插件的 PreFilter 函数的实现:
// pkg/scheduler/framework/plugins/podtopologyspread/filtering.go
// 在 prefilter 扩展点调用
func (pl *PodTopologySpread) PreFilter(ctx context.Context, cycleState *framework.CycleState, pod *v1.Pod) *framework.Status {
s, err := pl.calPreFilterState(pod)
if err != nil {
return framework.NewStatus(framework.Error, err.Error())
}
cycleState.Write(preFilterStateKey, s)
return nil
}
- 这里最核心的就是
calPreFilterState
函数,该函数用来计算描述如何在拓扑域上传递 Pod 的preFilterState
状态数据,在了解该函数如何实现之前,我们需要先弄明白preFilterState
的定义:
// pkg/scheduler/framework/plugins/podtopologyspread/common.go
type topologyPair struct {
key string
value string
}
// 拓扑分布约束定义
type topologySpreadConstraint struct {
MaxSkew int32
TopologyKey string
Selector labels.Selector
}
// pkg/scheduler/framework/plugins/podtopologyspread/filtering.go
const preFilterStateKey = "PreFilter" + Name
// preFilterState 在 PreFilter 处进行计算,在 Filter 中使用。
// 它结合了 TpKeyToCriticalPaths 和 TpPairToMatchNum 来表示。
// (1) 最少的 Pod 在每个分布约束上匹配的关键路径。
// (2) 在每个分布约束上匹配的 Pod 数量。
// 一个 nil preFilterState 表示没有设置(在 PreFilter 阶段);
// 一个空的 preFilterState 对象是一个合法的状态,在 PreFilter 阶段进行设置。
type preFilterState struct {
// demo: {{
// MaxSkew: 1,
// TopologyKey: "zone",
// Selector: ......,
// }}
Constraints []topologySpreadConstraint
// 这里记录2条关键路径,而不是所有的关键路径。
// criticalPaths[0].MatchNum 总是保持最小的匹配数。
// criticalPaths[1].MatchNum 总是大于或等于criticalPaths[0].MatchNum,但不能保证是第2个最小匹配数。
// demo: {
// "zone": {{"zone3", 0}, {"zone2", 2}},
// "node": {{"node-b", 1}, {"node-a", 2}},
// }
TpKeyToCriticalPaths map[string]*criticalPaths
// TpPairToMatchNum 以 topologyPair 为 key,匹配的 Pods 数量为 value 值
// demo: {key: "zone", value: "zone1"}: pointer.Int32Ptr(3),
// {key: "zone", value: "zone2"}: pointer.Int32Ptr(2),
// {key: "zone", value: "zone3"}: pointer.Int32Ptr(0),
// {key: "node", value: "node-a"}: pointer.Int32Ptr(2),
// {key: "node", value: "node-b"}: pointer.Int32Ptr(1),
TpPairToMatchNum map[topologyPair]*int32
}
type criticalPaths [2]struct {
// TopologyValue 拓扑Key对应的拓扑值
TopologyValue string
// MatchNum 匹配的 Pod 数量
MatchNum int32
}
preFilterState
中定义了3个属性,在 PreFilter 处进行计算,在 Filter 中使用:
- Constraints 用来保存定义的所有拓扑分布约束信息
- TpKeyToCriticalPaths 是一个 map,以定义的拓扑 Key 为 Key,值是一个
criticalPaths
指针,criticalPaths
的定义不太好理解,是一个两个长度的结构体数组,结构体里面保存的是定义的拓扑对应的 Value 值以及该拓扑下匹配的 Pod 数量,而且需要注意的是这个数组的第一个元素中匹配数量是最小的(其实这里定义一个结构体就可以,只是为了保证获取到的是最小的匹配数量,就定义了两个,第二个是用来临时比较用的,真正有用的是第一个结构体) - TpPairToMatchNum 同样是一个 map,对应的 Key 是
topologyPair
,这个类型其实就是一个拓扑对,Values 值就是这个拓扑对下匹配的 Pod 数
- 这里可能不是很好理解,我们用测试代码中的一段测试用例来进行说明可能更好理解:
// pkg/scheduler/framework/plugins/podtopologyspread/filtering_test.go
{
name: "normal case with two spreadConstraints",
pod: st.MakePod().Name("p").Label("foo", "").
SpreadConstraint(1, "zone", v1.DoNotSchedule, fooSelector).
SpreadConstraint(1, "node", v1.DoNotSchedule, fooSelector).
Obj(),
nodes: []*v1.Node{
st.MakeNode().Name("node-a").Label("zone", "zone1").Label("node", "node-a").Obj(),
st.MakeNode().Name("node-b").Label("zone", "zone1").Label("node", "node-b").Obj(),
st.MakeNode().Name("node-x").Label("zone", "zone2").Label("node", "node-x").Obj(),
st.MakeNode().Name("node-y").Label("zone", "zone2").Label("node", "node-y").Obj(),
},
existingPods: []*v1.Pod{
st.MakePod().Name("p-a1").Node("node-a").Label("foo", "").Obj(),
st.MakePod().Name("p-a2").Node("node-a").Label("foo", "").Obj(),
st.MakePod().Name("p-b1").Node("node-b").Label("foo", "").Obj(),
st.MakePod().Name("p-y1").Node("node-y").Label("foo", "").Obj(),
st.MakePod().Name("p-y2").Node("node-y").Label("foo", "").Obj(),
st.MakePod().Name("p-y3").Node("node-y").Label("foo", "").Obj(),
st.MakePod().Name("p-y4").Node("node-y").Label("foo", "").Obj(),
},
want: &preFilterState{
Constraints: []topologySpreadConstraint{
{
MaxSkew: 1,
TopologyKey: "zone",
Selector: mustConvertLabelSelectorAsSelector(t, fooSelector),
},
{
MaxSkew: 1,
TopologyKey: "node",
Selector: mustConvertLabelSelectorAsSelector(t, fooSelector),
},
},
TpKeyToCriticalPaths: map[string]*criticalPaths{
"zone": {{"zone1", 3}, {"zone2", 4}},
"node": {{"node-x", 0}, {"node-b", 1}},
},
TpPairToMatchNum: map[topologyPair]*int32{
{key: "zone", value: "zone1"}: pointer.Int32Ptr(3),
{key: "zone", value: "zone2"}: pointer.Int32Ptr(4),
{key: "node", value: "node-a"}: pointer.Int32Ptr(2),
{key: "node", value: "node-b"}: pointer.Int32Ptr(1),
{key: "node", value: "node-x"}: pointer.Int32Ptr(0),
{key: "node", value: "node-y"}: pointer.Int32Ptr(4),
},
},
}
- 理解了 preFilterState 的定义,接下来我们就可以来分析
calPreFilterState
函数的实现了:
// pkg/scheduler/framework/plugins/podtopologyspread/filtering.go
func (pl *PodTopologySpread) calPreFilterState(pod *v1.Pod) (*preFilterState, error) {
// 获取所有节点信息
allNodes, err := pl.sharedLister.NodeInfos().List()
if err != nil {
return nil, fmt.Errorf("listing NodeInfos: %v", err)
}
var constraints []topologySpreadConstraint
if len(pod.Spec.TopologySpreadConstraints) > 0 {
// 如果 Pod 中配置了 TopologySpreadConstraints,转换成这里的 topologySpreadConstraint 对象
constraints, err = filterTopologySpreadConstraints(pod.Spec.TopologySpreadConstraints, v1.DoNotSchedule)
if err != nil {
return nil, fmt.Errorf("obtaining pod's hard topology spread constraints: %v", err)
}
} else {
// 获取默认配置的拓扑分布约束
constraints, err = pl.defaultConstraints(pod, v1.DoNotSchedule)
if err != nil {
return nil, fmt.Errorf("setting default hard topology spread constraints: %v", err)
}
}
// 没有约束,直接返回
if len(constraints) == 0 {
return &preFilterState{}, nil
}
// 初始化 preFilterState 状态
s := preFilterState{
Constraints: constraints,
TpKeyToCriticalPaths: make(map[string]*criticalPaths, len(constraints)),
TpPairToMatchNum: make(map[topologyPair]*int32, sizeHeuristic(len(allNodes), constraints)),
}
for _, n := range allNodes {
node := n.Node()
if node == nil {
klog.Error("node not found")
continue
}
// 如果定义了 NodeAffinity 或者 NodeSelector,则应该分布到这些过滤器的节点
if !helper.PodMatchesNodeSelectorAndAffinityTerms(pod, node) {
continue
}
// 保证现在的节点的标签包含 Constraints 中的所有 topologyKeys
if !nodeLabelsMatchSpreadConstraints(node.Labels, constraints) {
continue
}
// 根据约束初始化拓扑对
for _, c := range constraints {
pair := topologyPair{key: c.TopologyKey, value: node.Labels[c.TopologyKey]}
s.TpPairToMatchNum[pair] = new(int32)
}
}
processNode := func(i int) {
nodeInfo := allNodes[i]
node := nodeInfo.Node()
// 计算每一个拓扑对下匹配的 Pod 总数
for _, constraint := range constraints {
pair := topologyPair{key: constraint.TopologyKey, value: node.Labels[constraint.TopologyKey]}
tpCount := s.TpPairToMatchNum[pair]
if tpCount == nil {
continue
}
// 计算约束的拓扑域中匹配的 Pod 数
count := countPodsMatchSelector(nodeInfo.Pods, constraint.Selector, pod.Namespace)
atomic.AddInt32(tpCount, int32(count))
}
}
parallelize.Until(context.Background(), len(allNodes), processNode)
// 计算每个拓扑的最小匹配度(保证第一个Path下是最小的值)
for i := 0; i < len(constraints); i++ {
key := constraints[i].TopologyKey
s.TpKeyToCriticalPaths[key] = newCriticalPaths()
}
for pair, num := range s.TpPairToMatchNum {
s.TpKeyToCriticalPaths[pair.key].update(pair.value, *num)
}
return &s, nil
}
// update 函数就是来保证 criticalPaths 中的第一个元素是最小的 Pod 匹配数
func (p *criticalPaths) update(tpVal string, num int32) {
// first verify if `tpVal` exists or not
i := -1
if tpVal == p[0].TopologyValue {
i = 0
} else if tpVal == p[1].TopologyValue {
i = 1
}
if i >= 0 {
// `tpVal` exists
p[i].MatchNum = num
if p[0].MatchNum > p[1].MatchNum {
// swap paths[0] and paths[1]
p[0], p[1] = p[1], p[0]
}
} else {
// `tpVal` doesn't exist
if num < p[0].MatchNum {
// update paths[1] with paths[0]
p[1] = p[0]
// update paths[0]
p[0].TopologyValue, p[0].MatchNum = tpVal, num
} else if num < p[1].MatchNum {
// update paths[1]
p[1].TopologyValue, p[1].MatchNum = tpVal, num
}
}
}
-
首先判断 Pod 中是否定义了
TopologySpreadConstraint
,如果定义了就获取转换成preFilterState
中的Constraints
,如果没有定义需要查看是否为调度器配置了默认的拓扑分布约束,如果都没有这就直接返回了。 -
然后循环所有的节点,先根据
NodeAffinity
或者NodeSelector
进行过滤,然后根据约束中定义的topologyKeys
过滤节点。 -
接着计算每个节点下的拓扑对匹配的 Pod 数量,存入
TpPairToMatchNum
中,最后就是要把所有约束中匹配的 Pod 数量最小(或稍大)的放入TpKeyToCriticalPaths
中保存起来。整个preFilterState
保存下来传递到后续的插件中使用,比如在 filter 扩展点中同样也注册了这个插件,所以我们可以来查看下在 filter 中是如何实现的。
4.2 PodTopologySpread预选阶段Filter
- 在 preFilter 阶段将 Pod 拓扑分布约束的相关信息存入到了 CycleState 中,下面在 filter 阶段中就可以来直接使用这些数据了:
// pkg/scheduler/framework/plugins/podtopologyspread/filtering.go
func (pl *PodTopologySpread) Filter(ctx context.Context, cycleState *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
node := nodeInfo.Node()
if node == nil {
return framework.NewStatus(framework.Error, "node not found")
}
// 获取 preFilsterState
s, err := getPreFilterState(cycleState)
if err != nil {
return framework.NewStatus(framework.Error, err.Error())
}
// 如果没有拓扑匹配的数量或者没有约束,则直接返回
if len(s.TpPairToMatchNum) == 0 || len(s.Constraints) == 0 {
return nil
}
podLabelSet := labels.Set(pod.Labels)
// 循环Pod设置的约束
for _, c := range s.Constraints {
tpKey := c.TopologyKey // 拓扑Key
// 检查当前节点是否有的对应拓扑Key
tpVal, ok := node.Labels[c.TopologyKey]
if !ok {
klog.V(5).Infof("node '%s' doesn't have required label '%s'", node.Name, tpKey)
return framework.NewStatus(framework.UnschedulableAndUnresolvable, ErrReasonNodeLabelNotMatch)
}
// 如果拓扑约束的selector匹配pod本身标签,则selfMatchNum=1
selfMatchNum := int32(0)
if c.Selector.Matches(podLabelSet) {
selfMatchNum = 1
}
// zone=zoneA zone=zoneB
// p p p p p
// 一个拓扑域
pair := topologyPair{key: tpKey, value: tpVal}
// 获取指定拓扑key的路径匹配数量
// [{zoneB 2}]
paths, ok := s.TpKeyToCriticalPaths[tpKey]
if !ok {
klog.Errorf("internal error: get paths from key %q of %#v", tpKey, s.TpKeyToCriticalPaths)
continue
}
// 获取最小匹配数量
minMatchNum := paths[0].MatchNum
matchNum := int32(0)
// 获取当前节点所在的拓扑域匹配的Pod数量
if tpCount := s.TpPairToMatchNum[pair]; tpCount != nil {
matchNum = *tpCount
}
// 如果匹配的Pod数量 + 1或者0 - 最小的匹配数量 > MaxSkew
// 则证明不满足约束条件
skew := matchNum + selfMatchNum - minMatchNum
if skew > c.MaxSkew {
klog.V(5).Infof("node '%s' failed spreadConstraint[%s]: MatchNum(%d) + selfMatchNum(%d) - minMatchNum(%d) > maxSkew(%d)", node.Name, tpKey, matchNum, selfMatchNum, minMatchNum, c.MaxSkew)
return framework.NewStatus(framework.Unschedulable, ErrReasonConstraintsNotMatch)
}
}
return nil
}
-
首先通过
CycleState
获取preFilterState
,如果没有配置约束或者拓扑对匹配数量为0这直接返回了。 -
然后循环定义的拓扑约束,先检查当前节点是否有对应的 TopologyKey,没有就返回错误,然后判断拓扑对的分布程度是否大于
MaxSkew
,判断方式为拓扑中匹配的 Pod 数量 + 1/0(如果 Pod 本身也匹配则为1) - 最小的 Pod 匹配数量 > MaxSkew
,这个也是前面我们在关于 Pod 拓扑分布约束中的 maxSkew 的含义描述的意思**。**
4.3 PodTopologySpread优选阶段PreScore与Score
- PodTopologySpread 除了在预选阶段会用到,在打分阶段其实也会用到,在默认的插件注册函数中可以看到:
func getDefaultConfig() *schedulerapi.Plugins {
return &schedulerapi.Plugins{
......
PreFilter: &schedulerapi.PluginSet{
Enabled: []schedulerapi.Plugin{
{Name: podtopologyspread.Name},
......
},
},
Filter: &schedulerapi.PluginSet{
Enabled: []schedulerapi.Plugin{
{Name: podtopologyspread.Name},
......
},
},
......
PreScore: &schedulerapi.PluginSet{
Enabled: []schedulerapi.Plugin{
{Name: podtopologyspread.Name},
......
},
},
Score: &schedulerapi.PluginSet{
Enabled: []schedulerapi.Plugin{
// Weight is doubled because:
// - This is a score coming from user preference.
// - It makes its signal comparable to NodeResourcesLeastAllocated.
{Name: podtopologyspread.Name, Weight: 2},
......
},
},
......
}
}
- 同样首先需要调用 PreScore 函数进行打分前的一些准备,把打分的数据存储起来:
// pkg/scheduler/framework/plugins/podtopologyspread/scoring.go
// preScoreState 在 PreScore 时计算,在 Score 时使用。
type preScoreState struct {
// 定义的约束
Constraints []topologySpreadConstraint
// IgnoredNodes 是一组 miss 掉 Constraints[*].topologyKey 的节点名称
IgnoredNodes sets.String
// TopologyPairToPodCounts 以 topologyPair 为键,以匹配的 Pod 数量为值
TopologyPairToPodCounts map[topologyPair]*int64
// TopologyNormalizingWeight 是我们给每个拓扑的计数的权重
// 这使得较小的拓扑的 Pod 数不会被较大的稀释
TopologyNormalizingWeight []float64
}
// initPreScoreState 迭代 "filteredNodes" 来过滤掉没有设置 topologyKey 的节点,并进行初始化:
// 1) s.TopologyPairToPodCounts: 以符合条件的拓扑对和节点名称为键
// 2) s.IgnoredNodes: 不应得分的节点集合
// 3) s.TopologyNormalizingWeight: 根据拓扑结构中的数值数量给予每个约束的权重
func (pl *PodTopologySpread) initPreScoreState(s *preScoreState, pod *v1.Pod, filteredNodes []*v1.Node) error {
var err error
// 将 Pod 或者默认定义的约束转换到 Constraints 中
if len(pod.Spec.TopologySpreadConstraints) > 0 {
s.Constraints, err = filterTopologySpreadConstraints(pod.Spec.TopologySpreadConstraints, v1.ScheduleAnyway)
if err != nil {
return fmt.Errorf("obtaining pod's soft topology spread constraints: %v", err)
}
} else {
s.Constraints, err = pl.defaultConstraints(pod, v1.ScheduleAnyway)
if err != nil {
return fmt.Errorf("setting default soft topology spread constraints: %v", err)
}
}
if len(s.Constraints) == 0 {
return nil
}
topoSize := make([]int, len(s.Constraints))
// 循环过滤节点得到的所有节点
for _, node := range filteredNodes {
if !nodeLabelsMatchSpreadConstraints(node.Labels, s.Constraints) {
// 后面打分时,没有全部所需 topologyKeys 的节点会被忽略
s.IgnoredNodes.Insert(node.Name)
continue
}
// 循环约束条件
for i, constraint := range s.Constraints {
if constraint.TopologyKey == v1.LabelHostname {
continue
}
// 拓扑对 初始化
pair := topologyPair{key: constraint.TopologyKey, value: node.Labels[constraint.TopologyKey]}
if s.TopologyPairToPodCounts[pair] == nil {
s.TopologyPairToPodCounts[pair] = new(int64)
topoSize[i]++ // 拓扑对数量+1
}
}
}
s.TopologyNormalizingWeight = make([]float64, len(s.Constraints))
for i, c := range s.Constraints {
sz := topoSize[i] // 拓扑约束数量
if c.TopologyKey == v1.LabelHostname {
// 如果 TopologyKey 是 Hostname 标签
sz = len(filteredNodes) - len(s.IgnoredNodes)
}
// 计算拓扑约束的权重
s.TopologyNormalizingWeight[i] = topologyNormalizingWeight(sz)
}
return nil
}
// topologyNormalizingWeight 根据拓扑存在的值的数量,计算拓扑的权重。
// 由于<size>至少为1(所有通过 Filters 的节点都在同一个拓扑结构中)
// 而k8s支持5k个节点,所以结果在区间<1.09,8.52>。
//
// 注意:当没有节点具有所需的拓扑结构时,<size> 也可以为0
// 然而在这种情况下,我们并不关心拓扑结构的权重
// 因为我们对所有节点都返回0分。
func topologyNormalizingWeight(size int) float64 {
return math.Log(float64(size + 2))
}
// PreScore 构建写入 CycleState 用于后面的 Score 和 NormalizeScore 使用
func (pl *PodTopologySpread) PreScore(
ctx context.Context,
cycleState *framework.CycleState,
pod *v1.Pod,
filteredNodes []*v1.Node,
) *framework.Status {
// 获取所有节点
allNodes, err := pl.sharedLister.NodeInfos().List()
if err != nil {
return framework.NewStatus(framework.Error, fmt.Sprintf("error when getting all nodes: %v", err))
}
// 过滤后的节点或者当前没有节点,表示没有节点用于打分
if len(filteredNodes) == 0 || len(allNodes) == 0 {
return nil
}
// 初始化 preScoreState 状态
state := &preScoreState{
IgnoredNodes: sets.NewString(),
TopologyPairToPodCounts: make(map[topologyPair]*int64),
}
err = pl.initPreScoreState(state, pod, filteredNodes)
if err != nil {
return framework.NewStatus(framework.Error, fmt.Sprintf("error when calculating preScoreState: %v", err))
}
// 如果传入的 pod 没有软拓扑传播约束,则返回
if len(state.Constraints) == 0 {
cycleState.Write(preScoreStateKey, state)
return nil
}
processAllNode := func(i int) {
nodeInfo := allNodes[i]
node := nodeInfo.Node()
if node == nil {
return
}
// (1) `node`应满足传入 pod 的 NodeSelector/NodeAffinity
// (2) 所有的 topologyKeys 都需要存在于`node`中。
if !pluginhelper.PodMatchesNodeSelectorAndAffinityTerms(pod, node) ||
!nodeLabelsMatchSpreadConstraints(node.Labels, state.Constraints) {
return
}
for _, c := range state.Constraints {
// 拓扑对
pair := topologyPair{key: c.TopologyKey, value: node.Labels[c.TopologyKey]}
// 如果当前拓扑对没有与任何候选节点相关联,则继续避免不必要的计算
// 每个节点的计数也被跳过,因为它们是在 Score 期间进行的
tpCount := state.TopologyPairToPodCounts[pair]
if tpCount == nil {
continue
}
// 计算节点上匹配的所有 Pod 数量
count := countPodsMatchSelector(nodeInfo.Pods, c.Selector, pod.Namespace)
atomic.AddInt64(tpCount, int64(count))
}
}
parallelize.Until(ctx, len(allNodes), processAllNode)
cycleState.Write(preScoreStateKey, state)
return nil
}
- 上面的处理逻辑整体比较简单,最重要的是计算每个拓扑约束的权重,这样才方便后面打分的时候计算分数,存入到 CycleState 后就可以了来查看具体的 Score 函数的实现了:
// pkg/scheduler/framework/plugins/podtopologyspread/scoring.go
// 在 Score 扩展点调用
func (pl *PodTopologySpread) Score(ctx context.Context, cycleState *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) {
nodeInfo, err := pl.sharedLister.NodeInfos().Get(nodeName)
if err != nil || nodeInfo.Node() == nil {
return 0, framework.NewStatus(framework.Error, fmt.Sprintf("getting node %q from Snapshot: %v, node is nil: %v", nodeName, err, nodeInfo.Node() == nil))
}
node := nodeInfo.Node()
s, err := getPreScoreState(cycleState)
if err != nil {
return 0, framework.NewStatus(framework.Error, err.Error())
}
// 如果该节点不合格,则返回
if s.IgnoredNodes.Has(node.Name) {
return 0, nil
}
// 每出现一个 <pair>,当前节点就会得到一个 <matchSum> 的分数。
// 而我们将<matchSum>相加,作为这个节点的分数返回。
var score float64
for i, c := range s.Constraints {
if tpVal, ok := node.Labels[c.TopologyKey]; ok {
var cnt int64
if c.TopologyKey == v1.LabelHostname {
// 如果 TopologyKey 是 Hostname 则 cnt 为节点上匹配约束的 selector 的 Pod 数量
cnt = int64(countPodsMatchSelector(nodeInfo.Pods, c.Selector, pod.Namespace))
} else {
// 拓扑对下匹配的 Pod 数量
pair := topologyPair{key: c.TopologyKey, value: tpVal}
cnt = *s.TopologyPairToPodCounts[pair]
}
// 计算当前节点所得分数
score += scoreForCount(cnt, c.MaxSkew, s.TopologyNormalizingWeight[i])
}
}
return int64(score), nil
}
// scoreForCount 根据拓扑域中匹配的豆荚数量、约束的maxSkew和拓扑权重计算得分。
// `maxSkew-1`加到分数中,这样拓扑域之间的差异就会被淡化,控制分数对偏斜的容忍度。
func scoreForCount(cnt int64, maxSkew int32, tpWeight float64) float64 {
return float64(cnt)*tpWeight + float64(maxSkew-1)
}
- 在 Score 阶段就是为当前的节点去计算一个分数,这个分数就是通过拓扑对下匹配的 Pod 数量和对应权重的结果得到的一个分数,另外在计算分数的时候还加上了
maxSkew-1
,这样可以淡化拓扑域之间的差异。
4.4 PodTopologySpread优选阶段NormalizeScore
- 当所有节点的分数计算完成后,还需要调用
NormalizeScore
扩展插件:
// pkg/scheduler/framework/plugins/podtopologyspread/scoring.go
// NormalizeScore 在对所有节点打分过后调用
func (pl *PodTopologySpread) NormalizeScore(ctx context.Context, cycleState *framework.CycleState, pod *v1.Pod, scores framework.NodeScoreList) *framework.Status {
s, err := getPreScoreState(cycleState)
if err != nil {
return framework.NewStatus(framework.Error, err.Error())
}
if s == nil {
return nil
}
// 计算 <minScore> and <maxScore>
var minScore int64 = math.MaxInt64
var maxScore int64
for _, score := range scores {
if s.IgnoredNodes.Has(score.Name) {
continue
}
if score.Score < minScore {
minScore = score.Score
}
if score.Score > maxScore {
maxScore = score.Score
}
}
// 循环 scores({node score}集合)
for i := range scores {
nodeInfo, err := pl.sharedLister.NodeInfos().Get(scores[i].Name)
if err != nil {
return framework.NewStatus(framework.Error, err.Error())
}
node := nodeInfo.Node()
// 节点被忽略了,分数记为0
if s.IgnoredNodes.Has(node.Name) {
scores[i].Score = 0
continue
}
// 如果 maxScore 为0,指定当前节点的分数为 MaxNodeScore
if maxScore == 0 {
scores[i].Score = framework.MaxNodeScore
continue
}
// 计算当前节点分数
s := scores[i].Score
scores[i].Score = framework.MaxNodeScore * (maxScore + minScore - s) / maxScore
}
return nil
}
-
NormalizeScore
扩展是在 Score 扩展执行完成后,为每个 ScorePlugin 并行运行 NormalizeScore 方法,然后并为每个 ScorePlugin 应用评分默认权重,然后总结所有插件调用过后的分数,最后选择一个分数最高的节点。 -
到这里我们就完成了对 PodTopologySpread 的实现分析,我们利用该特性可以实现对 Pod 更加细粒度的控制,我们可以把 Pod 分布到不同的拓扑域,从而实现高可用性,这也有助于工作负载的滚动更新和平稳地扩展副本。
-
不过如果对 Deployment 进行缩容操作可能会导致 Pod 的分布不均衡,此外具有污点的节点上的 Pods 也会被统计到。
第五节 节点资源更均匀和较少优选插件
5.1 优选插件NodeResourcesBalancedAllocation
- 调度 Pod 的时候,选择资源分配更为均匀和资源分配较少的节点
- 该插件是在打分阶段,选择资源分配更为均匀的节点(CPU 和内存资源占用率相近的胜出),默认启用该插件,权重为1。同样直接分析插件的 Score 函数即可:
// pkg/scheduler/framework/plugins/noderesources/balanced_allocation.go
func (ba *BalancedAllocation) Score(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) {
nodeInfo, err := ba.handle.SnapshotSharedLister().NodeInfos().Get(nodeName)
if err != nil {
return 0, framework.NewStatus(framework.Error, fmt.Sprintf("getting node %q from Snapshot: %v", nodeName, err))
}
// ba.score 有利于资源使用率均衡的节点
// 它计算cpu和内存容量的差值,并根据这两个指标的接近程度来确定主机的优先级。
// Detail: score = (1 - variance(cpuFraction,memoryFraction,volumeFraction)) * MaxNodeScore.
return ba.score(pod, nodeInfo)
}
- 核心实现就是下面的 score 函数了,该函数的核心是计算内存与 CPU 容量的差值,根据这两个指标的接近程度来确定节点的优先级:
// pkg/scheduler/framework/plugins/noderesources/resource_allocation.go
// resourceToWeightMap 包含资源名称和对应的权重
type resourceToWeightMap map[v1.ResourceName]int64
// defaultRequestedRatioResources 定义默认的 CPU 和内存的 resourceToWeightMap
var defaultRequestedRatioResources = resourceToWeightMap{v1.ResourceMemory: 1, v1.ResourceCPU: 1}
// resourceAllocationScorer 包含计算资源分配分数的信息。
type resourceAllocationScorer struct {
Name string
scorer func(requested, allocable resourceToValueMap, includeVolumes bool, requestedVolumes int, allocatableVolumes int) int64
resourceToWeightMap resourceToWeightMap
}
// resourceToValueMap 包含资源名称和分数
type resourceToValueMap map[v1.ResourceName]int64
func (r *resourceAllocationScorer) score(
pod *v1.Pod,
nodeInfo *framework.NodeInfo) (int64, *framework.Status) {
node := nodeInfo.Node()
......
requested := make(resourceToValueMap, len(r.resourceToWeightMap))
allocatable := make(resourceToValueMap, len(r.resourceToWeightMap))
// 根据资源定义的权重来计算对应资源的分数
for resource := range r.resourceToWeightMap {
allocatable[resource], requested[resource] = calculateResourceAllocatableRequest(nodeInfo, pod, resource)
}
var score int64
// 检查pod是否有volumes卷,可以添加到scorer函数中,以实现均衡的资源分配
if len(pod.Spec.Volumes) >= 0 && utilfeature.DefaultFeatureGate.Enabled(features.BalanceAttachedNodeVolumes) && nodeInfo.TransientInfo != nil {
score = r.scorer(requested, allocatable, true, nodeInfo.TransientInfo.TransNodeInfo.RequestedVolumes, nodeInfo.TransientInfo.TransNodeInfo.AllocatableVolumesCount)
} else {
score = r.scorer(requested, allocatable, false, 0, 0)
}
return score, nil
}
- 该函数的整体实现比较简单,根据资源定义的权重来计算节点上对应资源的分数,然后调用 scorer 回调函数计算出最后的得分。
- 默认定义的 CPU 和内存资源的权重是1:1,然后调用
calculateResourceAllocatableRequest
函数分别计算节点上这两种资源可分配和请求的分数,最后通过回调函数 scorer 函数来计算出最终的得分。
// pkg/scheduler/framework/plugins/noderesources/resource_allocation.go
// 计算节点指定资源的可分配值和请求值
func calculateResourceAllocatableRequest(nodeInfo *framework.NodeInfo, pod *v1.Pod, resource v1.ResourceName) (int64, int64) {
// 计算 Pod 对应资源的请求值
podRequest := calculatePodResourceRequest(pod, resource)
switch resource {
case v1.ResourceCPU:
// 节点资源的请求值为节点上已有的请求CPU值+当前Pod请求的值
return nodeInfo.Allocatable.MilliCPU, (nodeInfo.NonZeroRequested.MilliCPU + podRequest)
case v1.ResourceMemory:
// 节点资源的请求值为节点上已有的请求内存值+当前Pod请求的值
return nodeInfo.Allocatable.Memory, (nodeInfo.NonZeroRequested.Memory + podRequest)
// 节点资源的请求值为节点上已有的请求临时存储值+当前Pod请求的值
case v1.ResourceEphemeralStorage:
return nodeInfo.Allocatable.EphemeralStorage, (nodeInfo.Requested.EphemeralStorage + podRequest)
default:
// 节点资源的请求值为节点上已有的请求标量资源值+当前Pod请求的值
if v1helper.IsScalarResourceName(resource) {
return nodeInfo.Allocatable.ScalarResources[resource], (nodeInfo.Requested.ScalarResources[resource] + podRequest)
}
}
return 0, 0
}
// 返回总的非零请求. 如果为 Pod 定义了 Overhead 并且启用了 PodOverhead 特性
// 这 Overhead 也会被计算在内。
// podResourceRequest = max(sum(podSpec.Containers), podSpec.InitContainers) + overHead
func calculatePodResourceRequest(pod *v1.Pod, resource v1.ResourceName) int64 {
var podRequest int64
// 所有容器的 Request 值
for i := range pod.Spec.Containers {
container := &pod.Spec.Containers[i]
// 获取指定容器指定资源的非0请求值
value := schedutil.GetNonzeroRequestForResource(resource, &container.Resources.Requests)
podRequest += value
}
// 所有初始化容器的 Request 值,需要和普通容器的 Request 进行比较,取较大的值
for i := range pod.Spec.InitContainers {
initContainer := &pod.Spec.InitContainers[i]
value := schedutil.GetNonzeroRequestForResource(resource, &initContainer.Resources.Requests)
if podRequest < value {
podRequest = value
}
}
// 如果开启了 Overhead 特性,则也需要计算在 Request 中
if pod.Spec.Overhead != nil && utilfeature.DefaultFeatureGate.Enabled(features.PodOverhead) {
if quantity, found := pod.Spec.Overhead[resource]; found {
podRequest += quantity.Value()
}
}
return podRequest
}
calculateResourceAllocatableRequest
函数用来计算节点资源的可分配和请求的值,可分配的资源直接从 NodeInfo 获取即可,请求的资源是节点上所有 Pod 的非0总请求资源,然后加上当前 Pod 的请求资源值即可。- 计算当前 Pod 的请求资源也很简单,算法为
max(sum(podSpec.Containers), podSpec.InitContainers) + overHead
,就是所有普通容器和初始化容器总的请求较大值,如果开启了 Overhead 特性,也需要计算在 Request 中,这里有一个重点是计算容器请求资源的时候,是计算非0的请求值,因为有一些 Pod 没有指定 Requests 值,那么就需要计算一个默认值:
// pkg/scheduler/util/non_zero.go
// 如果没有找到或者指定 Request 值则返回默认的资源请求值
func GetNonzeroRequestForResource(resource v1.ResourceName, requests *v1.ResourceList) int64 {
switch resource {
case v1.ResourceCPU:
if _, found := (*requests)[v1.ResourceCPU]; !found {
// 没有指定 CPU,默认返回 100(0.1core)
return DefaultMilliCPURequest
}
return requests.Cpu().MilliValue()
case v1.ResourceMemory:
if _, found := (*requests)[v1.ResourceMemory]; !found {
// 没有指定内存,默认返回200MB
return DefaultMemoryRequest
}
return requests.Memory().Value()
case v1.ResourceEphemeralStorage:
// 如果本地存储容量隔离特性被禁用,则 Pod 请求为 0 disk。
if !utilfeature.DefaultFeatureGate.Enabled(features.LocalStorageCapacityIsolation) {
return 0
}
// 没有找到也返回0
quantity, found := (*requests)[v1.ResourceEphemeralStorage]
if !found {
return 0
}
return quantity.Value()
default:
// 如果是标量资源没有找到返回0
if v1helper.IsScalarResourceName(resource) {
quantity, found := (*requests)[resource]
if !found {
return 0
}
return quantity.Value()
}
}
return 0
}
- 当 Pod 没有指定 CPU 的 requests 的时候,默认返回 100m(0.1core),如果是内存没有指定,则默认为200MB。
- 到这里就请求如何计算节点可分配和总的请求资源值了,接下来就是去查看具体的回调 scorer 函数的实现。在 BalancedAllocation 插件初始化的时候传入了 scorer 函数的实现:
// pkg/scheduler/framework/plugins/noderesources/balanced_allocation.go
// 实例化 BalancedAllocation 插件
func NewBalancedAllocation(_ runtime.Object, h framework.FrameworkHandle) (framework.Plugin, error) {
return &BalancedAllocation{
handle: h,
resourceAllocationScorer: resourceAllocationScorer{
BalancedAllocationName,
balancedResourceScorer,
defaultRequestedRatioResources,
},
}, nil
}
- scorer 回调函数就是这里的
balancedResourceScorer
:
// pkg/scheduler/framework/plugins/noderesources/balanced_allocation.go
func balancedResourceScorer(requested, allocable resourceToValueMap, includeVolumes bool, requestedVolumes int, allocatableVolumes int) int64 {
// 总的请求和可分配的资源的比例
cpuFraction := fractionOfCapacity(requested[v1.ResourceCPU], allocable[v1.ResourceCPU])
memoryFraction := fractionOfCapacity(requested[v1.ResourceMemory], allocable[v1.ResourceMemory])
// 如果比例>=1,相当于 requested>=allocable,那么该主机绝对不应该优先调度了,所以返回分数为0
if cpuFraction >= 1 || memoryFraction >= 1 {
return 0
}
// 如果 Volumes 需要计算在内
if includeVolumes && utilfeature.DefaultFeatureGate.Enabled(features.BalanceAttachedNodeVolumes) && allocatableVolumes > 0 {
volumeFraction := float64(requestedVolumes) / float64(allocatableVolumes)
if volumeFraction >= 1 {
// 和上面一样,volume的请求和可分配的比例>=1了,不能优先调度
return 0
}
// 计算这3个比率的方差(偏离程度)
mean := (cpuFraction + memoryFraction + volumeFraction) / float64(3) // 平均数
variance := float64((((cpuFraction - mean) * (cpuFraction - mean)) + ((memoryFraction - mean) * (memoryFraction - mean)) + ((volumeFraction - mean) * (volumeFraction - mean))) / float64(3))
// 方差越小,代表越稳定,所以分数应该更高,所以这里用 1 减去方差
return int64((1 - variance) * float64(framework.MaxNodeScore))
}
// cpuFraction 和 memoryFraction 之间的差值范围为 -1 和 1,将差值 * `MaxNodeScore`,将其变为 0-MaxNodeScore
// 0代表分配均衡较好,`MaxNodeScore`代表均衡不良
// 从 `MaxNodeScore` 中减去,得到的分数也是从0到`MaxNodeScore`,而`MaxNodeScore`代表平衡良好。
// 差值越小越平衡,所以分数更高
diff := math.Abs(cpuFraction - memoryFraction)
return int64((1 - diff) * float64(framework.MaxNodeScore))
}
func fractionOfCapacity(requested, capacity int64) float64 {
if capacity == 0 {
return 1
}
return float64(requested) / float64(capacity)
}
- 该计算分数的函数先计算得到 CPU 和内存的请求和可分配容器间的比率,如果需要计算 Volume,则计算这三种资源比率的方差,方差越小,代表越稳定,所以分数应该更高。同样如果只需要计算 CPU 和内存,则直接计算二者比率的差值即可,差值越小表示分配越平衡,理论上分数就应该更高,这就是这里我们提到的资源分配更均匀的算法。
5.2 优选插件NodeResourcesLeastAllocated
-
该插件同样是在打分阶段,默认启用,偏向与选择请求资源分配较少的节点,计算算法为:
(cpu((capacity-sum(requested))*MaxNodeScore/capacity) + memory((capacity-sum(requested))*MaxNodeScore/capacity))/weightSum
,它计算节点上调度的 Pod 所请求的内存和 CPU 的百分比,根据请求的分数与容量的平均值的最小值进行优先级排序。 -
整体流程和上面的插件一样,核心都是去调用最终的 scorer 回调函数计算分数:
// pkg/scheduler/framework/plugins/noderesources/least_allocated.go
// 初始化插件
func NewLeastAllocated(laArgs runtime.Object, h framework.FrameworkHandle) (framework.Plugin, error) {
args, ok := laArgs.(*config.NodeResourcesLeastAllocatedArgs)
if !ok {
return nil, fmt.Errorf("want args to be of type NodeResourcesLeastAllocatedArgs, got %T", laArgs)
}
if err := validation.ValidateNodeResourcesLeastAllocatedArgs(args); err != nil {
return nil, err
}
// 根据插件配置的时候传递的参数来确定资源权重
resToWeightMap := make(resourceToWeightMap)
for _, resource := range (*args).Resources {
resToWeightMap[v1.ResourceName(resource.Name)] = resource.Weight
}
return &LeastAllocated{
handle: h,
resourceAllocationScorer: resourceAllocationScorer{
Name: LeastAllocatedName,
scorer: leastResourceScorer(resToWeightMap),
resourceToWeightMap: resToWeightMap,
},
}, nil
}
- 通过上面初始化函数可以看出最终的打分函数定义为
leastResourceScorer(resToWeightMap)
:
// pkg/scheduler/framework/plugins/noderesources/least_allocated.go
func leastResourceScorer(resToWeightMap resourceToWeightMap) func(resourceToValueMap, resourceToValueMap, bool, int, int) int64 {
return func(requested, allocable resourceToValueMap, includeVolumes bool, requestedVolumes int, allocatableVolumes int) int64 {
var nodeScore, weightSum int64
for resource, weight := range resToWeightMap {
// 计算未使用资源的得分
resourceScore := leastRequestedScore(requested[resource], allocable[resource])
// 作用权重
nodeScore += resourceScore * weight
weightSum += weight
}
// 节点得分/总的权重,得到一个节点未使用资源的平均分数
return nodeScore / weightSum
}
}
// 未使用的容量按0-MaxNodeScore的比例计算
// 0为最低优先级,`MaxNodeScore`为最高优先级
// 未使用的资源越多,得分越高
func leastRequestedScore(requested, capacity int64) int64 {
if capacity == 0 {
return 0
}
if requested > capacity {
return 0
}
// 为使用的资源比率
return ((capacity - requested) * int64(framework.MaxNodeScore)) / capacity
}
-
这里的算法比较简单,节点未使用的资源越多自然分数越高,直接用未使用资源的比率作为分数,然后作用权重后得到所有未使用资源的一个平均分数,作为最终的分数,这个算法非常好理解。
-
实际上除了上面的两个根据节点资源进行打分的插件外,还有一个名为
NodeResourcesMostAllocated
的插件,从名字可以看出这是选择资源分配较多的节点,从调度的角度来说我们肯定不会去采用这个插件,事实上默认也并没有启用这个插件,只是在ClusterAutoscaler
的时候默认会将上面的NodeResourcesLeastAllocated
插件替换成NodeResourcesMostAllocated
。