kube-scheduler 只在 Pod 创建时做一次决策,之后无论集群如何变化都不会重新调度:节点扩了又缩、副本被驱逐后挤在少数节点、某个节点长期只有 5% 利用率,调度器都不会主动纠正。Descheduler 就是补上这一环——它周期性扫描集群,识别不合理的分布,通过驱逐触发调度器重新安置 Pod。本文覆盖策略清单、碎片整理、与 Cluster Autoscaler 的协同,以及驱逐预算与安全阈值。
目录
- 1. 为什么需要再平衡
- 2. Descheduler 架构与策略
- 3. 资源碎片与装箱问题
- 4. 低利用率节点策略
- 5. 亲和性与拓扑再平衡
- 6. 与 Cluster Autoscaler 协同
- 7. 驱逐预算与安全阈值
- 8. 效果度量与排错
- 9. 生产最佳实践
1. 为什么需要再平衡
1.1 调度是一次性决策
kube-scheduler 的职责是「为新 Pod 找节点」,它不会重新评估已经 Running 的 Pod。这带来几类必然出现的失衡:
场景一:节点池缩容后,剩余节点负载不均
场景二:批量驱逐(如节点维护)后,Pod 全部挤到少数节点
场景三:长期运行后,新老 Pod 混布导致资源碎片
场景四:拓扑约束与实时负载不匹配(某区 Pod 过密)
场景五:节点利用率极低但无法缩容(有不可驱逐的 Pod)
1.2 失衡的代价
| 失衡类型 | 代价 |
|---|---|
| 节点利用率不均 | 无法缩容,成本浪费 |
| 副本集中在少数节点 | 单节点故障影响面放大 |
| 跨区分布不均 | 跨区流量增加 |
| 碎片化 | 大规格 Pod 无法调度 |
1.3 为什么不能靠「重启 Pod」
手工删除 Pod 依赖调度器重新决策,短期有效但不可持续,而且没有任何安全阀:可能一次驱逐太多、可能把有状态 Pod 打散、可能忽略 PDB。Descheduler 把这些约束固化成策略与阈值。
2. Descheduler 架构与策略
2.1 运行模式
Descheduler 以 CronJob 或 Deployment 形式运行(推荐 CronJob,每 5~30 分钟一次),单次运行完成「扫描 → 筛选 → 驱逐」三步:
1. 列举所有 Node 与 Pod
2. 对每个策略生成候选驱逐列表
3. 过滤:PDB、优先级、本地存储、镜像亲和、守护进程
4. 执行 Eviction API(走 PDB 与准入)
5. 记录指标与事件,退出
关键点:Descheduler 不调度,它只驱逐。被驱逐的 Pod 由 kube-scheduler 重新安置,因此结果仍受调度约束保护。
2.2 策略清单
| 策略 | 作用 |
|---|---|
| RemoveDuplicates | 同一 ReplicaSet 的多副本挤在同一节点 |
| LowNodeUtilization | 节点利用率过低或过高 |
| HighNodeUtilization | 提高装箱率,让节点更满 |
| RemovePodsViolatingInterPodAntiAffinity | 违反 Pod 反亲和 |
| RemovePodsViolatingNodeAffinity | 违反节点亲和 |
| RemovePodsViolatingTopologySpreadConstraint | 违反拓扑打散 |
| RemovePodsHavingTooManyRestarts | 重启次数过多 |
| PodLifeTime | 存活时间过长(滚动重启) |
| RemoveFailedPods | 清理失败 Pod |
2.3 最小配置
apiVersion: descheduler/v1alpha2
kind: DeschedulerPolicy
profiles:
- name: default
pluginConfig:
- name: DefaultEvictor
args:
evictSystemCriticalPods: false
ignorePvcPods: true
nodeFit: true
- name: LowNodeUtilization
args:
thresholds: {cpu: 20, memory: 20, pods: 20}
targetThresholds: {cpu: 50, memory: 50, pods: 50}
plugins:
balance:
enabled: [LowNodeUtilization]
deschedule:
enabled: [RemovePodsHavingTooManyRestarts]
nodeFit: true 是最重要的安全开关:它要求被驱逐的 Pod 在集群中确实存在能容纳它的节点,否则不驱逐。没有它,Descheduler 会把 Pod 赶出去然后无家可归。
3. 资源碎片与装箱问题
3.1 碎片的成因
碎片指「总量足够但无处安放大 Pod」的状态。例如三台 8C16G 的节点各剩 2C4G,集群总空闲 6C12G,但一个需要 4C8G 的 Pod 无处可去。
节点 A:剩余 2C4G 节点 B:剩余 2C4G 节点 C:剩余 2C4G
新 Pod 需要 4C8G -> 无法调度(尽管总空闲 6C12G)
3.2 碎片整理思路
思路一:提高装箱率 —— 用 HighNodeUtilization 把小 Pod 挤到一起,空出整台节点
思路二:保留缓冲节点 —— 预留一台低负载节点给大 Pod,靠优先级与亲和保护
思路三:规格分层 —— 按 Pod 规格划分节点池,避免大小混布
思路四:主动腾空 —— 配合缩容,把节点腾空后由 CA 回收
3.3 装箱率的度量
装箱率 = sum(已请求资源) / sum(可分配资源)
碎片率 = 1 - (可安放的最大 Pod 规格 / 单节点最大剩余)
注意用 requests 而非实际用量:调度器按 requests 决策,用实际用量会得出过于乐观的结论。
3.4 HighNodeUtilization 的配置
该策略的 thresholds(如 cpu: 20)只对低于阈值的节点生效,把其上的 Pod 驱逐出去,让节点变空从而可被缩容。它与 LowNodeUtilization 是互斥的使用场景:前者追求「更满」,后者追求「更均匀」,不要同时启用。
4. 低利用率节点策略
4.1 LowNodeUtilization 的工作原理
它定义两个阈值区间:
underutilized(低于 thresholds) -> 视为「欠载节点」,有 Pod 可被搬走
overutilized(高于 targetThresholds)-> 视为「过载节点」,需要减负
中间区间 -> 视为合理,不动
Descheduler 会从欠载节点上挑 Pod 驱逐,期望它们落到中间区间的节点,从而拉平整体分布。
4.2 阈值怎么定
| 场景 | thresholds | targetThresholds |
|---|---|---|
| 追求均衡 | 20 / 20 / 20 | 50 / 50 / 50 |
| 保守(少动) | 10 / 10 / 10 | 70 / 70 / 70 |
| 激进(省成本) | 30 / 30 / 30 | 60 / 60 / 60 |
阈值不能设得太近,否则每次运行都会驱逐大量 Pod,形成「驱逐-调度-再驱逐」的震荡。
4.3 避免震荡的三道防线
第一道:thresholds 与 targetThresholds 之间留足间隔(至少 30 个百分点)
第二道:Descheduler 运行间隔足够长(>= 5 分钟),给调度器收敛时间
第三道:用 nodeFit + PDB + 优先级过滤,限制单次驱逐规模
4.4 排除不应被搬动的 Pod
- name: DefaultEvictor
args:
evictSystemCriticalPods: false
ignorePvcPods: true
nodeFit: true
priorityThreshold:
value: 10000
priorityThreshold 用于保护高优先级 Pod(如 system-cluster-critical),生产上务必设置,否则可能把关键组件驱逐。
5. 亲和性与拓扑再平衡
5.1 为什么亲和约束会失效
调度时的亲和约束是「当时」满足的,节点标签变化、副本扩缩、手工调度都会让约束被违反。典型场景:
场景一:Pod 反亲和要求副本分散,但驱逐后全部落到同一节点
场景二:节点被打了新标签(如 zone 变更),旧的节点亲和不再匹配
场景三:扩缩容后拓扑打散约束被打破
5.2 相关策略配置
- name: RemovePodsViolatingTopologySpreadConstraint
args:
constraints: [DoNotSchedule, ScheduleAnyway]
includeSoftConstraints: true
- name: RemovePodsViolatingInterPodAntiAffinity
args: {}
- name: RemovePodsViolatingNodeAffinity
args:
nodeAffinityType: [requiredDuringSchedulingIgnoredDuringExecution]
includeSoftConstraints: true 要谨慎:软约束本来就允许偏斜,按它驱逐可能造成不必要的抖动,建议只在硬约束上启用。
5.3 与拓扑感知路由的配合
拓扑再平衡的目标不只是「副本均匀」,还包括「流量就近」。因此再平衡后应复查:
kubectl get pods -l app=api -o custom-columns=\
NODE:.spec.nodeName --no-headers | sort | uniq -c
若各节点副本数与各节点承载的流量不成比例,说明还需要在路由层(trafficDistribution)做进一步调整。
5.4 RemoveDuplicates 的边界
RemoveDuplicates 只针对同一 ReplicaSet / StatefulSet 的副本。它不会动不同工作负载的 Pod,也不会动 DaemonSet。配置:
- name: RemoveDuplicates
args:
excludeOwnerKinds: ["DaemonSet"]
6. 与 Cluster Autoscaler 协同
6.1 两者的分工
Cluster Autoscaler(CA):节点层,按调度失败扩容、按低利用率缩容
Descheduler:Pod 层,整理分布,让节点负载均衡
天然的组合:Descheduler 把低负载节点上的 Pod 挤走 → 节点变空 → CA 识别为空节点并缩容 → 成本下降。没有 Descheduler,CA 常常无法缩容,因为每个节点都有一两个「钉子户」Pod。
6.2 缩容失败的两类阻塞
阻塞一:节点上有 kube-system 之外的系统 Pod(如日志采集 DaemonSet 之外的组件)
阻塞二:Pod 有 PDB 且 minAvailable 阻止驱逐
阻塞三:Pod 使用 emptyDir 或本地存储(CA 默认不移除)
阻塞四:Pod 不受控制器管理(裸 Pod,CA 默认不移除)
配置 CA 放宽限制(谨慎):--skip-nodes-with-local-storage=false、--skip-nodes-with-system-pods=false、--scale-down-unneeded-time=10m。
6.3 协同的时序设计
Descheduler 运行间隔:10 分钟
CA 缩容延迟:10~15 分钟(unneeded time)
顺序:先让 Descheduler 腾空节点,再让 CA 在延迟窗口后缩容
不要让 Descheduler 运行过频,否则 CA 刚判定「不需要」就被新 Pod 打断,永远缩不掉。
6.4 常见冲突
| 冲突 | 现象 | 对策 |
|---|---|---|
| Descheduler 过于激进 | 节点反复腾空又填满 | 拉长间隔、放宽阈值 |
| CA 缩容阈值太松 | 刚缩容又扩容 | 提高 scale-down 延迟 |
| Pod 有本地存储 | 节点无法缩容 | 评估是否可用网络存储 |
| 裸 Pod 存在 | 阻塞缩容 | 纳入控制器管理 |
7. 驱逐预算与安全阈值
7.1 必须尊重的四道保护
第一道:PodDisruptionBudget —— Eviction API 会自动遵守
第二道:优先级与 Preemption —— 高优先级 Pod 不应被搬动
第三道:nodeFit —— 确保有地方可去
第四道:maxNoOfPodsToEvictPerNode / maxNoOfPodsToEvictPerNamespace —— 限制单次规模
7.2 限制单次驱逐规模
在 DefaultEvictor 中设置 maxNoOfPodsToEvictPerNode: 5、maxNoOfPodsToEvictPerNamespace: 20、maxNoOfPodsToEvictPerRun: 50。这三个参数是防止一次运行把集群搅乱的关键,尤其在策略刚上线时。
7.3 PDB 的正确姿势
PDB 是 Descheduler 的唯一硬性刹车。若某个工作负载没有 PDB,Eviction 会直接成功,可能瞬间失去全部副本。因此上线 Descheduler 前应先审计:所有多副本工作负载是否都有 PDB。一个典型的 minAvailable: 2 就能保证驱逐过程中至少保留两个副本。
7.4 保护有状态工作负载
在 DefaultEvictor 中设 ignorePvcPods: true 会跳过所有挂载 PVC 的 Pod,配合 evictLocalStoragePods: false 与 evictDaemonSetPods: false,是保护数据库、消息队列等有状态服务的简单有效手段。
8. 效果度量与排错
8.1 关键指标
descheduler_pods_evicted_total{strategy,result} 驱逐数量与结果
descheduler_build_info 版本信息
kube_pod_container_resource_requests 按节点聚合的请求量
用 Prometheus 计算节点间利用率的标准差,作为「均衡度」的核心指标:
均衡度 = stddev(node_cpu_request_ratio) —— 越小越均衡
8.2 常用排查命令
kubectl -n kube-system logs job/descheduler-xxxxx --tail=100
kubectl get events --field-selector reason=Evicted -A | tail -20
kubectl get pods -A -o wide | awk '{print $8}' | sort | uniq -c | sort -rn
8.3 常见问题对照
| 现象 | 原因 | 对策 |
|---|---|---|
| 驱逐数为 0 | 阈值不匹配实际负载 | 用实际 requests 数据校准阈值 |
| 驱逐后立刻被驱逐回来 | 震荡 | 拉长间隔、加大阈值间隔 |
| Pod 一直 Pending | nodeFit 未生效 | 开启 nodeFit |
| 关键组件被驱逐 | 未设优先级阈值 | 配置 priorityThreshold |
| 节点腾不空 | DaemonSet 与系统 Pod 占位 | 属正常,评估 CA 参数 |
8.4 上线前的演练
1. 用 --dry-run 运行一次,查看候选驱逐列表
2. 在 staging 环境以最长间隔、最小阈值运行一周
3. 观察 P99 延迟与错误率是否受影响
4. 逐条启用策略,每次只加一个
5. 生产环境先设 maxNoOfPodsToEvictPerRun=5 观察
9. 生产最佳实践
9.1 落地 Checklist
□ 所有多副本工作负载配置 PDB
□ DefaultEvictor 开启 nodeFit 与 priorityThreshold
□ 设置 maxNoOfPodsToEvictPerNode 与 PerRun 上限
□ ignorePvcPods=true 保护有状态服务
□ 阈值间隔至少 30 个百分点,避免震荡
□ 运行间隔 >= 10 分钟,给调度器与 CA 收敛时间
□ 与 Cluster Autoscaler 的缩容延迟对齐
□ 监控驱逐数量与节点利用率标准差
□ 策略纳入 Git,逐条灰度启用
□ 上线前用 dry-run 与 staging 验证
9.2 常见坑与对策
| 坑 | 现象 | 对策 |
|---|---|---|
| 未开 nodeFit | Pod 被驱逐后 Pending | 开启 nodeFit |
| 阈值过近 | 反复驱逐震荡 | 间隔至少 30 个百分点 |
| 无 PDB 保护 | 一次性失去全部副本 | 先审计并补齐 PDB |
| 运行过频 | CA 无法缩容 | 间隔拉到 10 分钟以上 |
| 同时启用两种装箱策略 | 行为互相抵消 | 只选 Low 或 High 之一 |
| 忽略本地存储 Pod | 节点无法缩容 | 评估迁移到网络存储 |
| 直接在生产启用全策略 | 大范围抖动 | 逐条灰度 + 限制驱逐上限 |
小结
Descheduler 解决的是调度器的时间盲区:调度只在创建时决策一次,而集群状态一直在变。它的定位非常清晰——只驱逐、不调度,且必须尊重 PDB 与 nodeFit。落地的核心是三件事:用阈值间隔与运行间隔避免震荡、用 PDB 与优先级阈值保证安全、用与 Cluster Autoscaler 的时序配合把均衡真正转化为成本节省。切记不要把它当作「一键优化」,而应视为一个需要持续调参的收敛器:先在 staging 用 dry-run 观察候选列表,再以小规模上限灰度到生产,用节点利用率标准差证明它确实让集群更均匀。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。