引言
前面我们写完了五篇安全纵深(网络、权限、授权、镜像、运行时),又补了一篇带新手跑通第一个应用的入门篇。但有一个最被低估、却决定每一个 Pod 命运的组件一直没讲——调度器(kube-scheduler)。
你一定见过这个场景:
kubectl get pods
NAME READY STATUS RESTARTS
web-7d9c8b6f4-abcde 0/1 Pending 0
Pending 背后,调度器在做什么?为什么它不把这个 Pod 放到某台明明"看起来有空"的节点上?为什么加了 nodeAffinity 后 Pod 反而起不来了?为什么高优先级的作业能"挤掉"低优先级的?
这些问题,靠 kubectl describe 看 Events 只能看到结论,看不到机制。本文要把调度器从"黑盒"拆成"白盒":从 Pod 进入调度队列,到打分、筛选、绑定、抢占的完整生命周期,并配实战与排错。读完你应当能解释 90% 的"为什么没调度上去"。
一、原理解析(架构深度)
- 调度器的职责边界:它只"决定",不"执行"
首先要纠正一个常见误解:kube-scheduler 不创建 Pod、不拉镜像、不启动容器。它只做一件事——
为每个未绑定的 Pod,选出一个最合适的 Node,并把"Pod → Node"这个绑定关系写回 API Server。
真正的"把容器跑起来"是 kubelet 在目标节点上完成的。这种"决策与执行分离"是 K8s 声明式架构的典型体现:调度器是控制面里的一个"建议者",而非"执行者"。 - 一个 Pod 的调度生命周期
调度器内部是一条事件驱动 + 队列化的流水线。理解这条链路,是看懂所有"Pending"现象的基础:
新 Pod 进入(unscheduled)
│
▼
┌─────────────────────────┐
│ Scheduling Queue │ 按优先级排序;backoff 防止频繁重试
│ (active / backoff / │
│ unschedulable 三个子队列)│
└───────────┬─────────────┘
│ 取出一个 Pod
▼
╔══════════════════════════════════════════════╗
║ Scheduling Cycle(串行,不绑定) ║
║ PreFilter → Filter → PostFilter(抢占) ║
║ → PreScore → Score → NormalizeScore ║
║ 结果:选出"得分最高的可行节点" ║
╚══════════════════════════════════════════════╝
│
▼
╔══════════════════════════════════════════════╗
║ Binding Cycle(并行,真正绑定) ║
║ Reserve → Permit → PreBind → Bind → PostBind║
║ 通过 API Server 写入 binding 对象 ║
╚══════════════════════════════════════════════╝
│
▼
kubelet 在目标节点拉起 Pod
关键认知:Scheduling Cycle 是串行的、纯计算(对所有候选节点跑 Filter/Score),Binding Cycle 才真正写绑定。若 Bind 阶段失败(如节点突然不可用),会通过 Unreserve 回滚预留,Pod 重新入队。 - 调度框架(Scheduling Framework):可插拔的扩展点
现代 kube-scheduler(v1.19+ GA)的核心是 Scheduling Framework——把调度过程定义为一串有序的扩展点(extension points),每个扩展点挂一组插件(Plugin)。看懂这张表,就等于拿到了调度器的"源码地图":
扩展点
作用
典型内置插件
PreFilter
预处理、校验 Pod 是否根本不可调度
NodeResourcesFit(预检资源)
Filter
硬约束:节点不满足则直接淘汰(原 Predicates)
NodeResourcesFit、NodeAffinity、TaintToleration、PodAffinity、拓扑约束、VolumeBinding、NodePorts
PostFilter
Filter 全失败时兜底,实现抢占(Preemption)
DefaultPreemption
PreScore
打分前准备数据
Score
软约束:给节点打分(原 Priorities)
LeastAllocated、NodeAffinity、InterPodAffinity、ImageLocality、TopologySpread
NormalizeScore
分数归一化,避免某插件分数量级碾压他人
框架内置
Reserve
假定占用节点资源,防止并发调度冲突
VolumeBinding、NodeResourcesFit
Permit
允许/拒绝/延迟绑定(可用于批处理等待)
PreBind / Bind / PostBind
真正执行绑定(Bind 默认写 API)
Unreserve
上述任一步失败时的回滚
深度要点:Filter 是"一票否决",Score 是"加权择优"。一个节点只要被任一 Filter 淘汰,就绝无可能;而 Score 只是决定"在都可行里选谁"。 - Filter 阶段:那些"一票否决"的硬约束
理解 Filter 插件,就理解了绝大多数"Pending"的根因:
- NodeResourcesFit:节点的可分配 CPU/内存是否满足 Pod 的 requests。注意是 requests 不是 limits——这是新手最大的认知盲区(见实战)。
- TaintToleration:节点有污点(taint),Pod 无对应容忍(toleration)则被淘汰。
- NodeAffinity:节点标签不满足 Pod 的 nodeAffinity 硬要求(requiredDuringSchedulingIgnoredDuringExecution)。
- PodAffinity / PodAntiAffinity:Pod 间亲和/反亲和的硬约束(拓扑域内有/无特定 Pod)。
- TopologySpread(v1.26+ 稳定):约束 Pod 在拓扑域(如 zone、node)间的均匀分布。
- VolumeBinding / VolumeRestrictions / VolumeZone:PVC 能否在该节点绑定、本地卷/拓扑卷约束。
- NodePorts:节点上 HostPort 是否冲突。
- Score 阶段:打分如何决定"最优"
Filter 筛出可行节点后,Score 阶段给每个节点打分(默认 0–100),加权求和取最高。几个核心打分插件:
- NodeResourcesLeastAllocated:节点"空闲比例越高分越高"——倾向于把负载铺开,利于装箱均衡。
- 直觉公式:对 CPU/内存分别算 (allocatable - requested) / allocatable,取平均。
- NodeResourcesBalancedAllocation:优先让 CPU 与内存的利用率接近(避免"CPU 满内存闲"的畸形分配)。
- InterPodAffinity:亲和/反亲和的软约束打分。
- ImageLocality:节点若已缓存该镜像,打分更高——减少拉取耗时。
- TopologySpread:鼓励跨拓扑域分散。
NormalizeScore 会把各插件原始分归一化,防止某个插件分数范围(如 0–10)碾压另一个(0–100),保证权重(plugin.weight)才是真正的调节旋钮。
- 亲和性与污点容忍:用户侧的"调度语言"
调度器的大部分"行为定制"都通过 Pod spec 表达,三类核心原语:
- nodeSelector / nodeAffinity:把 Pod 引向特定节点(基于节点标签)。required 是硬约束(走 Filter),preferred 是软约束(走 Score)。
- podAffinity / podAntiAffinity:基于"其他 Pod 在哪"来决定位置(如"和缓存 Pod 同节点"“不和同类 Pod 同节点”)。
- Taints(节点污点)+ Tolerations(Pod 容忍):把节点"排斥"出去,除非 Pod 明确容忍。典型用法:专用节点(GPU、隔离),默认排斥普通 Pod。
- 抢占(Preemption)与优先级(PriorityClass)
当高优先级 Pod 无法调度(Filter 全失败)时,PostFilter 阶段的 DefaultPreemption 会计算"踢掉哪些低优先级 Pod 能腾出空间":
- 每个 Pod 可设置 priorityClassName 指向一个 PriorityClass(数值越大越高);
- 抢占只踢 priority 更低 且集群计算"抢占代价最小"的 Pod;
- 被抢占的 Pod 进入 terminating,高优先级 Pod 随后调度;
- preemptionPolicy: Never 可关闭某 Pod 的抢占资格(即使它优先级高也不抢)。
深度陷阱:抢占是"集群级"的兜底机制,不是常规调度手段。滥用会导致低优先级负载被频繁驱逐,引发雪崩。生产应优先用合理的 requests 与亲和性,而非依赖抢占。
- 性能与扩展性:万节点规模怎么扛
调度器是集群吞吐的瓶颈之一。关键参数与机制:
- percentageOfNodesToScore:超大规模下,不必打分全部节点,只抽样一定比例(默认随节点数自适应,小集群全扫)。调大 → 调度更快但可能选次优节点。
- Scheduling Queue 与 backoff:失败 Pod 指数退避,避免无效高频重试压垮调度器。
- 多调度器共存:通过 Pod 的 spec.schedulerName 指定非默认调度器;不同负载可用不同调度策略。
- Scheduler Extender:把部分决策外包给外部 HTTP 服务(早期自定义调度的主流方式,现已多被 Scheduling Framework Plugin 取代)。
二、实战演示(深度向)
环境:标准 K8s 集群,含带 gpu=true 标签的专用节点与若干普通节点。
场景 1:把 GPU 负载调度到专用节点(nodeAffinity + taint/toleration)
apiVersion: v1
kind: Node
metadata:
name: gpu-node-01
labels:
gpu: “true”
spec:
taints:- key: dedicated # 给 GPU 节点打污点,排斥普通 Pod
value: gpu
effect: NoSchedule
- key: dedicated # 给 GPU 节点打污点,排斥普通 Pod
apiVersion: apps/v1
kind: Deployment
metadata:
name: gpu-trainer
spec:
replicas: 1
selector:
matchLabels: { app: trainer }
template:
metadata:
labels: { app: trainer }
spec:
# 容忍污点,否则根本不会被考虑调度到 GPU 节点
tolerations:
- key: dedicated
operator: Equal
value: gpu
effect: NoSchedule
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution: # 硬约束(Filter)
nodeSelectorTerms:
- matchExpressions:
- key: gpu
operator: In
values: [“true”]
containers:
- name: trainer
image: nvidia/cuda:12.2-base
resources:
requests: { nvidia.com/gpu: 1 } # 真实 GPU 资源请求
kubectl apply -f gpu.yaml
kubectl get pods -o wide
预期:Pod 落在 gpu-node-01(普通节点因污点被 Filter 淘汰)
kubectl describe pod | grep -A3 “Tolerations|Node-Selector”
场景 2:同服务 Pod 反亲和,避免单点(podAntiAffinity)
spec:
template:
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution: # 硬反亲和:同一节点不允许有同类 Pod
- labelSelector:
matchLabels: { app: web }
topologyKey: kubernetes.io/hostname
containers: [ … ]
kubectl scale deployment web --replicas=3
kubectl get pods -o wide
预期:3 个 Pod 分布在 3 个不同节点;若节点不足,多余的会 Pending(硬反亲和无法满足)
场景 3:高优先级抢占低优先级(PriorityClass + preemption)
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: high-priority
value: 1000000 # 高于默认 0
globalDefault: false
preemptionPolicy: PreemptLowerPriority
apiVersion: apps/v1
kind: Deployment
metadata: { name: critical-job }
spec:
template:
spec:
priorityClassName: high-priority
containers: [ { name: c, image: busybox, command: [“sleep”,“3600”] } ]
先让集群资源吃紧(低优先级占满),再提交 critical-job
kubectl apply -f priority.yaml
kubectl get pods -o wide
观察:低优先级 Pod 被驱逐(Terminating),critical-job 成功调度
kubectl get events --sort-by=.lastTimestamp | grep -i “preempt|evict”
场景 4:自定义调度器(schedulerName)
spec:
template:
spec:
schedulerName: my-custom-scheduler # 不走默认 kube-scheduler
containers: [ … ]
部署你自己的调度器(或 scheduler extender),它需监听未绑定 Pod 并写 binding
验证:默认调度器不会动这个 Pod,只有 my-custom-scheduler 处理
kubectl get pods -o jsonpath=‘{.items[*].spec.schedulerName}’
三、排错实战(深度向)
调度问题是线上最高频的"诡异"故障之一。下面按实战顺序展开。
3.1 Pod 一直 Pending:系统化排查
第一步永远看 Events:
kubectl describe pod
末尾 Events 会直接写明原因,例如:
FailedScheduling: 0/5 nodes are available:
3 node(s) didn’t match Pod’s node affinity/selector
2 node(s) had untolerated taint {dedicated: gpu}
常见根因对照:
Events 线索
根因
解法
didn’t match Pod’s node affinity/selector
nodeAffinity 太严 / 节点无对应标签
放宽 preferred 或给目标节点打标签
had untolerated taint
节点污点未被容忍
加 tolerations,或去掉污点
insufficient cpu/memory
节点剩余 < Pod 的 requests
调小 requests、加节点、或降低副本
node(s) had volume node affinity conflict
PVC 拓扑/区域不匹配
检查 StorageClass 的 allowedTopologies
didn’t match pod anti-affinity rules
硬反亲和无法满足(节点不够)
增加节点或改 preferred
3.2 “明明有空节点,却不调度”——requests vs limits 的认知盲区
这是最经典的排错误区:
kubectl describe node
Allocated resources:
(Total limits may be over 100 percent, total requests may be under.)
Resource Requests Limits
cpu 780m (20%) 2000m (51%)
memory 2.1Gi (27%) 4Gi (51%)
调度只看 Requests,不看 Limits。 即使节点 limits 显示"已用 51%“,只要 requests 累加后剩余不足以覆盖新 Pod 的 requests,该 Pod 就会被 NodeResourcesFit 淘汰。新手常误以为"limits 没满就能放”,结果一直 Pending。
3.3 调度延迟高:吞吐与采样
当集群大、Pod 创建频繁却调度慢:
看调度器自身日志(关注调度耗时与 Queue 情况)
kubectl logs -n kube-system -l component=kube-scheduler | grep -i “binding|schedule”
调大节点采样比例以提升大集群调度速度(谨慎,可能选次优)
–percentage-of-nodes-to-score=50
- 若 unschedulable 队列堆积 → 检查是否有大量"永远调度不上"的 Pod 反复入队(backoff 机制应已缓解)。
- 若绑定慢 → 怀疑 API Server 写入压力或 etcd 延迟。
3.4 抢占不生效
现象
原因
处理
高优先级 Pod 仍 Pending,没踢人
preemptionPolicy: Never 或 PriorityClass 未生效
确认 priorityClassName 已设置;确认值确实更高
低优先级 Pod 不被驱逐
Pod 有 do-not-evict 类保护或 PriorityClass 同值
检查目标 Pod 的 priority 是否真的更低
抢占引发雪崩
大量低优先级被同时踢
降低对抢占的依赖,改用合理 requests
3.5 排错决策树
Pod Pending?
├─ 看 describe Events ──────▶ 按需修正 affinity / tolerations / requests / 拓扑
├─ 节点有空但不过滤 ──────▶ 确认是 requests 而非 limits 在起作用(3.2)
├─ 调度整体慢 ────────────▶ 查调度器日志 / Queue / percentage-of-nodes-to-score(3.3)
└─ 抢占异常 ──────────────▶ 查 PriorityClass / preemptionPolicy / 被抢 Pod 优先级(3.4)
四、总结
把调度器拆完,你会发现它完美诠释了 K8s 的设计哲学:声明式 + 最终一致 + 决策与执行分离。你声明"要什么"(requests、亲和、容忍、优先级),调度器通过"Filter 一票否决 + Score 加权择优 + 必要时抢占"这套严谨流水线,最终把 Pod 落到合适的节点,并写入绑定关系让 kubelet 接手。
几条工程落地的核心认知(呼应前文):
- 调度只认 requests:资源规划务必写对
requests,它与 limits 语义完全不同,直接决定能否调度。 - 硬约束(required)是 Pending 元凶:nodeAffinity/podAntiAffinity 的
required一旦过严,直接 Filter 淘汰;不确定时用preferred先观察。 - 污点/容忍是"专用节点"的标准答案:GPU、隔离节点靠 taint+toleration 实现,比手动挑节点可靠得多。
- 抢占是兜底不是常规手段:滥用会雪崩,优先靠合理 requests 与亲和。
- 看懂 Framework 才看得懂排错:Filter(否决)与 Score(择优)的二分,是解释一切调度行为的钥匙。
至此,我们的 K8s 博客矩阵已覆盖:入门跑通(基础篇)→ 控制器全貌(参考手册)→ 安全纵深五篇(网络/权限/授权/镜像/运行时)→ 核心机制深度(调度器,本篇)。从"能用"到"懂原理",这条进阶路线已经相当完整。
下一步可选:把全部文章整合为带导航的《Kubernetes 实战手册》,或继续深挖另一个核心机制(如etcd 与 API Server 一致性、存储 CSI 与 StatefulSet 持久化编排、或Ingress/Gateway API 流量治理)。你定方向,我接着写。