背景
k8s原生调度器默认资源均衡是依据Node节点的闲暇request来实现的,然而咱们配置Pod request预设值时根本是虚拟机的思维,会比理论程序应用值偏大并且和理论偏差较大,造成Node的request已调配比和资源理论利用率(水位)偏差较大,如下图所示。如果集群规模较大或集群运行工夫较长,每个节点中request调配尽管靠近,然而节点间资源水位相差很大。负载很高的主机其上的业务存在运行不稳固,同时负载很低的主机资源被大量节约,哈啰自研的基于水位均衡的调度器次要就是为了解决这个问题。
水位调度器整体工作逻辑:通过监控获取Node节点和Pod历史资源占用,在调度时,依据水位均衡算法,将低水位的Pod调度到高水位的Node节点上,将高水位的Pod调度到低水位的Node节点上,最终使整个集群中的Node水位相近,使物理资源失去更充沛的利用,整个集群的稳定性也大大晋升。本篇旨在实现一个均衡集群中Node理论使用率的调度器,从而达到晋升集群稳定性,进步资源使用率的目标。
调度器简介
Kubernetes Scheduler通过watch etcd,及时发现PodSpec. NodeName为空的Pods,通过肯定的规定,筛选最合适的Node,将PodSpec.NodeName设置为该Node name。该Node上的kubelet会监听到新Pod并启动。
Scheduler从 Kubernetes 1.16 版本开始, 构建了一种新的调度框架Scheduling Framework 的机制。Scheduling Framework无论在性能上,还是效率上绝对之前的调度器都有很大的晋升。上面次要对Scheduling Framework作一个简略的介绍。
工作流程
这是官网提供的一个Scheduler工作流程图,其中每个阶段都能够进行定制(也称为扩大点)。每个插件能够实现一个或多个扩大点。
1.一个Pod从生成到绑定到Node上,称为一个调度周期。一个调度周期次要分为两大周期: 调度周期, 绑定周期,还有一个之前的sort阶段。
2.个别咱们次要对调度周期进行一些定制。调取周期最重要的就是Filter和Score,上面具体介绍下他们的工作流程:
a. PreFilter 预过滤
该扩大点用于预处理无关 Pod 的信息,查看集群或 Pod 必须满足的某些条件。如果 PreFilter 返回谬误,则调度周期将停止。在一个调度周期中,每个插件的PreFilter钩子函数只会执行一次。
b. Filter 过滤
过滤掉不满足需要的节点。如果任意一个插件返回的失败,则该Node就会被标记为不可用, Node不会进入下一阶段。过滤插件其实相似于上一代Kubernetes 调度器中的预选环节,即 Predicates。在每个调度周期中,每个插件的Filter钩子函数会执行屡次(由Node数量决定)。
c. PreScore 预打分
预打分阶段次要能够提前计算数据、提前指标用于下一阶段的打分。也能够进行一些日志的打印。每个插件的PreScore钩子函数只会执行一次。
d. Score 打分
Score 扩大点和上一代的调度器的优选流程很像,它分为两个小阶段:
- Score “打分”,用于对已通过过滤阶段的节点进行排名。调度程序将为 Score 每个节点调用每个计分插件进行打分,这个分数只有在int64范畴内即可。
- NormalizeScore “归一化”,用于在调度程序计算节点的最终排名之前批改分数,个别是对上一步得进去的分出进行再一次优化,能够不实现, 然而须要保障 Score 插件的输入必须是 [0-100]范畴内的整数。
e. 调度周期工作流程伪代码
allNode = K8S所有的node节点for PreFilter in (plugin1, plugin2, ...): IsSuccess = PreFilter(state *CycleState, pod *v1.Pod) if IsSuccess is False: return // 调度周期完结 feasibleNodes = [] // 存储Filter阶段符合条件的Nodefor nodeInfo in allNode: IsSuccess = False for Filter in (plugin1, plugin2, ...): IsSuccess = Filter(state *CycleState, pod *v1.Pod, nodeInfo *NodeInfo) // 如果任意一个插件返回了False,阐明Node不合乎调度条件 if IsSuccess is False: break if IsSuccess = True: feasibleNodes.append(nodeInfo)// 如果只有一个Node通过了Filter阶段的查看,该Node会间接进入绑定阶段,跳过打分阶段if len(feasibleNodes) == 1: return feasibleNodes[0]for PreScore in (plugin1, plugin2, ...): PreScore(state *CycleState, pod *v1.Pod) NodeScores = { }// NodeScores数据结构: {"plugin1": [node1_score, node2_score], "plugin2": [...], ... }// 每个插件对每个Node,都会进行一次打分,总共会有(Node数量*插件数)个分数for index, nodeInfo in feasibleNodes: for Score in (plugin1, plugin2, ...): score = Score(state *CycleState, pod *v1.Pod, nodeInfo *NodeInfo) NodeScores[插件名][index] = score // 归一化, 这个阶段解决过,分数都在[1-100]之间for NormalizeScore in (plugin1, plugin2, ...): nodeScoreList = NodeScores[插件名] NormalizeScore(state *CycleState, p *v1.Pod, scores NodeScoreList) // 加上插件权重因子for pluginName, nodeScoreList in NodeScores: for nodeScore in nodeScoreList: nodeScoreList[i].Score = nodeScore.Score * int64(pluginWeight)// 计算每个Node的总分result = []for nodeIndex, nodeName in feasibleNodes { _result = {Name: nodeName, Score: 0}) for pluginName, _ in NodeScores { _result.Score += NodeScores[pluginName][nodeIndex].Score } result.append(_result)}// result 后果为 [{Name: node1, Score: 200}, {Name: node2, Score: 100}, ...]// selectHost 找到得分最高的Node进入绑定阶段Node = selectHost(result)return Node
3.绑定周期
个别都是对一些资源进行解决,或者减少一些日志、事件触发等,罕用的是PreBind和PostBind。
a. Permit 审批
在每个Pod的调度周期完结时,将调用Permit插件,以避免或提早与候选节点的绑定。permit插件能够执行以下三项操作之一:
- approve
一旦所有permit插件批准Pod,便将其发送以进行绑定。 - deny
如果任何permit插件回绝Pod,则将其返回到调度队列。这将触发Reserve插件中的Unreserve阶段。 - wait(with a timeout)
如果Permit插件返回”wait”,则Pod会保留在外部的”waiting” Pods列表中,此Pod的绑定周期开始,但会间接阻塞,直到取得批准为止。如果产生超时,wait将变为deny,并且Pod将返回到调度队列,从而触发Reserve插件中的Unreserve阶段。
b. PreBind 预绑定
用于执行绑定Pod之前所需的任何工作。例如,PreBind插件能够设置网络卷并将其挂载在指标节点上,而后再容许Pod在此处运行。如果任何PreBind插件返回谬误,则Pod被回绝并返回到调度队列。
c. Bind 绑定
将Pod绑定到节点。在所有PreBind插件实现之前,不会调用Bind插件。每个Bind插件均按配置顺序调用。Bind插件能够抉择是否解决给定的Pod。如果Bind插件抉择解决Pod,则会跳过其余的Bind插件。
d. PostBind
胜利绑定Pod后,将调用PostBind插件。绑定周期到此结束,能够用来清理关联的资源。
调度器插件配置
能够通过配置文件(能够是文件或者configmap)指定每个阶段须要开启或者敞开的插件。
apiVersion: kubescheduler.config.k8s.io/v1alpha2kind: KubeSchedulerConfigurationprofiles:- schedulerName: default-scheduler plugins: preFilter: enabled: - name: HheWaterLevelBalance filter: enabled: - name: HheWaterLevelBalance - name: HkePodTopologySpread preScore: enabled: - name: HkePodTopologySpread - name: HheWaterLevelBalance score: enabled: - name: HkePodTopologySpread // 启用自定义插件 - name: HheWaterLevelBalance disabled: - name: ImageLocality // 禁用默认插件 - name: InterPodAffinity postBind: enabled: - name: HheWaterLevelBalance pluginConfig: // 插件配置 - name: HheWaterLevelBalance args: clusterCpuMinNodeWeight: 0.2
计划调研
kubernetes-sigs的TargetLoadPacking插件
实现原理
- 通过一个Metrics Provider提供api,可查问Node cpu使用率(工夫窗口为5分钟,10分钟,15分钟)
- 通过配置文件设置cluster_cpu(百分比),示意冀望每个Node的cpu 使用率都达到这个值
- score阶段算法
- 获取要评分的Node的15m cpu利用率。记为node_cpu
- 依据Pod limit计算出以后 Pod 的cpu 使用量, 除以Node容量,计算出该Pod在以后Node的cpu 使用率。记为 pod_cpu
- 如果 Pod 调度在该节点下,计算预期利用率,即 target_cpu = node_cpu + pod_cpu
- 如果 target_cpu <= cluster_cpu,则返回 (100 - cluster_cpu)target_cpu/cluster_cpu+ cluster_cpu 作为分数,记为状况A
- 如果 cluster_cpu < target_cpu <= 100%,则返回 50(100 - target_cpu)/(100 - cluster_cpu) ,记为状况B // 留神这里的50有问题,前面我会特地阐明
- 如果 target_cpu > 100%,返回 0,记为状况C
核心思想:
1.这个算法其实就是数学中装箱问题(背包问题)的变种,采纳的best fit 近似算法
2.把Pod尽量调度到靠近cluster_cpu线的node上
3.负载高的Pod会调度到绝对低的node上,负载低的Pod会调度到负载绝对高的node上
4.热点问题:
- scheduler本地保护了一个缓存ScheduledPodsCache
- 应用informer监听Pod事件
- 在Pod binding到Node后,会写入缓存ScheduledPodsCache
- 在打分阶段,取到Node的 metrcs update time记为metrics_time, 当缓存中该Node中的Pod的Timestamp 大于等于metrics_time, 记为missingUtil
- 计算Node理论负载时,会加上missingUtil
- 定时清理过久数据
- 监听Pod事件,如果Pod销毁,该Pod信息会从缓存中删除
总结
a. 该插件算法实现了负载偏低的Pod调度到负载高的Node上,负载偏高的Pod调度到负载低的Node上,这部分合乎预期
b. Pod cpu使用量是通过limit获得,在咱们公司内limit和Pod理论使用率偏差较大,造成计算出来的target_cpu不符合实际
c. 须要预设集群现实值cluster_cpu,因为互联网业务,存在显著的业务顶峰和低谷,没方法配置一个固定值
d. 下面的算法50(100 - target_cpu)/(100 - cluster_cpu)中的这个50是有问题的,在计算出来的target_cpu过低的状况下,状况B的得分有可能比状况A高,Pod会被调度到高与cluster_cpu的Node上。在集群扩容节点的时候,这种状况尤为重大。下图为当cluster_cpu=10%的状况下,该算法的得分状况:
这个问题我曾经提了pr,具体见:https://github.com/kubernetes...
crane-scheduler
实现原理
a. 通过一个Node-annotator组件定期从Prometheus中拉取节点负载 metric(cpu_usage_avg_5m、cpu_usage_max_avg_1h、cpu_usage_max_avg_1d、mem_usage_avg_5m、mem_usage_max _avg_1h、mem_usage_max_avg_1d),写入到节点的 annotation中
b. 为了防止 Pod 调度到高负载的 Node 上, 能够通过参数配置,在filter阶段间接把负载过高的Node过滤掉
c. 在score阶段,读取Pod annotation理论负载的上述指标,而后依据加权和运算进行打分
实现的指标:把尽可能多的Pod调度到理论负载低的Node上
d. 热点问题解决
- 如果节点在过来1分钟调度了超过2个 Pod,则优选评分减去1分
- 如果节点在过来5分钟调度了超过5个 Pod,则优选评分减去1分
总结
a. 大部分Pod理论负载偏低,然而依据crane-scheduler的算法,大量的这种Pod被调度到低水位的Node上,造成Node 的limit预调配曾经满了,Node实在水位仍旧很低
b. 没有思考Pod理论利用负载,冀望的状况应该是负载偏低的Pod调度到负载高的Node上,或者相同
自研计划整顿
外围前提
通过对下面两个计划的剖析,自研计划必须要满足的前提条件:
- 必须获取到Node的历史和以后水位
- 必须获取到被调度Pod的资源利用率
- 通过计算Node和Pod水位,负载偏低的Pod调度到负载高的Node上,负载偏高的Pod调度到负载低的node上
- 须要思考业务的波峰谷底
- 须要思考热点问题
水位的获取
1.Node水位
Node水位实现比较简单,通过一个golang程序读取 Prometheus或其余监控零碎中的 Node 实在负载信息,写入Node的annotation中。
2.Pod水位
Pod的水位获取会麻烦一些,这里分成两类,一类为通过Deployment、Cloneset治理的Pod。其余的都归为第二类。
a. 第一类(以Deployment示例):
- 监控信息的获取与Node一样,读取 Prometheus中的负载信息,写入Deployment或Cloneset中的annotation中
- 调度时,通过Pod的OwnerReferences属性,查到Deployment
- 读取Deployment 的annotation
b. 第二类
- 读取Pod的limit作为Pod的水位资源
计算公式
参考下面的TargetLoadPacking插件算法, 伪代码如下:
cluster_cpu = 预设现实值target_cpu = node_cpu + pod_cpuif target_cpu <= cluster_cpu: score = (100 - cluster_cpu)target_cpu/cluster_cpu+ cluster_cpu else if cluster_cpu < target_cpu <= 100: score = cluster_cpu(100 - target_cpu)/(100 - cluster_cpu)else: score = 0
当初预设:
1.集群现实值为cluster_cpu=20%,
2.有一个须要调度的Pod,须要占用的水位为Pa = 1
3.假如有5个Node,水位(node_cpu)别离是
Na = 0Nb = 4Nc = 24Nd = 49Ne = 98
4.当Pod别离调度到这5个Node上时,Node的水位(target_cpu)占用
Ta = 1Tb = 5Tc = 25Td = 50Te = 99
5.计算得分(score)
Sa = (100 - 20) * 1 / 20 + 20 = 24Sb = (100 - 20) * 5 / 20 + 20 = 40Sc = 20 * (100 - 25) / (100 - 20) = 19Sd = 20 * (100 - 50) / (100 - 20) = 13Se = 20 * (100 - 90) / (100 - 20) = 3
业务的波峰谷底
须要解决三个问题:
1.Pod、Node水位的获取要多个时间段
这里采纳三个时间段: 15分钟、1小时、1天。
2.预设现实值依据实时集群整体水位进行动静调整
这里也能够间接应用实时集群Node的均匀水位作为集群的现实值,然而有一个毛病: 通过下面的算法,能够得悉Pod会尽量落到现实值左近的Node上,没方法及时填充到最低水位的Node上。所以最好最低node的水位也参加计算。调整过的算法如下:
cluster_cpu = (cluster_cpu_avg + min_node_cpu * min_weight) / (1+ min_weight )
min_weight可通过配置文件配置,cpu水位差值比拟大的时候,min_weight能够配置的比拟高,偏差小的时候配置低一些
3.打分公式须要多个维度
获取Node和Pod 15分钟、1小时、1天的水位,别离依据下面的公式计算出来三个分数score_15m, score_1h,score_1d,依据比例算进去一个新的分数,作为最终得分。
score = score_15m weight + score_1h weight + score_1d * weight
解决热点问题
1.scheduler本地保护了一个缓存ScheduledPodsCache,数据结构:
{ "node1": [ { "Timestamp": "unixTime", "PodName": "podName", "PodUtil": { "cpu": 100, "mem": 500 } }, { "Timestamp": "unixTime2", "PodName": "podName2", "PodUtil": { "cpu": 100, "mem": 500 } } ], "nodeName2": { "Timestamp": "unixTime", "PodName": "podName", "PodUtil": { "cpu": 100, "mem": 500 } }}
2.Pod binding到Node后,会写入缓存ScheduledPodsCache
3.在打分阶段,取到Node的 metrcs update time记为metrics_time, 当缓存中该Node中的4. Pod的Timestamp 大于等于metrics_time, 记为missingUtil
4.计算Node理论负载时,会加上missingUtil
5.定时5m 会清理过久数据
6.监听Pod事件,如果Pod销毁,该Pod信息会从缓存中删除
工作流程图
计划落地
社区支流的调度器扩大计划分为两种extender,framework plugin。两者都属于非侵入式的计划,无需批改scheduler外围代码。其中framework plugin在Kubernetes 1.16开始反对,具备灵便、效率低等长处。所以本次扩大通过framework plugin模式实现。
插件注册
能够在https://github.com/kubernetes... 找到示例
import ( "math/rand" "os" "time" "k8s.io/component-base/logs" "k8s.io/kubernetes/cmd/kube-scheduler/app" "pkg/hhewaterlevelbalance" "pkg/pugin2")func main() { rand.Seed(time.Now().UnixNano()) // Register custom plugins to the scheduler framework. // Later they can consist of scheduler profile(s) and hence // used by various kinds of workloads. command := app.NewSchedulerCommand( app.WithPlugin(hhewaterlevelbalance.Name, HheWaterLevelBalance.New), // hhewaterlevelbalance.Name为插件名字 app.WithPlugin(pugin2.Name, pugin2.New), ) logs.InitLogs() defer logs.FlushLogs() if err := command.Execute(); err != nil { os.Exit(1) }}
将cmd/main.go打包成新的kube-scheduler,替换掉线上的版本即可。
批改版本号
在执行./bin/kube-scheduler --version加上一些标识,不便辨认是自定义调度器。
批改makefile
VERSION := $(shell git describe --tags --match "v*" | awk -F - '{print $$1}' 2>/dev/null || (printf "v0.0.0"))COMMIT := $(shell git rev-parse --short HEAD)RELEASE_DATE :=$(shell date +%Y%m%d)LDFLAGS=-ldflags "-X k8s.io/component-base/version.gitVersion=$(VERSION)-$(COMMIT)-${RELEASE_DATE}-hellobike -w"build: go build $(LDFLAGS) -o bin/kube-scheduler cmd/main.g
执行make install即可。
插件实现
在对应的阶段实现逻辑代码即可,示例:
// 插件名称const Name = "HheWaterLevelBalance"type HheWaterLevelBalanceArgs struct { ClusterCpuMinNodeWeight float64}type HheWaterLevelBalance struct { args *HheWaterLevelBalanceArgs handle framework.FrameworkHandle}func (h *HheWaterLevelBalance) Name() string { return Name}func (h *HheWaterLevelBalance) PreFilter(pc *framework.PluginContext, pod *v1.Pod) *framework.Status { klog.V(3).Infof("prefilter pod: %v", pod.Name) return framework.NewStatus(framework.Success, "")}func (h *HheWaterLevelBalance) Filter(pc *framework.PluginContext, pod *v1.Pod, nodeName string) *framework.Status { klog.V(3).Infof("filter pod: %v, node: %v", pod.Name, nodeName) return framework.NewStatus(framework.Success, "")}func (h *HheWaterLevelBalance) PreScore( pc *framework.PluginContext, cycleState *framework.CycleState, pod *v1.Pod, filteredNodes []*v1.Node,) *framework.Status { klog.V(3).Infof("prescore pod: %v", pod.Name) return framework.NewStatus(framework.Success, "")}func (h *HheWaterLevelBalance) Score(pc *framework.PluginContext, cycleState *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) { klog.V(3).Infof("score pod: %v, node: %v", pod.Name, nodeName) return score, framework.NewStatus(framework.Success, "")}func New(config *runtime.Unknown, f framework.FrameworkHandle) (framework.Plugin, error) { args := &HheWaterLevelBalanceArgs{} if err := framework.DecodeInto(config, args); err != nil { return nil, err } klog.V(3).Infof("get plugin config args: %+v", args) return &HheWaterLevelBalance{ args: args, handle: f, }, nil}
运行成果比照
图一为开启HheWaterLevelBalance前的监控图,Node间的水位最大偏差达到50%多。图二为插件运行一段时间后的监控图,水位偏差根本维持在15%左右。
总结
1.当初Node和Pod的水位获取都是借助annotation来实现的,思考性能,后续应该对立应用Kubernetes Metrics Server来实现。
2.后续能够退出时序变量,实现潮汐混部,晋升业务低峰期集群利用率。
3.水位平衡能够显著晋升集群稳定性。再配合弹性伸缩、Pod request/limit预测配置等措施,一起来实现降本的目标。
(本文作者:朱喜喜)
本文系哈啰技术团队出品,未经许可,不得进行商业性转载或者应用。非商业目标转载或应用本文内容,敬请注明“内容转载自哈啰技术团队”。