mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2662 字
7 分钟
Kubernetes 调度框架与自定义调度器开发
2021-11-02

一、Kubernetes 调度框架以及开发实例#

Kubernetes 的调度器(kube-scheduler)是集群的核心组件之一,负责为每个新建的 Pod 选择最合适的运行节点。默认调度器采用 predicates 和 priorities 算法进行节点筛选和排序,但在生产环境中往往需要对调度行为进行更精细的控制,例如将特定服务绑定到特定节点池、考虑数据的本地性(data locality)、或实现 Bin-Packing/Spread 调度策略。

从 Kubernetes 1.18 开始,调度框架(Scheduling Framework)进入 beta 阶段,逐步取代旧的 predicates/priorities 插件机制。旧的扩展方式在 1.21 标记为弃用,1.23 完全移除,调度框架成为官方推荐的扩展方式。相比旧体系,调度框架提供了更清晰的扩展点(Extension Points)和更完善的生命周期管理,是走向生产可用的必经之路。

1.1 调度框架的核心概念#

调度框架定义了一套插件接口和扩展点,调度器的核心逻辑与具体策略实现完全解耦。调度器核心代码只负责按顺序调用各扩展点的插件,而具体的筛选、排序逻辑由插件实现。

一个 Pod 的完整调度周期分为两个主要阶段:调度周期(Scheduling Cycle)绑定周期(Binding Cycle)。调度周期负责为 Pod 选择目标节点,绑定周期负责将调度结果持久化到 apiserver。两个周期相互独立,调度周期失败则 Pod 不会被调度,绑定周期失败则调度结果会被回滚。

flowchart TB subgraph "调度周期 Scheduling Cycle" START([Pod 进入队列]) --> QS[QueueSort 排序] QS --> PF[PreFilter 预处理] PF --> F[Filter 节点筛选] F --> PoF{有可用节点?} PoF -->|否| POST[PostFilter 后处理] POST --> FAIL([调度失败]) PoF -->|是| PS[PreScore 预打分] PS --> S[Score 节点打分] S --> SELECT[选择最优节点] end subgraph "绑定周期 Binding Cycle" SELECT --> R[Reserve 资源预留] R --> P[Permit 许可检查] P --> PERMIT{通过?} PERMIT -->|否| ROLLBACK[释放预留资源] ROLLBACK --> FAIL PERMIT -->|是| B[Bind 绑定节点] B --> PB[PostBind 后处理] PB --> SUCCESS([调度成功]) end style START fill:#e3f2fd style SUCCESS fill:#c8e6c9 style FAIL fill:#ffcdd2

调度框架定义了以下扩展点,按调度周期中的调用顺序排列:

扩展点插件接口调用时机
QueueSortframework.QueueSortPlugin对调度队列中的 Pod 进行排序,决定下一个被调度的 Pod
PreFilterframework.PreFilterPlugin预处理 Pod 的调度条件,可用于检查集群是否满足 Pod 的调度需求
Filterframework.FilterPlugin排除不满足条件的节点,对应旧的 predicates
PostFilterframework.PostFilterPluginFilter 阶段后的后处理,当没有节点通过 Filter 时被调用
PreScoreframework.PreScorePlugin预打分阶段,可用于准备评分所需的共享资源信息
Scoreframework.ScorePlugin对通过 Filter 的节点进行评分,对应旧的 priorities
Reserveframework.ReservePlugin预留插件,用于「成功绑定前」的资源预留(如 CSI 存储挂载)
Permitframework.PermitPlugin允许阶段,可用于暂停/拒绝/批准调度结果
Bindframework.BindPlugin绑定阶段,将 Pod 绑定到目标节点
PostBindframework.PostBindPlugin绑定完成后的后处理,可用于清理预留资源
graph TB subgraph "Filter 阶段 - 节点筛选" N1["Node 1"] --> F1{"PodFitResources?"} N2["Node 2"] --> F2{"PodFitResources?"} N3["Node 3"] --> F3{"PodFitResources?"} N4["Node 4"] --> F4{"PodFitResources?"} F1 -->|通过| S1["加入候选列表"] F2 -->|拒绝| X1["排除"] F3 -->|通过| S2["加入候选列表"] F4 -->|通过| S3["加入候选列表"] end subgraph "Score 阶段 - 节点打分" CANDIDATES["候选节点<br/>Node 1, 3, 4"] CANDIDATES --> SCORE1["Node 1: 60 分"] CANDIDATES --> SCORE2["Node 3: 85 分"] CANDIDATES --> SCORE3["Node 4: 45 分"] end RESULT["选择 Node 3<br/>最高分节点"] S1 --> CANDIDATES S2 --> CANDIDATES S3 --> CANDIDATES SCORE2 --> RESULT style RESULT fill:#c8e6c9 style X1 fill:#ffcdd2

