kube-scheduler深度剖析与开发(四)

1,048 阅读10分钟

为了深入学习 kube-scheduler,本系列从源码和实战角度深度学 习 kube-scheduler,该系列一共分6篇文章,如下:

  • kube-scheduler 整体架构
  • 初始化一个 scheduler
  • 一个 Pod 是如何调度的
  • 如何开发一个属于自己的scheduler插件
  • 开发一个 prefilter 扩展点的插件
  • 开发一个 socre 扩展点的插件

上一篇文章我们讲了一个 Pod 是如何被感知到需要调度和如何被调度的,其中在调度过程中寻找合适的 Node 用的都是内置默认的插件,其实 kube-scheduler 是可以扩展的。那么为什么要扩展呢?原因在于默认的插件算法可能并不能满足你的需要,比如我想要在preFilter 阶段就过滤掉带某些标签的节点,又或者我想根据节点实际的资源使用率来打分而不是当前已经分配的资源,那么默认的 kube-scheduler 是无法满足你的要求的,这时候我们就需要开发一个自己的插件对 kube-scheduler 进行扩展。kube-scheduler 扩展的方式主要有下面两种:

  1. 使用现有 kube-scheduler 提供的 extender 机制,进行扩展;这种方式就是开发 filter、score、bind的独立运行程序,这种模式允许你使用任何语言开发,开发的程序可以运行在任何 kube-scheduler 可以通过 http 访问访问到的地方;这种模式的优点就是 kube-scheduler 解耦,无需改变 kube-scheduler 代码,只需要增加配置文件即可;但是缺点也很明显,kube-scheduler 需要通过调用API的方式来访问扩展,所以会产生网络IO,这是很重的操作,会降低调度效率,并且还强依赖外部扩展的稳定性
  2. 通过调度框架(Scheduling Framework)进行扩展,Kubernetes v1.15 版本中引入了可插拔架构的调度框架,使得定制调度器这个任务变得更加的容易;这种模式只需要在现有扩展点的某个位置插入自定义的插件即可,还可以关闭默认的插件。这种模式也不需要修改现有的 kube-scheduler 框架代码,跟上一种模式不同的是,他是运行在 kube-scheduler 框架中的,就跟默认的内置插件没有区别,所以不需要 API 调用,稳定性好,效率也高。如下图,每一个箭头都是一个扩展点,每一个扩展点都可以插入自定义插件

架构图

如我们可以在 filter 扩展点插入自己的插件,如下图

本文就根据第二种模式详细讲讲,开发一个自定义的插件需要哪些步骤。

我们先回顾下几个重要的概念:scheduler, framework, registry

他们之间的关系我用下图来表示

  • scheduler 是一个调度器,它实现了整体调度逻辑,如在适当位置执行适当的扩展点,一个 Pod 调度失败了需要做什么处理,记录每一个调度情况暴露出 metric 方便外界对 scheduler 的监控等等。scheduler 是串联整个调度流程的。

  • Profiles scheduler 的一个成员,就是如下的一个Map

// pkg/scheduler/profile/profile.go

// Map holds frameworks indexed by scheduler name.
type Map map[string]framework.Framework

这个 map 的 key 是 scheduler name, vaule 是 Framework。我们一般在使用 kube-scheduler 时,没有对 kube-scheduler 或他配置文件做修改,此时这个 map 的 key 就只有默认的"default-scheduler",但是我们现在要开发自己的插件,我们可以在配置文件中定义新的 scheduler name,在这个 scheduler name 中引用自己开发的插件,我们看下下面的配置

apiVersion: kubescheduler.config.k8s.io/v1beta2
kind: KubeSchedulerConfiguration
leaderElection:
  leaderElect: true
clientConnection:
  kubeconfig: "/etc/kubernetes/scheduler.conf"

profiles:
- schedulerName: my-scheduler-1
  plugins:
    preFilter:
      enabled:
       // 我们在 preFilter 扩展点的 开发的 zoneLabel 插件
        - name: zoneLabel
        
- schedulerName: my-scheduler-2
  plugins:
    queueSort:
      enabled:
      // 我们在 sort 扩展点开发的 mySort 插件,替代默认的 sort插件
        - name: mySort

