第 6 章 · 调度器:把「Pod 放哪」这个难题工程化
一句话结论:调度在数学上是个 NP-hard 问题(装箱问题 bin packing 的变体),但 K8s 的调度器没有试图「求最优解」,而是把它拆成「过滤(filter)→ 打分(score)」两阶段——先排除「根本放不下的」,再给剩下的「打分选个还不错的」。用「够好的启发式」替代「不可解的最优解」,这是它最聪明的工程取舍。
1. 先正视问题的难度
调度器的任务:给一个新 Pod 选一台「合适」的节点。
听上去简单,但「合适」要考虑的因素一多,就成了一个组合爆炸的难题:
- 这台机器 CPU / 内存够不够?
- 这个 Pod 有没有「必须放在有 GPU 的机器」的硬性要求?
- 有没有「反亲和」——这个 Pod 不能和某个 Pod 放一起(比如主从副本不能同机)?
- 有没有「亲和」——最好和某个服务放一起(省跨机流量)?
- 端口冲突吗?存储卷这台机器挂得上吗?
这是一个典型的「装箱问题」变体,NP-hard——当节点和约束多到一定程度,「求全局最优」在计算上不可行。
2. 思考的分叉:怎么「不可解的问题」落地?
方向 A:求全局最优——用算法在所有节点上做全局搜索。
- 代价:计算爆炸,调度延迟不可接受(生产环境调度要以毫秒~秒级响应)。
方向 B:用「过滤 + 打分」的启发式,求「足够好」。
- 过滤(filter/feasibility):先剔除「不满足硬性条件」的节点,缩小候选集。
- 打分(score/priority):给剩下的候选节点打分,选分数最高的。
为什么 K8s 选 B:
- 因为调度要的是「快 + 够好」,不是「慢 + 最优」。生产环境的 Pod 创建是高频、低延迟要求的操作,等不起全局最优。
- 而且「过滤 + 打分」是可插拔、可演进的——过滤规则和打分策略都是「插件」,可以不断加新规则、调权重,而不改核心框架。
类比:找房子。你不会「把所有房子遍历一遍求全局最优」,你会先「过滤」掉不符合硬性条件的(预算不够、通勤超 1 小时),再在剩下的里「打分」(离地铁近 +1、采光好 +1),选个分高的。过滤排除「绝对不行」,打分在「都还行」里选「更好」,这就是现实中的决策方式,K8s 只是把它形式化了。
3. 伪代码:两阶段调度的骨架
func (s *Scheduler) Schedule(pod *Pod) (node string, err error) {
// ① 过滤:硬性条件,一票否决
feasible := []*Node{}
for _, n := range s.nodes {
if s.filtersPass(pod, n) { // 每个 filter 是一个「能不能放」的判断
feasible = append(feasible, n)
}
}
if len(feasible) == 0 {
return "", ErrUnschedulable // 一个都放不下,标记「无法调度」
}
// ② 打分:软性偏好,择优
best := feasible[0]
bestScore := math.MinInt
for _, n := range feasible {
score := s.score(pod, n) // 每个 score 插件给一个分,加权求和
if score > bestScore {
best, bestScore = n, score
}
}
return best.Name, nil
}
func (s *Scheduler) filtersPass(pod *Pod, n *Node) bool {
// 硬性 filter 的例子
return n.FreeCPU() >= pod.Request.CPU && // 资源够
n.FreeMem() >= pod.Request.Mem &&
!s.hasPortConflict(pod, n) && // 端口不冲突
s.nodeSelectorMatch(pod, n) // 标签匹配
}
func (s *Scheduler) score(pod *Pod, n *Node) int {
// 软性 score 的例子:加权求和
return w1 * leastRequestedScore(pod, n) + // 资源均衡(别把一台塞太满)
w2 * localityScore(pod, n) + // 数据本地性(就近调度)
w3 * affinityScore(pod, n) // 亲和/反亲和
}拆开看几个设计要点:
- 「硬约束」和「软偏好」分开:过滤处理「绝对不行」,打分处理「更好」。把「必须」和「最好」分离开,是决策系统的通用设计——混在一起,你会分不清「是放不下,还是放得下但不够好」。
- 全部可插拔:每个 filter、每个 score 都是一个独立插件,通过一个「调度框架」注册。这让调度器能随业务演进,而不必重写核心(第 9 章「插件化」会再呼应)。
ErrUnschedulable是一个「一等状态」:放不下不是「报错崩溃」,而是「标记为未调度、进入等待队列」,等资源释放了再重试。这和第 3 章的 level-driven 一脉相承——调度器也是「持续校对」的,不会因为一次放不下就放弃。
4. 一个容易被忽略的「职责边界」
调度器只做「决定放哪」,不负责「放上去」。真正把 Pod 绑定到节点、拉镜像、起容器的是 kubelet(每个节点上的 agent)。
调度器(Scheduler):控制平面,负责「决策」——这个 Pod 去节点 A
↓(写入 Pod 的 nodeName 字段)
kubelet(节点 A 上的 agent):负责「执行」——真的把容器拉起来
为什么要把「决策」和「执行」分开:
- 调度器是中心化的、全局视角的决策者,它需要看到全集群的资源情况才能做出好决策。
- kubelet 是分布式的、本地视角的执行者,它只关心自己这台机器,负责具体的拉镜像、起容器、健康检查。
- 这个分离是经典的「决策与执行解耦」:中心做全局最优决策,边缘做本地可靠执行。混在一起,要么中心被执行细节拖垮,要么边缘失去全局视野做不出好决策。
这个「中心决策 + 边缘执行」的分工,是整个 K8s 架构的骨架:API Server / 调度器 / 控制器在「控制平面」(管大脑),kubelet / 容器运行时在「数据平面」(管手脚)。理解这个分工,你就理解了 K8s 几乎所有组件图。
思考题
Q:既然调度是 NP-hard,「过滤 + 打分」只是启发式,那它一定会在某些情况下做出「局部最优但全局很糟」的决策。K8s 有办法缓解这个问题吗?如果有,是什么机制?
参考答案:
有,K8s 用了几个机制来缓解「局部最优陷阱」:
- 打分里的「均衡」偏好(leastRequestedScore):默认倾向把 Pod 放到「负载较低」的节点,而不是一味堆到「当前看起来最合适」的节点。这相当于一种贪心 + 前瞻的平衡,避免把少数节点塞爆。
- 反亲和 / 拓扑分布(pod anti-affinity / topology spread):让用户显式声明「同类 Pod 要打散到不同节点/可用区」,从「约束」层面就避免了「全挤在一台」的坏结果。
- 重新调度 / 驱逐(descheduler / eviction):调度器决策是一次性的,但系统还允许事后纠偏——比如通过 descheduler 把「后来变得不均衡」的 Pod 再平衡一次。
这引出一个更深的认知:在一个「不断变化」的系统里,没有「一次决策就一劳永逸」的最优解。 聪明的做法是——用「够好的启发式」快速做出初始决策,再用「持续对账 / 事后纠偏」来修正随着时间积累的偏差。「快决策 + 慢纠偏」的配合,比「试图一次算准」要可靠得多。 这和第 3 章「控制器持续校对」是同一个哲学:不求一次做对,但求持续逼近。