1.2 自定义调度器开发示例#

接下来通过一个实际例子,演示如何开发一个自定义调度器插件。该插件的功能是:优先将标注了 app.gpu=true 的 Pod 调度到带有 GPU 标签的节点

graph TB subgraph "Pod 调度请求" POD["GPU Pod<br/>annotation: app.gpu=true"] end subgraph "Filter 阶段" F1["检查节点 GPU 标签"] GN1["GPU Node<br/>gpu.kubernetes.io=true"] GN2["GPU Node<br/>gpu.kubernetes.io=true"] CN["普通 Node<br/>无 GPU 标签"] F1 --> GN1 -->|"通过"| PASS1["候选"] F1 --> GN2 -->|"通过"| PASS2["候选"] F1 --> CN -->|"拒绝"| REJECT["排除"] end subgraph "Score 阶段" SCORE["为 GPU 节点打高分"] PASS1 -->|"100 分"| S1["Node A"] PASS2 -->|"100 分"| S2["Node B"] end RESULT["选择最优 GPU 节点"] S1 --> RESULT S2 --> RESULT POD --> F1 style POD fill:#fff9c4 style RESULT fill:#c8e6c9 style REJECT fill:#ffcdd2

创建项目结构#

使用 Kubebuilder 初始化调度器插件项目:

# 初始化项目
kubebuilder init --domain myorg.io --repo github.com/myorg/scheduler-plugins
# 创建 API(CRD)
kubebuilder create api --group scheduling --namespacedfalse --kind NodeGPU
# 创建插件代码
mkdir -p pkg/plugins/gpunode

实现 Filter 插件#

Filter 插件负责检查节点是否满足 Pod 的调度条件。这里需要检查:若 Pod 需要 GPU,则目标节点必须拥有 GPU 标签。

pkg/plugins/gpunode/gpu_filter.go
package gpunode
import (
"context"
"fmt"
"github.com/kubernetes-sigs/scheduler-plugins/pkg/apis/config/scheme"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/kubernetes/pkg/scheduler/framework"
)
const Name = "NodeGPUFilter"
// FilterArgs 定义插件配置参数
type FilterArgs struct {
// 需要 GPU 的 Pod 需要调度到哪些标签的节点
GPULabelKey string `json:"gpuLabelKey,omitempty"`
GPULabelValue string `json:"gpuLabelValue,omitempty"`
}
// Filter 实现 Filter 扩展点
type Filter struct {
args FilterArgs
}
var _ framework.FilterPlugin = &Filter{}
func (f *Filter) Name() string {
return Name
}
// Filter 检查节点是否满足调度条件
func (f *Filter) Filter(ctx context.Context, _ *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
// 获取 Pod 是否需要 GPU
needsGPU, ok := pod.Annotations["app.gpu"]
if !ok || needsGPU != "true" {
// Pod 不需要 GPU,直接通过
return nil
}
// 检查节点是否有 GPU 标签
node := nodeInfo.Node()
if node == nil {
return framework.NewStatus(framework.Unschedulable, "node is nil")
}
gpuValue, exists := node.Labels[f.args.GPULabelKey]
if !exists || gpuValue != f.args.GPULabelValue {
return framework.NewStatus(
framework.Unschedulable,
fmt.Sprintf("node %s does not have GPU label %s=%s",
node.Name, f.args.GPULabelKey, f.args.GPULabelValue),
)
}
return nil
}
// NewFilter 创建 Filter 插件实例
func NewFilter(_ context.Context, plArgs runtime.Object) (framework.Plugin, error) {
args := &FilterArgs{}
if err := scheme.Scheme.Convert(plArgs, args, nil); err != nil {
return nil, fmt.Errorf("failed to convert args: %v", err)
}
if args.GPULabelKey == "" {
args.GPULabelKey = "gpu.kubernetes.io"
}
if args.GPULabelValue == "" {
args.GPULabelValue = "true"
}
return &Filter{args: *args}, nil
}

