资源请求与实际消耗的鸿沟:Kubernetes 调度策略的深度调优

资源请求与实际消耗的鸿沟:Kubernetes 调度策略的深度调优

一、资源请求失真与调度效率的恶性循环

Kubernetes 集群里有个挺常见但容易被忽略的问题:Pod 的资源请求(Requests)和实际消耗对不上号。开发者写 Deployment 时总爱往高了估------CPU 多申请 50%,内存多申请 100%,就为了防着 OOM Killed。结果呢?集群调度效率直线下降:节点实际负载才 40%,调度器却觉得已经满了,新 Pod 根本排不上队。

更麻烦的是,这种偏差会越滚越大。开发者发现 Pod 老是被驱逐或调度不上,索性继续调高请求值,结果更多节点资源被"纸上占用",实际却空着。有个 200 节点的生产集群做过统计,Prometheus 数据显示 CPU 请求总量是实际使用量的 2.3 倍,内存请求总量是 1.8 倍。算下来,超过一半的集群算力都浪费在冗余请求里了。

还有个问题:Kubernetes 默认调度器的打分策略(NodeResourcesFit)喜欢把 Pod 均匀撒到所有节点。这种"最分散优先"的思路,从资源利用率角度看其实不太划算。结果每个节点都半死不活------既腾不出整节点给大规格 Pod,也省不下节点做缩容。

二、调度器决策链路:从预选到打分的全流程解析

想优化调度效率,得先搞清楚 Kubernetes 调度器是怎么做决定的。整个过程分两步:预选(Filter)和打分(Score)。预选阶段筛掉不符合硬性条件的节点,打分阶段给剩下的节点排个序,挑个最优的。

sequenceDiagram participant P as Pod 入队 participant S as 调度器 participant F as Filter 预选 participant SC as Score 打分 participant N as Node 绑定 P->>S: Pod 进入调度队列 S->>F: 执行预选过滤 Note over F: 检查项:<br/>1. 节点资源是否满足 Requests<br/>2. 节点是否有污点与容忍匹配<br/>3. 亲和性/反亲和性规则<br/>4. PVC 卷拓扑约束 F->>SC: 返回可行节点列表 Note over SC: 打分策略(可插拔):<br/>1. NodeResourcesFit:资源均衡<br/>2. NodeAffinity:节点亲和<br/>3. PodTopologySpread:拓扑打散<br/>4. ImageLocality:镜像本地性<br/>5. 自定义插件:业务感知打分 SC->>S: 返回最优节点 S->>N: 执行 Bind 操作 Note over N: 绑定后:<br/>1. kubelet 启动容器<br/>2. 更新节点已分配资源<br/>3. 触发资源监控指标 N-->>S: 绑定结果回调

重点在 Score 阶段------这是我们可以插自定义插件的地方。默认的 NodeResourcesFitLeastAllocated 算法,优先挑资源分配率最低的节点。安全上没问题,但资源效率确实差了点意思。

LeastAllocated 的坑:假设集群有两个节点,Node A 的 CPU 已分配 30%,Node B 的 CPU 已分配 60%。来个请求 2 核的 Pod,LeastAllocated 会选 Node A。结果两个节点都卡在中等负载,既形不成"热节点+冷节点"的分布,冷节点也没法缩容。

MostAllocated 的局限 :反着来,MostAllocated 优先挑已分配率高的节点,能集中负载。但它有个致命伤------大规格 Pod 来了,高负载节点可能塞不下,直接调度失败。

所以生产环境得搞个混合策略:小规格 Pod 用 MostAllocated 集中负载,大规格 Pod 用 LeastAllocated 保调度成功率。这就是自定义调度插件要解决的核心问题。

三、基于资源画像的自适应调度插件实现

下面这段代码实现了一个自适应调度插件,根据 Pod 的资源规格动态切换打分策略,还能结合历史使用数据校准资源请求的偏差。

go 复制代码
package scheduler

