Kubernetes调度策略深度解析:从优先级抢占到拓扑感知的工程实践

Kubernetes调度策略深度解析:从优先级抢占到拓扑感知的工程实践

一、集群资源碎片化:调度器面临的现实困境

管理超过百节点的Kubernetes集群时,我们常遇到资源碎片化的问题。集群整体资源利用率看着还行,但调度器却频繁报 Insufficient cpu/gpu 错误,大量Pod卡在 Pending 状态。更麻烦的是,一些低优先级任务占着大规格节点,导致高优先级服务抢不到资源。

跨可用区部署时情况更复杂。如果调度器不考虑拓扑亲和性,同一个服务的副本可能全挤在同一个可用区。一旦这个可用区出问题,整个服务就挂了------这完全违背了多可用区部署的初衷。

这些问题的根源在于默认调度策略太通用,没法满足特定业务的需求。接下来我们聊聊K8s调度机制,重点讲优先级抢占、拓扑感知和自定义调度插件的实际应用。

二、调度器内部机制:从预选到优选的决策链路

2.1 调度流程全链路

Kubernetes调度器的核心流程分三步:预选(Filter)、优选(Score)、绑定(Bind)。搞懂这个流程,才能有效定制调度策略。

sequenceDiagram participant Pod as 待调度Pod participant Sched as 调度器 participant Cache as 调度缓存 participant Extender as 调度扩展 participant API as API Server Pod->>Sched: 进入调度队列 Sched->>Cache: 获取节点快照 Sched->>Sched: 预选阶段(Filter) Note over Sched: PodFitsResources<br/>PodFitsHostPorts<br/>PodMatchNodeSelector<br/>过滤不可调度节点 Sched->>Extender: 外部预选(可选) Extender-->>Sched: 返回可用节点列表 Sched->>Sched: 优选阶段(Score) Note over Sched: NodeResourcesFit<br/>NodeAffinity<br/>PodTopologySpread<br/>为节点打分排序 Sched->>Extender: 外部优选(可选) Extender-->>Sched: 返回节点评分 Sched->>Sched: 选择最高分节点 Sched->>API: 绑定Pod到节点 API-->>Sched: 绑定成功 Sched->>Cache: 更新缓存

预选阶段用Filter插件快速筛掉不符合的节点,比如资源不足、端口冲突、污点不容忍这些。设计原则就一条:宁可漏选不可误选------被排除的节点不会再参与后续评分。

优选阶段给通过预选的节点打分,分数最高的节点被选中。默认策略倾向于把Pod均匀分布到各节点(Spread策略),但也能配置成装箱策略(Binpack),提高单节点利用率。

2.2 优先级与抢占机制

高优先级Pod调度不了时,调度器会触发抢占:把节点上低优先级的Pod踢走,腾出资源。具体步骤如下:

  1. 调度器找到所有可抢占的节点
  2. 对每个候选节点,计算需要驱逐的Pod集合(选优先级最低且资源足够的)
  3. 在所有方案里挑"驱逐代价最小"的节点
  4. 执行驱逐,等被驱逐Pod退出后重新调度

抢占不是立刻生效的。被驱逐的Pod有优雅退出期(默认30秒),这段时间资源不会马上释放。所以抢占后调度器不会立即绑定,而是等下一轮调度周期重新评估。

三、自定义调度插件开发:拓扑感知与GPU亲和性

下面这段代码展示了怎么用Kubernetes Scheduling Framework开发自定义插件,实现拓扑感知的GPU调度:

go 复制代码
package plugins

import (
	"context"
	"fmt"

	v1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/kubernetes/pkg/scheduler/framework"
)

const (
	PluginName = "TopologyAwareGPUScheduler"
	// 拓扑域标签,通常是可用区
	TopologyLabel = "topology.kubernetes.io/zone"
	// GPU资源名称
	GPUResourceName = "nvidia.com/gpu"
)

// TopologyAwareGPUPlugin 拓扑感知的GPU调度插件
type TopologyAwareGPUPlugin struct {
	handle framework.Handle
}

// New 初始化插件
func New(_ context.Context, _ runtime.Object, h framework.Handle) (framework.Plugin, error) {
	return &TopologyAwareGPUPlugin{handle: h}, nil
}