实现 Score 插件#

在调度框架中,Filter 阶段只决定「能不能」调度,而 Score 阶段决定「哪个节点更好」。这里我们为 GPU 节点赋予更高的评分:

pkg/plugins/gpunode/gpu_score.go
package gpunode
import (
"context"
"fmt"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/kubernetes/pkg/scheduler/framework"
)
const (
// GPU 节点的基础分数
gpuNodeScore = 100
// 普通节点的分数
normalNodeScore = 50
)
// Score 实现 Score 扩展点
type Score struct {
handle framework.Handle
}
var _ framework.ScorePlugin = &Score{}
func NewScore(_ context.Context, _ runtime.Object, h framework.Handle) (framework.Plugin, error) {
return &Score{handle: h}, nil
}
func (s *Score) Name() string {
return Name
}
// Score 对节点进行评分
// 注意:Score 函数接收的是节点名称,需要通过 handle 获取节点信息
func (s *Score) Score(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) {
// 通过 handle 获取节点信息
nodeInfo, err := s.handle.SnapshotSharedLister().NodeInfos().Get(nodeName)
if err != nil {
return 0, framework.NewStatus(framework.Error, fmt.Sprintf("failed to get node %s: %v", nodeName, err))
}
node := nodeInfo.Node()
if node == nil {
return 0, framework.NewStatus(framework.Error, "node is nil")
}
// 检查是否为 GPU 节点
needsGPU, _ := pod.Annotations["app.gpu"]
if needsGPU == "true" {
if gpuValue, ok := node.Labels["gpu.kubernetes.io"]; ok && gpuValue == "true" {
return gpuNodeScore, nil
}
return 1, nil // GPU Pod 但节点无 GPU,给最低分
}
return normalNodeScore, nil
}
// ScoreExtensions 返回分数的扩展信息
func (s *Score) ScoreExtensions() framework.ScoreExtensions {
return nil
}

注册插件#

插件编写完成后,需要在调度器启动时注册。调度框架通过插件配置文件(KubeSchedulerProfile)加载插件:

scheduler-config.yaml
# KubeSchedulerConfiguration 的 apiVersion 历经 v1alpha1→v1beta1→v1beta2→v1beta3→v1 的演进。
# 1.25 起 v1 正式 GA,1.26 起推荐使用 v1,v1beta3 在 1.26 标记弃用、1.29 移除。
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler
pluginConfig:
- name: NodeGPUFilter
args:
gpuLabelKey: gpu.kubernetes.io
gpuLabelValue: "true"

启动调度器时指定配置文件:

kube-scheduler --config scheduler-config.yaml --authentication-kubeconfig=/path/to/kubeconfig --authorization-kubeconfig=/path/to/kubeconfig

使用自定义调度器#

Pod 可以通过 schedulerName 字段指定使用哪个调度器:

apiVersion: v1
kind: Pod
metadata:
name: gpu-pod
annotations:
app.gpu: "true" # 触发自定义调度逻辑
spec:
schedulerName: default-scheduler
containers:
- name: cuda-container
image: nvidia/cuda:11.0-base
command: ["nvidia-smi"]

1.3 调度框架的高级特性#

多个调度器并存#

Kubernetes 支持集群中运行多个调度器实例,每个调度器可以有不同的配置。通过 schedulerName 字段,Pod 可以选择使用哪个调度器。如果不指定,默认使用 default-scheduler