我们在 profiles 中定义了两个 profile,他们的 schedulerName 分别为 my-scheduler-1 和 my-scheduler-2,当这个配置文件被加载后,Profiles(一个map)的 key 就有两个了,即这两个 schedulerName。 他们的 value 是什么呢?我们往下看

  • Framerowk

Framework 是一个接口,接口里面定义了一系列方法,这些方法主要是用运行插件的,kube-scheduler 的 frameworkImpl 实现了这个接口

type Framework interface {
   Handle
   
   QueueSortFunc() LessFunc

   RunPreFilterPlugins(ctx context.Context, state *CycleState, pod *v1.Pod) (*PreFilterResult, *Status)

   RunPostFilterPlugins(ctx context.Context, state *CycleState, pod *v1.Pod, filteredNodeStatusMap NodeToStatusMap) (*PostFilterResult, *Status)

   RunPreBindPlugins(ctx context.Context, state *CycleState, pod *v1.Pod, nodeName string) *Status

   RunPostBindPlugins(ctx context.Context, state *CycleState, pod *v1.Pod, nodeName string)

   RunReservePluginsReserve(ctx context.Context, state *CycleState, pod *v1.Pod, nodeName string) *Status

   RunReservePluginsUnreserve(ctx context.Context, state *CycleState, pod *v1.Pod, nodeName string)

   RunPermitPlugins(ctx context.Context, state *CycleState, pod *v1.Pod, nodeName string) *Status

   WaitOnPermit(ctx context.Context, pod *v1.Pod) *Status

   RunBindPlugins(ctx context.Context, state *CycleState, pod *v1.Pod, nodeName string) *Status

   HasFilterPlugins() bool

   HasPostFilterPlugins() bool

   HasScorePlugins() bool

   ListPlugins() *config.Plugins

   ProfileName() string
}

frameworkImpl 的成员主要是各个扩展点插件数组,用来存放该扩展点插件。frameworkImpl 实现 Framework 这个接口,可以通过 RunxxxxPlugins() 这样的方法来执行 frameworkImpl 中的插件。

type frameworkImpl struct {
	registry             Registry
	snapshotSharedLister framework.SharedLister
	waitingPods          *waitingPodsMap
	scorePluginWeight    map[string]int
	queueSortPlugins     []framework.QueueSortPlugin
	preFilterPlugins     []framework.PreFilterPlugin
	filterPlugins        []framework.FilterPlugin
	postFilterPlugins    []framework.PostFilterPlugin
	preScorePlugins      []framework.PreScorePlugin
	scorePlugins         []framework.ScorePlugin
	reservePlugins       []framework.ReservePlugin
	preBindPlugins       []framework.PreBindPlugin
	bindPlugins          []framework.BindPlugin
	postBindPlugins      []framework.PostBindPlugin
	permitPlugins        []framework.PermitPlugin
	clientSet       clientset.Interface
	kubeConfig      *restclient.Config
	eventRecorder   events.EventRecorder
	informerFactory informers.SharedInformerFactory
	metricsRecorder *metricsRecorder
	profileName     string
	extenders []framework.Extender
	framework.PodNominator
	parallelizer parallelize.Parallelizer
}

那么这些插件数组包含哪些插件?分两部分,一部分是内置的默认插件,还有一部分就是上面配置文件中 profiles 中定义的插件。如有下面的配置文件

apiVersion: kubescheduler.config.k8s.io/v1beta2
kind: KubeSchedulerConfiguration
leaderElection:
  leaderElect: true
clientConnection:
  kubeconfig: "/etc/kubernetes/scheduler.conf"

profiles:
- schedulerName: my-scheduler-1
  plugins:
    preFilter:
      enabled:
        - name: myPlugins_1
        - name: myPlugins_2

