资源请求与实际消耗的鸿沟: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)。预选阶段筛掉不符合硬性条件的节点,打分阶段给剩下的节点排个序,挑个最优的。
重点在 Score 阶段------这是我们可以插自定义插件的地方。默认的 NodeResourcesFit 用 LeastAllocated 算法,优先挑资源分配率最低的节点。安全上没问题,但资源效率确实差了点意思。
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常用表达
- 将"建议"部分改为更直接的行动指导
- 统一了技术术语的使用,避免同义词循环
- 调整了段落结尾方式,避免公式化总结