graph TB subgraph "API Server" API["API Server"] end subgraph "调度器实例" DS["default-scheduler<br/>默认调度器"] GS["gpu-scheduler<br/>GPU 专用调度器"] BS["batch-scheduler<br/>批处理调度器"] end subgraph "Pod 部署" P1["普通 Pod<br/>schedulerName: default-scheduler"] P2["GPU Pod<br/>schedulerName: gpu-scheduler"] P3["Batch Job<br/>schedulerName: batch-scheduler"] end API -->|"监听 Pod"| DS API -->|"监听 Pod"| GS API -->|"监听 Pod"| BS DS -->|"调度"| P1 GS -->|"调度"| P2 BS -->|"调度"| P3 style DS fill:#bbdefb style GS fill:#c8e6c9 style BS fill:#fff9c4

多个调度器的典型部署场景是:为训练任务和在线服务分别配置调度器,训练任务使用 Bin-Packing 策略优先利用 GPU 节点资源,在线服务使用 Spread 策略保证高可用。

队列排序(QueueSort)#

调度队列中的 Pod 等待调度时,通过 QueueSort 扩展点决定哪个 Pod 先被调度。默认实现按 Pod 的创建时间排序,但可以通过实现自定义 QueueSort 插件来改变调度顺序,例如优先调度关键业务 Pod。

// QueueSort 实现示例:优先调度标注了 priorityClassName 的 Pod
type PriorityQueueSort struct{}
func (p *PriorityQueueSort) Less(podInfo1, podInfo2 *framework.QueuedPodInfo) bool {
p1 := getPriority(podInfo1.Pod)
p2 := getPriority(podInfo2.Pod)
if p1 != p2 {
return p1 > p2 // 更高优先级的 Pod 先调度
}
return podInfo1.Timestamp.Before(podInfo2.Timestamp)
}

预留(Reserve)与 Permit#

Reserve 扩展点在 Pod 被绑定前被调用,常用于需要「预占」资源的插件。最典型的场景是 CSI 存储卷的挂载,在 Pod 绑定到节点之前,调度器需要确保该节点能够访问 PVC 对应的存储卷。

当 StorageClass 的 volumeBindingMode 设置为 WaitForFirstConsumer 时,调度器会延迟 PV 的创建和绑定,直到 Pod 被调度到具体节点。这种设计使得存储卷可以与 Pod 的调度决策协同工作,避免存储卷被创建在不合适的节点上。CSI 存储插件的完整实现请参阅 Kubernetes CSI 存储插件开发

Permit 扩展点可以暂停或拒绝调度结果。当需要等待某个外部条件满足时(例如等待存储卷完成绑定),可以在 Permit 阶段返回 Wait 或 Deny:

func (p *MyPermit) Permit(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (*framework.Status, time.Duration) {
// 检查存储是否就绪
if !storageReady(pod) {
// 返回 Wait 并指定等待时间,调度器会在超时后重新评估
return framework.NewStatus(framework.Wait), 30 * time.Second
}
// 返回 Approval 批准调度,继续进入 Bind 阶段
return framework.NewStatus(framework.Success), 0
}

二、设备插件与扩展资源:GPU 等硬件怎么调度#

前面讲的调度都基于 CPU、内存这些内置资源。但 GPU、RDMA 网卡、FPGA 这类硬件没法用内置资源描述,Kubernetes 用设备插件(Device Plugin)加扩展资源(Extended Resource)的机制把它们接入调度。

2.1 设备插件的作用#

设备插件是一个跑在每个节点上的 gRPC 服务,负责向 kubelet 上报本节点的硬件设备清单。以 GPU 为例,NVIDIA 提供的 nvidia-device-plugin 以 DaemonSet 方式部署,它扫描节点上的 GPU 数量,通过 gRPC 向 kubelet 注册。

flowchart LR DP["设备插件 DaemonSet<br/>nvidia-device-plugin"] -->|"gRPC ListDevices()"| KL["kubelet"] KL -->|"更新 NodeStatus.Capacity"| API["API Server"] API --> KS["kube-scheduler<br/>读 Node Allocatable"] style DP fill:#c8e6c9,stroke:#2e7d32 style KL fill:#bbdefb,stroke:#1565c0

kubelet 收到设备列表后,把设备数量作为扩展资源更新到 Node 对象的 CapacityAllocatable 字段。扩展资源的命名遵循 <vendor>/<resource> 格式,比如 nvidia.com/gpuintel.com/rdma。注册完成后,kubectl describe node 就能看到节点上多了对应的资源项。

2.2 Pod 申请扩展资源#

Pod 通过 resources.limits 申请扩展资源,写法和申请 CPU/内存一样:

apiVersion: v1
kind: Pod
metadata:
name: gpu-task
spec:
containers:
- name: trainer
image: nvidia/cuda:12.0-base
resources:
limits:
nvidia.com/gpu: 1 # 申请 1 块 GPU
requests:
nvidia.com/gpu: 1 # 扩展资源必须 limits 和 requests 一致
# 让 Pod 调度到有 GPU 的节点
nodeSelector:
accelerator: nvidia

调度器在 Filter 阶段把扩展资源当作普通资源过滤节点:节点的 Allocatable 减去已分配量,得够 1 块 GPU 才允许 Pod 调度到该节点。这把 GPU 调度纳入了标准调度流程,不需要 1.2 节那种靠节点标签的”伪 GPU 调度”。

Note

扩展资源有一条规则:requestslimits 必须相等,不能只设 limits 不设 requests,也不能设成不同值。原因是扩展资源是离散可数的整数(几块 GPU、几张网卡),不像 CPU 那样能超分。设备插件的分配也是整数粒度,kubelet 一次把一整块设备分配给一个容器。

2.3 设备分配与环境变量#

Pod 调度到节点并启动容器时,kubelet 通过设备插件的 Allocate 接口拿到这块设备对应的运行时配置(设备文件路径、环境变量、挂载点),注入到容器里。对 NVIDIA GPU,注入的是 NVIDIA_VISIBLE_DEVICES 环境变量和 /dev/nvidia* 设备文件。

这套机制把硬件的”发现、上报、调度、分配”全串起来了:设备插件负责发现和分配,扩展资源负责调度。开发者不用改 kubelet 和调度器,只要写一个实现 gRPC 接口的设备插件,硬件就能进 K8s 调度视野。

Tip

为什么前面 1.2 节的自定义调度器用节点标签(gpu.kubernetes.io=true)做 GPU 调度,而不是用扩展资源?因为那是调度框架开发的示例,重点演示 Filter/Score 插件怎么写,刻意用了简单的标签匹配。真实 GPU 调度应该用设备插件加扩展资源,它能让调度器精确知道每块 GPU 被谁占用,而不是只知道”这个节点有没有 GPU 标签”。

2.4 虚拟机资源:KubeVirt 把 VM 变成 K8s 工作负载#

回到”如何通过 K8s 申请管理虚拟机资源”这个问题。K8s 原生管的是容器,不是虚拟机,但 KubeVirt 这个项目把虚拟机也建模成 K8s 资源,让 VM 能像 Pod 一样被声明、调度、运维。

KubeVirt 引入 VirtualMachineVirtualMachineInstance 两个 CRD,用 YAML 描述虚拟机的 CPU、内存、磁盘、网络,控制器在目标节点上拉起一个 libvirtd 进程跑这台 VM。调度仍然走 kube-scheduler:VM 的 requests/limits 声明在 CRD 里,调度器按节点 Allocatable 过滤,和调普通 Pod 一个流程。

apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
name: my-vm
spec:
template:
spec:
domain:
cpu:
cores: 2
resources:
requests:
memory: 4Gi
volumes:
- name: rootdisk
containerDisk:
image: kubevirt/fedora-cloud-container-disk-demo

需要 PCI 直通(把宿主机的物理设备整块给 VM 用)时,配合 kubevirt/device-plugins 的 PCI 设备插件,用 VFIO 框架把设备从宿主机解绑再透传进 VM。这本质还是设备插件加扩展资源那套机制,只是分配对象从容器变成了 VM。

把 VM 纳入 K8s 的意义在于统一运维:容器和虚拟机用同一套声明式 API、同一个调度器、同一套监控告警,不再为 VM 单独维护一套 OpenStack。代价是 KubeVirt 的成熟度和性能调优仍有门槛,重度虚拟化场景(大批量高密度 VM)目前还是专用虚拟化平台更稳。

参考资料

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Kubernetes 调度框架与自定义调度器开发
https://blog.souloss.cn/posts/kubernetes/k8s-scheduling/
作者
Souloss
发布于
2021-11-02
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时