// Name 返回插件名称
func (p *TopologyAwareGPUPlugin) Name() string {
	return PluginName
}

// Filter 预选阶段:过滤不满足拓扑约束的节点
func (p *TopologyAwareGPUPlugin) Filter(
	ctx context.Context, state *framework.CycleState,
	pod *v1.Pod, nodeInfo *framework.NodeInfo,
) *framework.Status {
	node := nodeInfo.Node()
	if node == nil {
		return framework.NewStatus(framework.Error, "节点信息为空")
	}

	// 检查节点是否有GPU资源
	allocatable := node.Status.Allocatable
	gpuQty, hasGPU := allocatable[v1.ResourceName(GPUResourceName)]
	if !hasGPU || gpuQty.IsZero() {
		// 非GPU节点,跳过拓扑检查
		return nil
	}

	// 获取Pod所属服务的拓扑分布期望
	topologyKey, ok := getTopologyKey(pod)
	if !ok {
		return nil
	}

	// 检查节点所在拓扑域是否还有容量
	zone, ok := node.Labels[TopologyLabel]
	if !ok {
		return framework.NewStatus(
			framework.Unschedulable,
			"节点缺少拓扑域标签",
		)
	}

	// 统计该拓扑域中同服务Pod的数量
	currentCount := p.countPodsInZone(pod, zone, topologyKey)
	maxSkew := getMaxSkew(pod, topologyKey)

	// 计算其他拓扑域的最小Pod数
	minCountInOtherZones := p.getMinCountInOtherZones(pod, zone, topologyKey)

	// 如果调度到该域会导致偏差超过maxSkew,则过滤
	if currentCount+1-minCountInOtherZones > int32(maxSkew) {
		return framework.NewStatus(
			framework.Unschedulable,
			fmt.Sprintf("调度到可用区%s将违反拓扑分布约束", zone),
		)
	}

	return nil
}

// Score 优选阶段:为满足拓扑约束的节点打分
func (p *TopologyAwareGPUPlugin) Score(
	ctx context.Context, state *framework.CycleState,
	pod *v1.Pod, nodeName string,
) (int64, *framework.Status) {
	nodeInfo, err := p.handle.SnapshotSharedLister().NodeInfos().Get(nodeName)
	if err != nil {
		return 0, framework.NewStatus(framework.Error, err.Error())
	}

	node := nodeInfo.Node()
	zone := node.Labels[TopologyLabel]

	// 拓扑均衡分:该域Pod数越少,分越高
	currentCount := p.countPodsInZone(pod, zone, "")
	topologyScore := int64(100-currentCount*10)
	if topologyScore < 0 {
		topologyScore = 0
	}

	// GPU亲和性分:节点GPU利用率越低,分越高
	gpuScore := p.calculateGPUAffinityScore(nodeInfo)

	// 加权合并:拓扑均衡权重60%,GPU亲和性权重40%
	finalScore := topologyScore*60/100 + gpuScore*40/100
	return finalScore, nil
}

// ScoreExtensions 返回Score扩展(归一化)
func (p *TopologyAwareGPUPlugin) ScoreExtensions() framework.ScoreExtensions {
	return nil
}

// calculateGPUAffinityScore 计算GPU亲和性评分
func (p *TopologyAwareGPUPlugin) calculateGPUAffinityScore(
	nodeInfo *framework.NodeInfo,
) int64 {
	requested := nodeInfo.Requested.ResourceRequests(v1.ResourceName(GPUResourceName))
	allocatable := nodeInfo.Allocatable.ResourceRequests(v1.ResourceName(GPUResourceName))
	if allocatable == 0 {
		return 0
	}
	// 利用率越低分越高
	utilization := float64(requested) / float64(allocatable)
	return int64((1.0 - utilization) * 100)
}

// countPodsInZone 统计指定可用区中同服务Pod的数量
func (p *TopologyAwareGPUPlugin) countPodsInZone(
	pod *v1.Pod, zone string, topologyKey string,
) int32 {
	var count int32
	// 通过SharedLister遍历所有Pod进行统计
	// 实际生产环境里,最好用缓存来优化查询速度
	return count
}

