你有没有遇到过这样的场景:明明集群里还有大量空闲节点,新创建的Pod却一直Pending;或者两个CPU密集型的Pod被调度到了同一个节点,导致互相拖垮;又或者你希望把某些敏感业务固定调度到指定的一组机器上,却不知道从何下手。这些问题,全都指向Kubernetes里一个既核心又容易被忽视的组件------调度器(kube-scheduler)。
这篇文章我会从调度器的整体架构讲起,把调度流程、常用调度策略、亲和性反亲和性、污点容忍,以及自定义调度器这些内容一次讲透。全文基于我在生产环境中的真实踩坑经历,希望能帮你把"调度"这件事彻底搞明白。
一、调度器到底在干什么
1.1 调度器的定位
Kubernetes是一个分布式系统,Master节点上跑着各种控制面组件,其中kube-scheduler负责一件事:为待调度的Pod找到一个最合适的节点。
听起来简单,但"最合适"三个字背后是一整套复杂的决策逻辑。调度器要同时考虑:
- 节点的资源剩余量(CPU、内存、GPU等)
- Pod对资源的需求(requests和limits)
- 各种约束条件(亲和性、污点、拓扑分布等)
- 集群当前的负载均衡情况
1.2 调度不是"随机分配"
很多人以为调度器是随便挑一个有空闲资源的节点,这是最大的误解。实际上,kube-scheduler的决策过程分为两个阶段:
过滤阶段(Filtering):先把不满足硬性条件的节点剔除掉。比如节点资源不够、端口冲突、不满足nodeSelector等,这些节点直接出局。
打分阶段(Scoring):在剩下的候选节点里,根据一系列评分规则给每个节点打分,得分最高的节点胜出。
这种"先过滤、后打分"的两阶段模型,是整个调度器的核心设计思想。它保证了调度结果既满足约束(正确性),又尽量最优(优化性)。
二、调度流程全解析
2.1 从Pod创建到调度完成
一个Pod从创建到运行,完整经历以下流程:
- 用户提交Pod(kubectl apply 或通过Deployment等控制器创建)
- Pod进入Pending状态,被写入etcd
- kube-scheduler通过watch机制监听到新的Pod,且该Pod的spec.nodeName为空
- 调度器执行过滤+打分,选出最优节点
- 调度器将结果写回:Pod的spec.nodeName设置为目标节点
- 目标节点上的kubelet监听到绑定,拉取镜像、启动容器
关键点:调度器只负责"选节点"和"写绑定",真正干活的是kubelet。所以即使调度器挂了,已经运行中的Pod不会受影响,只是新Pod无法被调度。
2.2 调度队列
调度器内部维护了多个队列来管理待调度的Pod:
- activeQ:活跃队列,存放等待调度的Pod
- backoffQ:退避队列,调度失败的Pod会进入这里,等待一段时间后重试
- unschedulableQ:不可调度队列,比如资源不足导致失败的Pod,等集群资源变化后再尝试
这三个队列的配合,让调度器在集群资源紧张时不会疯狂重试浪费CPU,而是"耐心等待"资源释放。
三、常用调度策略详解
3.1 nodeSelector:最简单的定向调度
nodeSelector是最基础的调度约束,通过节点的label来筛选节点:
yaml
apiVersion: v1
kind: Pod
metadata:
name: nginx-gpu
spec:
nodeSelector:
gpu: "true"
containers:
- name: nginx
image: nginx
只有带gpu=true标签的节点才会被选中。优点是简单直观,缺点是表达能力有限------只能做等值匹配,无法表达"或"、"非"这类复杂逻辑。
3.2 节点亲和性:nodeSelector的进阶版
节点亲和性(nodeAffinity)解决了nodeSelector表达能力不足的问题,支持更丰富的匹配规则:
yaml
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: disktype
operator: In
values:
- ssd
- nvme
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: zone
operator: In
values:
- cn-east-1
这里有两个关键概念:
requiredDuringSchedulingIgnoredDuringExecution:硬性要求,必须满足,否则Pod无法调度。注意后半段"IgnoredDuringExecution"表示:节点标签后续变化不影响已调度的Pod,这个设计是为了避免频繁迁移Pod。
preferredDuringSchedulingIgnoredDuringExecution:软性偏好,不满足也能调度,但满足的节点会获得更高分数。可以设置weight权重,实现"尽量调度到某个区域"的效果。
3.3 Pod亲和性与反亲和性
节点亲和性解决的是"Pod往哪些节点上放",而Pod亲和性解决的是"Pod和哪些Pod放一起"。
Pod亲和性(podAffinity):把相关联的Pod调度到一起。典型场景:Web应用和它依赖的Redis缓存放在同一可用区,减少跨机房网络延迟。
yaml
spec:
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values:
- redis
topologyKey: kubernetes.io/hostname
Pod反亲和性(podAntiAffinity):把互斥的Pod分散开。典型场景:同一个应用的多个副本不要调度到同一台机器,避免单点故障。
yaml
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- web
topologyKey: kubernetes.io/hostname
这里有个非常重要的参数:topologyKey 。它决定了"分散到什么粒度"------kubernetes.io/hostname表示按节点分散,topology.kubernetes.io/zone表示按可用区分散。生产环境建议至少做到按节点分散,核心服务按可用区分散。
3.4 污点与容忍:节点的"门禁系统"
污点(Taint)是打在节点上的标记,表示"这个节点有特殊属性,默认不接受普通Pod"。容忍(Toleration)是Pod的声明,表示"我能忍受这个污点"。
bash
# 给节点打污点:不调度普通Pod
kubectl taint nodes node1 dedicated=special:NoSchedule
# 查看节点污点
kubectl describe node node1 | grep Taints
污点有三种效果(effect):
- NoSchedule:不调度新Pod(已运行的不管)
- PreferNoSchedule:尽量不调度,但不是硬性拒绝
- NoExecute:不仅不调度新Pod,还会驱逐已经运行且不容忍的Pod
对应的容忍示例:
yaml
spec:
tolerations:
- key: dedicated
operator: Equal
value: special
effect: NoSchedule
生产实战经验 :Master节点默认带有node-role.kubernetes.io/master:NoSchedule污点,这就是为什么普通Pod不会跑到Master上。如果想利用Master节点资源,可以给特定Pod加容忍,但一定要谨慎------Master挂了的代价远高于省下的那点资源。
3.5 拓扑分布约束:让Pod"雨露均沾"
拓扑分布约束(topologySpreadConstraints)是Kubernetes 1.19+引入的调度策略,用于控制Pod在集群中的分布均匀度:
yaml
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: web
这段配置的意思是:app=web的Pod在节点上的分布偏差(skew)最大为1。比如集群有3个节点,10个副本,那么每个节点上Pod数量的差距不能超过1------大致是3、3、4的分布,而不是8、1、1。
whenUnsatisfiable有两个取值:
- DoNotSchedule:无法满足时不让调度(硬约束)
- ScheduleAnyway:无法满足时尽量均匀(软约束)
这个功能在滚动发布、多可用区部署时特别有用,能避免"所有新副本都挤到一个节点"的尴尬。
四、自定义调度器:当默认调度器不够用时
4.1 为什么要自定义调度器
默认调度器已经很强大了,但总有它覆盖不到的场景:
- 业务需要感知数据本地性:比如离线计算任务希望调度到数据所在节点
- 需要批量调度:一组Pod要么全部调度成功,要么全部失败(gang scheduling)
- 需要感知自定义资源:比如GPU显存、特殊加速卡
- 需要实现多租户配额之外更细粒度的资源控制
4.2 三种自定义方式
方式一:扩展默认调度器(推荐)
Kubernetes 1.19+支持调度框架(Scheduling Framework),可以编写调度插件注入到默认调度器的过滤/打分/绑定等扩展点:
go
type MyScorePlugin struct{}
func (p *MyScorePlugin) Name() string { return "MyScore" }
func (p *MyScorePlugin) Score(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) {
// 自定义打分逻辑:比如优先选择负载最低的节点
return 100, nil
}
编译成二进制后,通过调度器的配置文件启用:
yaml
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler
plugins:
score:
enabled:
- name: MyScore
方式二:独立的自定义调度器
实现一个独立的调度器,监听Pod并自行决定绑定。最简单的写法是用client-go:
go
// 伪代码:监听未调度的Pod,按自定义规则选节点,然后绑定
for pod := range podInformer.Informer().Run() {
if pod.Spec.NodeName == "" && pod.Spec.SchedulerName == "my-scheduler" {
node := myPickBestNode(pod) // 自定义选节点逻辑
bindPod(pod, node) // 调用Binding API
}
}
Pod通过schedulerName字段指定使用哪个调度器:
yaml
spec:
schedulerName: my-scheduler
方式三:调度器插件市场
社区有一些成熟的第三方调度器可以直接用,比如:
- Volcano:主打批量任务和gang scheduling,适合AI/大数据场景
- Koordinator:阿里开源的混部调度器,支持QoS感知和资源超卖
- Descheduler:严格说它不是调度器,而是"再调度器",负责把不合理的Pod重新调度
4.3 多调度器共存
一个集群可以同时运行多个调度器,通过schedulerName区分。默认调度器处理不指定schedulerName的Pod,自定义调度器处理指定自己的Pod。两者互不干扰,这在灰度验证自定义调度器时特别有用。
五、生产环境排障实战
5.1 Pod一直Pending怎么办
这是最高频的问题。排查步骤:
bash
# 第一步:看Pod状态和事件
kubectl describe pod <pod-name>
# 第二步:看调度器日志
kubectl logs -n kube-system kube-scheduler-<master-name>
常见的Pending原因:
- 资源不足 :所有节点都不满足requests。事件里会显示
0/5 nodes are available,此时要区分是CPU还是内存不足,考虑扩容节点或调低requests - 节点有污点且Pod无容忍 :事件会提示
node(s) had taint - nodeSelector/nodeAffinity不匹配:事件会提示节点不满足选择器
- PVC未绑定:Pod依赖的PVC处于Pending状态,调度器会等PVC就绪
- 端口冲突:Pod要用的hostPort已被占用
5.2 调度不均衡怎么办
某台节点负载特别高,其他节点很闲?检查:
- 是否有Pod反亲和性缺失:同一个Deployment的副本被调度到一起
- 是否设置了合理的requests:如果所有Pod的requests都写得很低,调度器无法准确评估节点负载
- 是否启用了拓扑分布约束:建议核心服务都加上topologySpreadConstraints
- 是否使用Descheduler定期整理:调度器只在Pod创建时决策一次,运行中的Pod不会自动迁移,Descheduler可以帮我们把不合理的调度纠正过来
5.3 调度延迟高的优化
如果Pod从创建到Running耗时明显变长:
- 检查调度队列 是否堆积(调度器metrics:
scheduler_queue_depth) - 检查API Server响应是否变慢(调度器的每次决策都要读写etcd)
- 考虑升级调度器版本,新版本在调度吞吐上有持续优化
六、总结与最佳实践
调度是Kubernetes里"看不见但极其重要"的能力。结合我的生产经验,给你几条落地建议:
- 所有工作负载都要写合理的requests:这是调度准确性的基石,不写requests的Pod会让调度器"盲人摸象"
- 核心服务必须配Pod反亲和性:至少按hostname分散,避免同节点故障导致全部挂掉
- 用拓扑分布约束替代手动打标签:更优雅、更自动
- Master节点不要轻易去污点:除非你清楚知道后果
- 自定义调度器是最后手段:先确认默认调度器+调度框架插件能否满足需求
- 监控调度指标:队列深度、调度时延、失败原因,这些指标能帮你提前发现问题
调度器就像Kubernetes的"交通指挥中心",它不直接干活,但它的每个决策都影响着整个集群的效率和稳定性。把调度搞明白了,你的集群才算真正"活"了起来。