在 my-scheduler-1 中,我们在 preFilter 扩展点定义了两个自定义的插件,这两个插件会在 preFilter 扩展点默认插件加载完后,然后再加载这两个默认的插件,他们都存放在 frameworkImpl 成员 preFilterPlugins 中。一般地,自定义插件和默认插件的执行顺序,根据下面规则:

  • 如果某个扩展点没有配置对应的扩展,调度框架将使用默认插件中的扩展
  • 如果为某个扩展点配置且激活了扩展,则调度框架将先调用默认插件的扩展,再调用配置中的扩展
  • 默认插件的扩展始终被最先调用,然后按照 KubeSchedulerConfiguration 中扩展的激活 enabled 顺序逐个调用扩展点的扩展
  • 可以先禁用默认插件的扩展,然后在 enabled 列表中的某个位置激活默认插件的扩展,这种做法可以改变默认插件的扩展被调用时的顺序

更详细的规则,请看官网的配置说明

现在我们知道了插件从哪里来,有哪些插件,自定义插件和默认插件的执行顺序。但是如果要让插件执行起来,那是不是这个插件得有执行函数(方法)呢?

我们挑一个 frameworkImpl 的一个扩展点插件数组看下数组类型

type Plugin interface {
	Name() string
}

type FilterPlugin interface {
	Plugin
	Filter(ctx context.Context, state *CycleState, pod *v1.Pod, nodeInfo *NodeInfo) *Status
}

如上代码,要实现一个 Filter 扩展点的一个插件,只需要实现 Filter 和 Name 方法即可,其他插件类似。

内置插件的实现可以参考 pkg/scheduler/framework/plugins 目录。那么我们自定义的插件只要实现 Name 方法和插件对应方法就可以被执行了。

那么插件怎么注册到 frameworkImpl 中各个扩展点插件数组里的呢?我们看看registry

  • registry

registry 中文意思为注册,对于插件来说注册的信息为:插件叫什么,如何创建这个插件。下面是 registry 的结构

type PluginFactory = func(configuration runtime.Object, f framework.Handle) (framework.Plugin, error)

type Registry map[string]PluginFactory

registry 是一个 map: key 是插件的名字,value 是 PluginFactory 类型的函数,这个函数返回 framework.Plugin,这个 Plugin 就是我们上面说接口,实现这个接口的对象就可以作为插件被调用。所以 PluginFactory 的作用就是新建一个 Plugin 类型的对象。我们可以像下面这么描述

registry[插件名字]创建插件对象的函数

初始化流程:在 scheduler 启动前,遍历这个map,执行这个 map 的 value 代表的函数,将函数返回值写入 frameworkImpl 对应的扩展点数组。

执行某个扩展点插件流程:遍历 frameworkImpl 中这个扩展点数组的所有对象,执行它即可。

内置插件的注册叫 InTreeRegistry, 用户自定义插件的注册叫 OutOfTreeRegistry,在注册所有的插件时只需要将内置插件的 registry 和 用户自定义的 registry 合并在一起。这个流程是通过下面这个函数实现的:

// pkg/scheduler/scheduler.go

func New(client clientset.Interface,
	informerFactory informers.SharedInformerFactory,
	dynInformerFactory dynamicinformer.DynamicSharedInformerFactory,
	recorderFactory profile.RecorderFactory,
	stopCh <-chan struct{},
	opts ...Option) (*Scheduler, error) {

    ...

	options := defaultSchedulerOptions

	for _, opt := range opts {
		opt(&options)
	}

    ...

	registry := frameworkplugins.NewInTreeRegistry()

	if err := registry.Merge(options.frameworkOutOfTreeRegistry); err != nil {
		return nil, err
	}
	
	...
}

// pkg/scheduler/framework/plugins/registry.go

