引言:调度器——Kubernetes的"大脑"
在Kubernetes集群中,每一个Pod的创建都要经过一个关键决策:它应该被调度到哪个节点上?这个看似简单的问题背后,涉及资源匹配、亲和性规则、拓扑约束、优先级抢占、自定义评分策略等多重复杂逻辑。默认调度器scheduler_default能处理绝大多数场景,但在AI训练、高性能计算、混合部署等细粒度场景中,仅靠默认策略远不能满足需求。本文将从调度器核心原理出发,系统讲解如何调优默认调度器,并手建一个生产可用级别的自定义调度器。
一、调度器核心架构拆解
1.1 调度循环:两个阶段的精密协作
Kubernetes调度器的核心设计是"过滤+打分"两阶段循环:
// scheduling cycle 核心逻辑
func (sched *Scheduler) scheduleOne(ctx context.Context) {
// 1. 从待调度队列获取Pod
pod := sched.NextPod()
// 2. 调度阶段(Scheduling Cycle)
scheduleResult, err := sched.SchedulePod(ctx, pod)
// 3. 绑定阶段(Binding Cycle)
go func() {
// 异步执行绑定
sched.BindPod(pod, scheduleResult.SuggestedHost)
}()
}
调度阶段(Scheduling Cycle)是同步执行的核心,包含:
- Predicates(预选):过滤不满足硬性条件的节点(资源不足、节点污点、Pod亲和性冲突等)。从v1.15起已从独立AlgorithmProvider转为FilterPlugin集合
- Priorities(优选):为通过预选的节点打分(LeastRequestedPriority、BalancedAllocation等)
- Scoring(新评分框架):v1.23起分离为ScorePlugin,支持灵活配置权重
- Preempt(抢占):低优先级Pod驱逐以腾出空间给高优先级Pod(如果开启了Preemption)
绑定阶段(Binding Cycle)异步执行PreBind→Bind→PostBind插件链,通过API Server将节点分配写入Pod的spec.nodeName字段。
1.2 调度框架(Scheduling Framework)
自v1.16引入的Scheduling Framework将调度流程拆分为可扩展的Plugin插槽(Extension Points):
| 插槽阶段 | Plugin类型 | 作用 |
|---|---|---|
| QueueSort | QueueSortPlugin | 定义待调度队列排序(默认FIFO) |
| PreFilter | PreFilterPlugin | 预计算Pod信息(如缓存节点资源快照) |
| Filter | FilterPlugin | 硬性条件过滤(类似旧Predicates) |
| PreScore | PreScorePlugin | 评分前预处理 |
| Scoring | ScorePlugin | 节点评分(可配置权重) |
| Reserve | ReservePlugin | 资源预留(调度成功后锁定) |
| Permit | PermitPlugin | 延迟绑定决策(Approve/Wait/Deny) |
| PreBind | PreBindPlugin | 绑定前准备(如PVC创建) |
| Bind | BindPlugin | 执行节点绑定(默认bind.go) |
| PostBind | PostBindPlugin | 绑定后清理 |
| Unreserve | UnreservePlugin | 调度失败时释放预留资源 |
二、默认调度器的关键Plugin与源码解析
2.1 NodeResourcesFit(资源匹配)
这是最常用的Filter+Score Plugin,核心逻辑在pkg/scheduler/framework/plugins/noderesources/fit.go中:
// Fit Filter: 检查节点是否有足够的CPU/Memory/Misc资源
func (f *Fit) Filter(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
inadequateResources := fitsRequest(pod, nodeInfo, f.ignoredResources, f.ignoredResourceGroups)
if len(inadequateResources) != 0 {
return framework.NewStatus(framework.Unschedulable, msg)
}
return nil
}
// Exact Scoring策略:LeastRequested / MostRequested / RequestedToCapacityRatio
func (f *Fit) Score(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) {
nodeInfo := f.infoProducer.GetNodeInfo(nodeName)
return f.calculateScore(pod, nodeInfo), nil
}
2.2 NodeAffinity / PodAffinity(亲和性调度)
亲和性的两阶段设计值得注意——requiredDuringSchedulingIgnoredDuringExecution(硬约束)在Filter阶段执行,而preferredDuringSchedulingIgnoredDuringExecution(软约束)则在Scoring阶段通过权重累加。源码中,PreFilter阶段构建一个NodeAffinityLister快速查询模式避免O(n*m)性能问题。
2.3 默认Profile配置
通过KubeSchedulerConfiguration的profiles.plugins可以灵活启用/禁用/加权:
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler
plugins:
score:
disabled:
- name: NodeResourcesLeastRequested
enabled:
- name: NodeResourcesBalancedAllocation
weight: 2
filter:
enabled:
- name: NodePorts
- name: NodeResourcesFit
- name: NodeUnschedulable
pluginConfig:
- name: NodeResourcesFit
args:
scoringStrategy:
type: RequestedToCapacityRatio
requestedToCapacityRatioParams:
shape:
- utilization: 0
score: 0
- utilization: 50
score: 5
- utilization: 100
score: 10
三、自定义调度器实战:AI训练场景的GPU感知调度
3.1 为什么需要自定义调度器
在AI训练集群中,默认调度器无法感知:
- GPU显存分布(两张24GB卡 vs 一张48GB卡的利用率差异)
- NVLink/PCIe拓扑(跨GPU通信带宽对AllReduce性能影响翻倍)
- 训练任务的弹性扩缩容需求(抢占式实例干扰下的任务迁移)
- GPU碎片管理(Bin-Packing与Spread策略的权衡)
3.2 自定义调度器核心实现
基于scheduling-framework库(k8s.io/kubernetes/pkg/scheduler/framework)构建自定义调度器:
第一步:注册自定义Plugin
func main() {
registry := schedframework.NewRegistry()
registry.Register("GPUNodeFit", NewGPUNodeFit)
registry.Register("GPUTopologyScore", NewGPUTopologyScore)
registry.Register("FragmentAwareScore", NewFragmentAwareScore)
profile := schedframework.NewProfile(
"gpu-aware-scheduler",
"default-scheduler",
registry,
)
sched := scheduler.New(
scheduler.WithProfiles(profile),
scheduler.WithPercentageOfNodesToScore(30),
scheduler.WithParallelism(16),
)
sched.Run(context.Background())
}
第二步:GPU显存精确过滤(GPUNodeFit Plugin)
type GPUNodeFit struct{}
func NewGPUNodeFit(obj runtime.Object, handle framework.Handle) (framework.Plugin, error) {
return &GPUNodeFit{}, nil
}
func (p *GPUNodeFit) Name() string { return "GPUNodeFit" }
func (p *GPUNodeFit) Filter(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
gpuMemoryReq := getGPUMemoryRequest(pod)
if gpuMemoryReq == 0 {
return nil
}
node := nodeInfo.Node()
allocatableGPU := getAllocatableGPUMemory(node)
requestedGPU := getRequestedGPUMemory(nodeInfo)
availableGPU := allocatableGPU - requestedGPU
if availableGPU < gpuMemoryReq {
return framework.NewStatus(framework.Unschedulable,
fmt.Sprintf("insufficient GPU memory: need=%d, available=%d", gpuMemoryReq, availableGPU))
}
return nil
}
func getGPUMemoryRequest(pod *v1.Pod) int64 {
if val, ok := pod.Annotations["gpu.ybb.press/memory-request"]; ok {
mem, _ := strconv.ParseInt(val, 10, 64)
return mem
}
return 0
}
第三步:NVLink拓扑感知评分(GPUTopologyScore Plugin)
type GPUTopologyScore struct {
topologyCache *GPUTopologyCache
}
type GPUNetworkTopology struct {
NodeName string
GPUs []GPUInfo
NVLinks map[string][]string
PCIeSwitch map[string][]string
}
type GPUInfo struct {
UUID string
Memory int64
PCIeBusID string
LinkWidth int
FabricID string
}
func (p *GPUTopologyScore) Score(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) {
gpuCount := getGPUCount(pod)
if gpuCount <= 1 {
return 50, nil
}
topology := p.topologyCache.Get(nodeName)
if topology == nil {
return 50, nil
}
bestGroup := topology.FindLargestFabricGroup()
if len(bestGroup) >= int(gpuCount) {
return 100, nil
}
if topology.FitsOnSingleSwitch(int(gpuCount)) {
return 70, nil
}
return 30, nil
}
第四步:GPU碎片感知评分(FragmentAwareScore)
func (p *FragmentAwareScore) Score(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) {
info := f.infoProducer.GetNodeInfo(nodeName)
node := info.Node()
fragmentCount := calculateFragment(info)
newFragment := calculateFragmentAfter(info, pod)
if newFragment < fragmentCount {
return 100, nil
} else if newFragment == fragmentCount {
return 50, nil
} else {
return 20, nil
}
}
3.3 Pod级别Selector:使用自定义调度器
通过spec.schedulerName指定使用自定义调度器:
apiVersion: v1
kind: Pod
metadata:
name: ai-training-node-0
namespace: ml-pipeline
annotations:
gpu.ybb.press/memory-request: "16384"
gpu.ybb.press/pod-count: "4"
spec:
schedulerName: gpu-aware-scheduler
containers:
- name: pytorch-trainer
image: nvcr.io/nvidia/pytorch:25.03-py3
resources:
limits:
nvidia.com/gpu: 4
requests:
nvidia.com/gpu: 4
env:
- name: NCCL_IB_DISABLE
value: "0"
nodeSelector:
node-type: gpu-a100
四、高级特性:动态资源共享与弹性抢占
4.1 动态NodeResources配额
Kubernetes的Node.Status.Allocatable是静态的。在高性能集群中,需要动态调整可分配资源以支持超卖(Overcommit)。这可以通过Validating/Mutating Admission Webhook在Pod创建时注入动态资源限额。
4.2 弹性抢占策略
Kubernetes默认抢占策略是纯优先级模式。在生产环境中,更好的方式是"效用抢占":
func preemptDecision(highPod, candidatePods) []PreemptionTarget {
costs := map[string]float64{}
for _, low := range candidatePods {
costs[low.Name] = calculatePreemptionCost(low)
}
targets := knapsackSelect(costs, needed)
return targets
}
五、生产落地的十大最佳实践
- 合理设置Requests/Limits比:Burst类Pod的Limit可设为Request的2-3倍提升集群利用率
- 禁用不必要的打分插件:关闭ImageLocality、HostPriority等场景不需要的Plugin降低调度延迟
- 控制PercentageOfNodesToScore:千级节点建议设为10-20%,避免对所有节点执行全量评分
- 优先级抢占配合配额管理:ResourceQuota限制低优先级Pod数量,防止抢占风暴
- 拓扑域合理规划:设置合适的拓扑约束粒度(hostname < rack < zone)
- 调度器性能调参:parallelism(默认16)因集群规模调整,大规模集群可设为32+
- Webhook超时控制:自定义Permit/Webhook的超时必须小于调度器默认10秒
- Pod队列优先级配合抢占:PriorityClass设置合理的分层阈值(100/1000/10000三级)
- GPU碎片监控:建立可视化Dashboard跟踪GPU碎片率,超过15%触发告警
- 灰度上线策略:新调度策略先在影子模式(Shadow Mode)运行对比,确认无误后再切流
结语:调度器是平台工程的基石
Kubernetes调度器的深度定制是迈向"平台工程"(Platform Engineering)的核心能力之一。无论是默认Plugin的精细调参,还是基于框架的定制Plugin开发,本质目标都是让集群资源与实际业务需求精确匹配。在AI工程师价格昂贵的2026年,一个优秀的GPU感知调度器可以直接降低30%以上的GPU闲置成本。

发表评论 取消回复