一、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 不会被调度,绑定周期失败则调度结果会被回滚。
调度框架定义了以下扩展点,按调度周期中的调用顺序排列:
| 扩展点 | 插件接口 | 调用时机 |
|---|---|---|
QueueSort | framework.QueueSortPlugin | 对调度队列中的 Pod 进行排序,决定下一个被调度的 Pod |
PreFilter | framework.PreFilterPlugin | 预处理 Pod 的调度条件,可用于检查集群是否满足 Pod 的调度需求 |
Filter | framework.FilterPlugin | 排除不满足条件的节点,对应旧的 predicates |
PostFilter | framework.PostFilterPlugin | Filter 阶段后的后处理,当没有节点通过 Filter 时被调用 |
PreScore | framework.PreScorePlugin | 预打分阶段,可用于准备评分所需的共享资源信息 |
Score | framework.ScorePlugin | 对通过 Filter 的节点进行评分,对应旧的 priorities |
Reserve | framework.ReservePlugin | 预留插件,用于「成功绑定前」的资源预留(如 CSI 存储挂载) |
Permit | framework.PermitPlugin | 允许阶段,可用于暂停/拒绝/批准调度结果 |
Bind | framework.BindPlugin | 绑定阶段,将 Pod 绑定到目标节点 |
PostBind | framework.PostBindPlugin | 绑定完成后的后处理,可用于清理预留资源 |
1.2 自定义调度器开发示例
接下来通过一个实际例子,演示如何开发一个自定义调度器插件。该插件的功能是:优先将标注了 app.gpu=true 的 Pod 调度到带有 GPU 标签的节点。
创建项目结构
使用 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 标签。
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 节点赋予更高的评分:
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)加载插件:
# KubeSchedulerConfiguration 的 apiVersion 历经 v1alpha1→v1beta1→v1beta2→v1beta3→v1 的演进。# 1.25 起 v1 正式 GA,1.26 起推荐使用 v1,v1beta3 在 1.26 标记弃用、1.29 移除。apiVersion: kubescheduler.config.k8s.io/v1kind: KubeSchedulerConfigurationprofiles: - 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: v1kind: Podmetadata: 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。
多个调度器的典型部署场景是:为训练任务和在线服务分别配置调度器,训练任务使用 Bin-Packing 策略优先利用 GPU 节点资源,在线服务使用 Spread 策略保证高可用。
队列排序(QueueSort)
调度队列中的 Pod 等待调度时,通过 QueueSort 扩展点决定哪个 Pod 先被调度。默认实现按 Pod 的创建时间排序,但可以通过实现自定义 QueueSort 插件来改变调度顺序,例如优先调度关键业务 Pod。
// QueueSort 实现示例:优先调度标注了 priorityClassName 的 Podtype 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 注册。
kubelet 收到设备列表后,把设备数量作为扩展资源更新到 Node 对象的 Capacity 和 Allocatable 字段。扩展资源的命名遵循 <vendor>/<resource> 格式,比如 nvidia.com/gpu、intel.com/rdma。注册完成后,kubectl describe node 就能看到节点上多了对应的资源项。
2.2 Pod 申请扩展资源
Pod 通过 resources.limits 申请扩展资源,写法和申请 CPU/内存一样:
apiVersion: v1kind: Podmetadata: name: gpu-taskspec: 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 调度”。
扩展资源有一条规则:requests 和 limits 必须相等,不能只设 limits 不设 requests,也不能设成不同值。原因是扩展资源是离散可数的整数(几块 GPU、几张网卡),不像 CPU 那样能超分。设备插件的分配也是整数粒度,kubelet 一次把一整块设备分配给一个容器。
2.3 设备分配与环境变量
Pod 调度到节点并启动容器时,kubelet 通过设备插件的 Allocate 接口拿到这块设备对应的运行时配置(设备文件路径、环境变量、挂载点),注入到容器里。对 NVIDIA GPU,注入的是 NVIDIA_VISIBLE_DEVICES 环境变量和 /dev/nvidia* 设备文件。
这套机制把硬件的”发现、上报、调度、分配”全串起来了:设备插件负责发现和分配,扩展资源负责调度。开发者不用改 kubelet 和调度器,只要写一个实现 gRPC 接口的设备插件,硬件就能进 K8s 调度视野。
为什么前面 1.2 节的自定义调度器用节点标签(gpu.kubernetes.io=true)做 GPU 调度,而不是用扩展资源?因为那是调度框架开发的示例,重点演示 Filter/Score 插件怎么写,刻意用了简单的标签匹配。真实 GPU 调度应该用设备插件加扩展资源,它能让调度器精确知道每块 GPU 被谁占用,而不是只知道”这个节点有没有 GPU 标签”。
2.4 虚拟机资源:KubeVirt 把 VM 变成 K8s 工作负载
回到”如何通过 K8s 申请管理虚拟机资源”这个问题。K8s 原生管的是容器,不是虚拟机,但 KubeVirt 这个项目把虚拟机也建模成 K8s 资源,让 VM 能像 Pod 一样被声明、调度、运维。
KubeVirt 引入 VirtualMachine 和 VirtualMachineInstance 两个 CRD,用 YAML 描述虚拟机的 CPU、内存、磁盘、网络,控制器在目标节点上拉起一个 libvirtd 进程跑这台 VM。调度仍然走 kube-scheduler:VM 的 requests/limits 声明在 CRD 里,调度器按节点 Allocatable 过滤,和调普通 Pod 一个流程。
apiVersion: kubevirt.io/v1kind: VirtualMachinemetadata: name: my-vmspec: 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 官方文档 - 调度框架扩展点详解
- 设备插件 - Kubernetes 官方文档 - Device Plugin 框架与 gRPC 接口
- 扩展资源 - Kubernetes 官方文档 - Extended Resource 机制
- KubeVirt 项目 - 在 K8s 上运行虚拟机
- KubeBuilder 官方文档 - Operator 开发框架
- scheduler-plugins 社区项目 - 调度器插件集合
- Kubernetes 调度器深度解析 - 云原生社区 - 调度原理详解
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