func NewInTreeRegistry() runtime.Registry {
	fts := plfeature.Features{
		EnableReadWriteOncePod:                       feature.DefaultFeatureGate.Enabled(features.ReadWriteOncePod),
		EnableVolumeCapacityPriority:                 feature.DefaultFeatureGate.Enabled(features.VolumeCapacityPriority),
		EnableMinDomainsInPodTopologySpread:          feature.DefaultFeatureGate.Enabled(features.MinDomainsInPodTopologySpread),
		EnableNodeInclusionPolicyInPodTopologySpread: feature.DefaultFeatureGate.Enabled(features.NodeInclusionPolicyInPodTopologySpread),
	}

	return runtime.Registry{
		selectorspread.Name:                  selectorspread.New,
		imagelocality.Name:                   imagelocality.New,
		tainttoleration.Name:                 tainttoleration.New,
		nodename.Name:                        nodename.New,
		nodeports.Name:                       nodeports.New,
		nodeaffinity.Name:                    nodeaffinity.New,
		podtopologyspread.Name:               runtime.FactoryAdapter(fts, podtopologyspread.New),
		nodeunschedulable.Name:               nodeunschedulable.New,
		noderesources.Name:                   runtime.FactoryAdapter(fts, noderesources.NewFit),
		noderesources.BalancedAllocationName: runtime.FactoryAdapter(fts, noderesources.NewBalancedAllocation),
		volumebinding.Name:                   runtime.FactoryAdapter(fts, volumebinding.New),
		volumerestrictions.Name:              runtime.FactoryAdapter(fts, volumerestrictions.New),
		volumezone.Name:                      volumezone.New,
		nodevolumelimits.CSIName:             runtime.FactoryAdapter(fts, nodevolumelimits.NewCSI),
		nodevolumelimits.EBSName:             runtime.FactoryAdapter(fts, nodevolumelimits.NewEBS),
		nodevolumelimits.GCEPDName:           runtime.FactoryAdapter(fts, nodevolumelimits.NewGCEPD),
		nodevolumelimits.AzureDiskName:       runtime.FactoryAdapter(fts, nodevolumelimits.NewAzureDisk),
		nodevolumelimits.CinderName:          runtime.FactoryAdapter(fts, nodevolumelimits.NewCinder),
		interpodaffinity.Name:                interpodaffinity.New,
		queuesort.Name:                       queuesort.New,
		defaultbinder.Name:                   defaultbinder.New,
		defaultpreemption.Name:               runtime.FactoryAdapter(fts, defaultpreemption.New),
	}
}

func (r Registry) Register(name string, factory PluginFactory) error {
	if _, ok := r[name]; ok {
		return fmt.Errorf("a plugin named %v already exists", name)
	}
	r[name] = factory
	return nil
}

func (r Registry) Merge(in Registry) error {
	for name, factory := range in {
		if err := r.Register(name, factory); err != nil {
			return err
		}
	}
	return nil
}


函数 NewInTreeRegistry 返回一个 registry,这个 registry 包含了所有内置插件对象的创建方法。Merge 函数将 NewInTreeRegistry 返回的 registry 和 options.frameworkOutOfTreeRegistry 做合并,那么 options.frameworkOutOfTreeRegistry 是什么呢?很明显,options.frameworkOutOfTreeRegistry 就是我们自定义的插件 registry

本文的重点来了

options.frameworkOutOfTreeRegistry 是通过 NewSchedulerCommand 函数的入参进行初始化的,如下代码:

//cmd/kube-scheduler/scheduler.go

func main() {
	command := app.NewSchedulerCommand()
	code := cli.Run(command)
	os.Exit(code)
}

// cmd/kube-scheduler/app/server.go

func NewSchedulerCommand(registryOptions ...Option) *cobra.Command {
	opts := options.NewOptions()

	cmd := &cobra.Command{
        
		RunE: func(cmd *cobra.Command, args []string) error {
			return runCommand(cmd, opts, registryOptions...)
		},
		Args: func(cmd *cobra.Command, args []string) error {
			for _, arg := range args {
				if len(arg) > 0 {
					return fmt.Errorf("%q does not take any arguments, got %q", cmd.CommandPath(), args)
				}
			}
			return nil
		},
	}
    ...
}

从代码中可以看到,NewSchedulerCommand 是在 kube-scheduler 的 main 函数中被执行的,但默认是没有任何入参的,那么我们想要使用自定义的插件是不是只要给这个函数传入合适的参数即可?

没错,正是如此,你只需要在你的 go 项目路径下创建一个 go 文件,该文件包含下面内容,你就可以编译出一个属于你自己的 scheduler

package main

