关于后端:哈啰Kubernetes基于水位的自定义调度器落地之路

17次阅读

共计 11134 个字符,预计需要花费 28 分钟才能阅读完成。

背景

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 预测配置等措施,一起来实现降本的目标。

(本文作者:朱喜喜)

本文系哈啰技术团队出品,未经许可,不得进行商业性转载或者应用。非商业目标转载或应用本文内容,敬请注明“内容转载自哈啰技术团队”。

正文完
 0