背景
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阶段符合条件的Node
for 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/v1alpha2
kind: KubeSchedulerConfiguration
profiles:
- 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_cpu
if 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 = 0
Nb = 4
Nc = 24
Nd = 49
Ne = 98
4.当Pod分别调度到这5个Node上时,Node的水位(target_cpu)占用
Ta = 1
Tb = 5
Tc = 25
Td = 50
Te = 99
5.计算得分(score)
Sa = (100 - 20) * 1 / 20 + 20 = 24
Sb = (100 - 20) * 5 / 20 + 20 = 40
Sc = 20 * (100 - 25) / (100 - 20) = 19
Sd = 20 * (100 - 50) / (100 - 20) = 13
Se = 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预测配置等措施,一起来实现降本的目的。
(本文作者:朱喜喜)
本文系哈啰技术团队出品,未经许可,不得进行商业性转载或者使用。非商业目的转载或使用本文内容,敬请注明“内容转载自哈啰技术团队”。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。