import (
	"os"

	"k8s.io/component-base/cli"
	_ "k8s.io/component-base/logs/json/register" // for JSON log format registration
	_ "k8s.io/component-base/metrics/prometheus/clientgo"
	_ "k8s.io/component-base/metrics/prometheus/version" // for version metric registration
	"k8s.io/kubernetes/cmd/kube-scheduler/app"
)

func main() {
	command := app.NewSchedulerCommand(xxxx)
	code := cli.Run(command)
	os.Exit(code)
}

其中 NewSchedulerCommand 函数的入参是 Option 类型的对象,而且可以传多个,表示你可以传入多个自定义的插件对象,Option 类型如下

//cmd/kube-scheduler/app/server.go

type Option func(runtime.Registry) error

在 cmd/kube-scheduler/app/server.go 目录下的 WithPlugin 函数正好可以返回 Option 类型的对象(请仔细阅读这个函数的注释)

// WithPlugin creates an Option based on plugin name and factory. Please don't remove this function: it is used to register out-of-tree plugins,
// hence there are no references to it from the kubernetes scheduler code base.

func WithPlugin(name string, factory runtime.PluginFactory) Option {
	return func(registry runtime.Registry) error {
		return registry.Register(name, factory)
	}
}

言下之意,我们可以通过 WithPlugin 函数返回 NewSchedulerCommand 所需要的入参。

WithPlugin 函数有2个入参:

  • name:表示插件的名字
  • runtime.PluginFactory:表示创建这个插件对象的函数

我们再看下 runtime.PluginFactory 的类型

// pkg/scheduler/framework/runtime/registry.go

type PluginFactory = func(configuration runtime.Object, f framework.Handle) (framework.Plugin, error)
type Plugin interface {
	Name() string
}

所以我们要实现自己的插件,就需要实现一个方法,这个方法返回一个 framework.Plugin 类型的对象,而 framework.Plugin 是一个接口类型,那么这个对象需要实现 Name() 方法,他就是一个framework.Plugin 类型的对象。至于这个自定义插件想要在哪个(或哪些)扩展点插入,你只需要实现对应扩展点的接口即可,即通过接口组合实现。例如,你要在 Filter 扩展点插入自定义插件,你需要实现下面的接口

type FilterPlugin interface {
	Plugin
	Filter(ctx context.Context, state *CycleState, pod *v1.Pod, nodeInfo *NodeInfo) *Status
}

即实现 Plugin 接口和 Filter 方法,实现 Plugin 接口很简单,只需要实现 Name 方法即可,Filter 就是插件执行时候的执行方法。

我们以创建一个 Filter 扩展点插件为例总结一下当我们需要开发一个自定义插件时的流程

package my_plugin

// 1. 定义一个插件结构体
type MyPlugin struct{}

// 2. 实现 Plugin 插件,即实现 Name 方法

func (pl *MyPlugin) Name() {
    return "myPluginName"
}

// 3. 实现 Filter 函数
func (pl *MyPlugin) Filter(ctx context.Context, state *CycleState, pod *v1.Pod, nodeInfo *NodeInfo) *framework.Status {
    你的代码逻辑
}

// 4. 实现 New 函数,返回该自定义插件对象,类似下面代码
func New(_ runtime.Object, _ framework.Handle) (framework.Plugin, error) {
	return &MyPlugin{}, nil
}

下面就可以在你的 scheduler main 函数中引用上面创建的插件了,如下:


package main

import (
	"os"

	"k8s.io/component-base/cli"
	_ "k8s.io/component-base/logs/json/register" // for JSON log format registration
	_ "k8s.io/component-base/metrics/prometheus/clientgo"
	_ "k8s.io/component-base/metrics/prometheus/version" // for version metric registration
	"k8s.io/kubernetes/cmd/kube-scheduler/app"
	"my_plugin"
)

func main() {
        myPlugin := app.WithPlugin("myPluginName", my_plugin.New)
	command := app.NewSchedulerCommand(myPlugin)
	code := cli.Run(command)
	os.Exit(code)
}

以上就是创建一个自定义插件的主要流程了。当然了还有一些细节例如配置文件中如何引用该插件,如何运行包含自定义插件的 scheduler 等,我们会下下一篇详细说明。