import (
	"context"
	"math"

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

const (
	// PluginName 插件名称
	PluginName = "adaptive-resource-scorer"

	// LargePodThreshold 大规格 Pod 判定阈值
	// CPU 请求超过此值视为大规格 Pod
	LargePodCPUMillicores int64 = 4000 // 4 核

	// MediumLoadThreshold 节点中等负载阈值
	MediumLoadThreshold float64 = 0.6
)

// AdaptiveResourceScorer 自适应资源打分插件
type AdaptiveResourceScorer struct {
	handle       framework.Handle
	usageTracker *ResourceUsageTracker
}

// ResourceUsageTracker 资源使用率追踪器
// 基于历史指标校准资源请求偏差
type ResourceUsageTracker struct {
	// 节点维度的实际使用率缓存
	nodeUsageCache map[string]*NodeUsageMetrics
}

// NodeUsageMetrics 节点资源使用指标
type NodeUsageMetrics struct {
	CPUUsageRatio    float64 // 实际 CPU 使用率
	MemoryUsageRatio float64 // 实际内存使用率
	CPURequestRatio  float64 // CPU 请求分配率
	MemRequestRatio  float64 // 内存请求分配率
}

// PodResourceProfile Pod 资源画像
type PodResourceProfile struct {
	IsLargePod  bool
	CPURequest  int64   // 毫核
	MemRequest  int64   // 字节
	RealCPUEst  float64 // 基于历史数据估算的实际 CPU 需求
	RealMemEst  float64 // 基于历史数据估算的实际内存需求
}

// Score 自适应打分:根据 Pod 规格动态切换策略
func (s *AdaptiveResourceScorer) Score(
	ctx context.Context,
	state *framework.CycleState,
	pod *v1.Pod,
	nodeInfo *framework.NodeInfo,
) (int64, *framework.Status) {
	node := nodeInfo.Node()
	if node == nil {
		return 0, framework.NewStatus(framework.Error, "node not found")
	}

	// 构建 Pod 资源画像
	profile := s.buildPodProfile(pod)

	// 获取节点实际使用指标
	usageMetrics := s.usageTracker.GetNodeUsage(node.Name)

	// 根据画像选择打分策略
	if profile.IsLargePod {
		// 大规格 Pod:使用 LeastAllocated 策略
		// 确保有足够资源空间,避免调度失败
		return s.scoreLeastAllocated(nodeInfo, usageMetrics), nil
	}

	// 小规格 Pod:使用 MostAllocated 策略
	// 集中负载,为缩容创造条件
	return s.scoreMostAllocated(nodeInfo, usageMetrics), nil
}

// buildPodProfile 构建 Pod 资源画像
func (s *AdaptiveResourceScorer) buildPodProfile(
	pod *v1.Pod,
) *PodResourceProfile {
	profile := &PodResourceProfile{}

	// 从 Pod 容器中累加资源请求
	for _, container := range pod.Spec.Containers {
		requests := container.Resources.Requests
		if cpu, ok := requests[v1.ResourceCPU]; ok {
			profile.CPURequest += cpu.MilliValue()
		}
		if mem, ok := requests[v1.ResourceMemory]; ok {
			profile.MemRequest += mem.Value()
		}
	}

	// 判定是否为大规格 Pod
	profile.IsLargePod = profile.CPURequest >= LargePodCPUMillicores

	// 基于历史数据校准实际资源需求
	// 通过 namespace + workload 标签匹配历史使用记录
	profile.RealCPUEst = s.usageTracker.EstimateRealCPU(pod)
	profile.RealMemEst = s.usageTracker.EstimateRealMemory(pod)

	return profile
}

// scoreMostAllocated 集中负载打分
// 已分配率越高的节点得分越高
func (s *AdaptiveResourceScorer) scoreMostAllocated(
	nodeInfo *framework.NodeInfo,
	metrics *NodeUsageMetrics,
) int64 {
	allocatable := nodeInfo.Allocatable
	requested := nodeInfo.Requested

	// 计算已分配比率
	cpuFraction := float64(requested.Cpu.MilliValue()) /
		float64(allocatable.Cpu.MilliValue())
	memFraction := float64(requested.Memory.Value()) /
		float64(allocatable.Memory.Value())

	// 综合得分:已分配率越高,得分越高
	// 但如果节点实际负载已超过阈值,降低得分避免过载
	score := (cpuFraction + memFraction) / 2.0

	// 过载惩罚:实际使用率超过阈值时降低得分
	if metrics != nil {
		if metrics.CPUUsageRatio > MediumLoadThreshold {
			overloadPenalty := (metrics.CPUUsageRatio - MediumLoadThreshold) * 2.0
			score -= overloadPenalty
		}
	}

	// 归一化到 0-100
	normalizedScore := int64(math.Round(score * 100))
	if normalizedScore < 0 {
		normalizedScore = 0
	}
	if normalizedScore > 100 {
		normalizedScore = 100
	}
	return normalizedScore
}

// scoreLeastAllocated 分散负载打分
// 已分配率越低的节点得分越高
func (s *AdaptiveResourceScorer) scoreLeastAllocated(
	nodeInfo *framework.NodeInfo,
	metrics *NodeUsageMetrics,
) int64 {
	allocatable := nodeInfo.Allocatable
	requested := nodeInfo.Requested

	cpuFraction := float64(requested.Cpu.MilliValue()) /
		float64(allocatable.Cpu.MilliValue())
	memFraction := float64(requested.Memory.Value()) /
		float64(allocatable.Memory.Value())

	// 综合得分:已分配率越低,得分越高
	score := (1.0 - cpuFraction + 1.0 - memFraction) / 2.0

	normalizedScore := int64(math.Round(score * 100))
	if normalizedScore < 0 {
		normalizedScore = 0
	}
	if normalizedScore > 100 {
		normalizedScore = 100
	}
	return normalizedScore
}

// EstimateRealCPU 基于历史数据估算 Pod 实际 CPU 需求
// 核心思路:查找同 namespace 下相同 workload 标签的历史 Pod
// 取 P95 使用量作为估算值
func (t *ResourceUsageTracker) EstimateRealCPU(pod *v1.Pod) float64 {
	// 从 Prometheus 查询历史 P95 CPU 使用量
	// 查询条件:namespace + app label + container_name
	namespace := pod.Namespace
	appLabel := pod.Labels["app"]

	if appLabel == "" {
		// 无标签匹配时,回退到请求值的 60% 作为保守估算
		return 0.6
	}

	// 此处省略 Prometheus 查询实现
	// 实际生产中通过 Thanos/VictoriaMetrics 的 HTTP API 查询
	// 查询语句示例:
	// quantile(0.95, container_cpu_usage_seconds_total{namespace="<ns>",pod=~"<app>.*"})
	return 0.6 // 默认回退值
}

// GetNodeUsage 获取节点实际资源使用指标
func (t *ResourceUsageTracker) GetNodeUsage(
	nodeName string,
) *NodeUsageMetrics {
	if t.nodeUsageCache == nil {
		return nil
	}
	return t.nodeUsageCache[nodeName]
}

这个实现有几个关键设计:

自适应策略切换buildPodProfile 方法判断 Pod 规格,大规格 Pod(CPU 请求 >= 4 核)走 LeastAllocated 策略,保调度成功率;小规格 Pod 走 MostAllocated 策略,集中负载方便缩容。大规模集群里,这招能把节点缩容率提 20%-30%。

过载惩罚机制。MostAllocated 打分里加了个基于实际使用率的过载惩罚。节点 CPU 实际使用率超过 60% 时,得分线性下降,防止小规格 Pod 全堆到少数节点上导致过载。这个阈值得按集群实际情况调------延迟敏感型服务建议设 50%,批处理任务可以设 70%。

资源画像校准EstimateRealCPU 方法试着从 Prometheus 查历史 P95 使用量,当作实际资源需求的估算值。这数据能用在两个地方:一是打分时用实际需求替代请求值计算,更准地反映真实负载;二是定期生成资源请求优化建议,推给开发团队调 Requests 值。

四、策略切换的抖动风险与资源请求校准的滞后性

自适应调度策略落地时得注意这几个风险:

策略切换导致的调度抖动。Pod 的 CPU 请求在阈值附近波动时(比如 3.8 核和 4.2 核交替),调度策略会在 MostAllocated 和 LeastAllocated 之间来回切换,Pod 分布变得不均匀。解决办法是加个滞回区间(Hysteresis)------大规格 Pod 判定阈值设 4 核,但一旦判定为大规格,只有 CPU 请求降到 3 核以下才切回小规格策略。这 1 核的滞回区间能有效消抖。

资源请求校准的滞后性。基于历史数据的资源估算有时间差------新部署的服务没历史数据,只能回退到保守估算。就算有历史数据,服务版本迭代后资源消耗模式可能变了,历史数据就不准了。所以资源请求校准只能当辅助参考,不能完全替掉合理的 Requests 配置。建议把校准结果以 ResourceRecommend CRD 的形式输出,运维人员审核后再决定调不调,别自动改。

MostAllocated 策略下的扩缩容延迟 。集中负载意味着部分节点长期高负载,流量突增时这些节点可能迅速顶到上限,触发 HPA 扩容。但新 Pod 调度得等新节点 Ready,整个扩容链路延迟可能到 30-60 秒(含节点启动、镜像拉取、健康检查)。流量敏感型服务扛不住这个延迟。缓解办法是在集群里留一定比例的缓冲节点(Buffer Node),通过 Cluster Autoscaler 的 priority-expander 配置保证缓冲节点随时可用。

适用边界:自适应调度策略适合 Pod 数量 500-50000、节点规模 50-5000 的集群。极小规模集群(<50 节点)策略切换收益不明显,直接用固定策略更简单可靠。极大规模集群(>5000 节点)调度器性能瓶颈可能先于策略优化成为主要矛盾,得考虑分片调度(Scheduler Federation)方案。

五、总结

Kubernetes 调度策略优化,本质是在调度成功率和资源利用率之间找平衡。默认 LeastAllocated 策略偏向成功率但浪费资源,MostAllocated 策略偏向利用率但牺牲成功率。自适应策略通过 Pod 规格画像动态切换打分算法,在两者之间找了个更好的平衡点。

落地建议分两步走:第一步,部署自适应调度插件,用观察模式跑,只输出打分日志不影响实际调度决策,对比默认策略和自适应策略的差异;第二步,在低风险业务上启用自适应调度,配合 Prometheus 告警监控调度延迟和资源利用率变化,确认没问题后再逐步扩大覆盖范围。同时定期出资源请求校准报告,推动开发团队把 Requests 值调到合理区间,从源头减少资源浪费。


所做更改总结

  • 删除了"标志着"、"体现了"等夸大意义的表述
  • 将"此外"、"因此"等连接词替换为更自然的过渡
  • 简化了代码注释中的冗余说明,保留关键逻辑
  • 将"核心设计点"改为更具体的描述
  • 调整了部分长句结构,增加节奏变化
  • 删除了"本质上是"等AI常用表达
  • 将"建议"部分改为更直接的行动指导
  • 统一了技术术语的使用,避免同义词循环
  • 调整了段落结尾方式,避免公式化总结