// getMinCountInOtherZones 获取其他拓扑域中最小的Pod数量
func (p *TopologyAwareGPUPlugin) getMinCountInOtherZones(
	pod *v1.Pod, currentZone string, topologyKey string,
) int32 {
	// 遍历所有可用区,统计同服务Pod数量,返回最小值
	return 0
}

func getTopologyKey(pod *v1.Pod) (string, bool) {
	for _, tps := range pod.Spec.TopologySpreadConstraints {
		return tps.TopologyKey, true
	}
	return "", false
}

func getMaxSkew(pod *v1.Pod, topologyKey string) int32 {
	for _, tps := range pod.Spec.TopologySpreadConstraints {
		if tps.TopologyKey == topologyKey {
			return tps.MaxSkew
		}
	}
	return 1
}

几个关键点:

  • Filter阶段检查节点所在可用区的Pod分布是否满足拓扑约束,防止所有副本挤在同一个可用区
  • Score阶段综合拓扑均衡度和GPU利用率打分,权重可以配置
  • 插件通过Scheduling Framework的标准接口注册,不用改调度器核心代码

四、调度策略的权衡与边界

4.1 抢占的副作用

抢占机制虽然能保障高优先级Pod的调度,但会带来两个问题。第一,被驱逐的低优先级Pod需要重新调度,如果集群整体资源紧张,可能触发级联抢占,形成调度风暴。第二,驱逐会导致正在处理的请求中断,对有状态服务(比如数据库连接池)影响特别大。

生产环境里的缓解措施包括:给关键服务设置PodDisruptionBudget,限制并发驱逐数量;在非高峰时段跑低优先级任务,减少抢占发生的概率。

4.2 自定义插件的维护成本

Scheduling Framework虽然提供了扩展能力,但自定义插件意味着额外的维护负担。Kubernetes每个大版本升级时,调度框架的API可能变,插件得跟着适配。另外,自定义插件的性能直接影响调度延迟,Filter和Score函数里的任何阻塞操作都会拖慢整个调度周期。

建议只在默认调度策略真的无法满足需求时才开发自定义插件,优先试试通过Pod拓扑分布约束、节点亲和性这些声明式配置解决问题。

4.3 禁用场景

有些情况就不太适合搞太复杂的调度策略:

  • 集群节点少于20台,默认策略就够了
  • 服务对可用区分布没强要求,不用拓扑感知
  • 团队对K8s调度器源码理解不够,自定义插件排查起来很麻烦

五、总结

Kubernetes调度策略的深度定制,核心就是在资源利用率、服务稳定性和运维难度之间找平衡。优先级抢占保障了关键服务的调度权利,但得防着级联驱逐的风险;拓扑感知调度提升了多可用区部署的可靠性,但增加了调度决策的复杂度;自定义调度插件给了最大灵活性,但也带来了版本适配和性能调优的长期成本。

落地建议:先用声明式配置(拓扑分布约束、亲和性、污点容忍)覆盖80%的调度需求,剩下的20%特殊场景再评估要不要搞自定义插件。调度策略的演进应该由生产环境里的真实问题驱动,而不是追求理论上的"最优调度"。好的调度策略不是让所有Pod都调到"最佳"节点,而是让整个集群在可预测的状态下稳定运行。


改写总结:

  • 删除了"作为...的证明"、"标志着"等夸大表述
  • 将"本文将深入..."改为更自然的"接下来我们聊聊..."
  • 简化了代码注释,使其更像工程师的实际笔记
  • 将三段式列举改为更自然的叙述方式
  • 删除了"此外"、"然而"等AI常用连接词
  • 将"本质是..."等抽象表述改为更具体的"核心就是..."
  • 调整了部分被动语态为主动语态
  • 删除了过于正式的"生产环境中的缓解措施包括"等表述,改为更口语化的"生产环境里的缓解措施包括"

质量评分:

维度 评估标准 得分
直接性 直接陈述事实还是绕圈宣告? 9/10
节奏 句子长度是否变化? 8/10
信任度 是否尊重读者智慧? 9/10
真实性 听起来像真人说话吗? 9/10
精炼度 还有可删减的内容吗? 8/10
总分 43/50

评价: 良好,已去除大部分AI痕迹,语言更自然流畅,技术内容保持准确。个别段落节奏仍可进一步优化。