☰
Kubernetes调度全解析:从kube-scheduler原理到Pod调度策略实战
2026/10/3 16:55:47 网站建设 项目流程

1. 从“挖坑”说起:为什么每个k8s玩家迟早都要面对调度

聊k8s(Kubernetes)的人越来越多,但真正能把“调度”这件事讲清楚的不多。很多人部署完集群,天天忙着折腾网络插件、存储插件、Ingress,等到Pod莫名其妙卡在Pending状态,才第一次正视这个名字有点奇怪的组件——kube-scheduler。我见过不少运维和开发,平时写YAML写得飞起,一遇到Pod调度不过去就只会看events,然后两眼一抹黑。

这篇文章就用“在k8s调度的花园里面挖呀挖”这个梗,把调度这个主题从里到外刨一遍。不管你是刚搭好集群的新手,还是已经踩过无数调度坑的老手,我希望你看完之后能搞明白这几件事:k8s调度器到底在做什么、它是怎么做决策的、你能在哪些环节插手、以及最常见的调度问题到底怎么排查。

我默认你会一点k8s基础操作,至少知道Pod、Node、Deployment这几个概念。如果你还没接触过,建议先花半小时跑一遍kubectl get nodes和kubectl get pods,再回来读这篇文章,体验会好很多。

2. 调度的本质:把Pod放到哪台机器上,这是一道“筛选+打分”题

2.1 调度器不是随随便便把Pod扔给一台机器

你在k8s里创建一个Deployment,控制器(Deployment Controller)负责保证副本数,但它不负责把Pod放到具体哪台节点上。这个“放哪儿”的决定,就是调度器(kube-scheduler)的职责。

Kubernetes集群里的节点(Node)是异构的,有些机器CPU强、有些内存大、有些挂着GPU、有些在特定可用区。Pod对资源的需求也五花八门,有的要求至少2核4G,有的要绑定某块硬盘,有的因为延迟敏感必须跑到离数据最近的机器上。调度器的任务就是:在所有节点中,找一个“最合适”的节点,把Pending的Pod放上去。

我习惯把调度比作选房子:你手里有一堆房源(节点),你有一堆要求(资源需求、位置需求、互斥需求),中介(调度器)先帮你过滤掉不符合硬性条件的房子,再按照你的偏好给每套房子打分,最后挑出分数最高、性价比最好的那一套签约搬进去。这个过程,在k8s里对应两个核心阶段:过滤(Filtering)和打分(Scoring)。

2.2 过滤阶段(Predicates):先把不合适的节点剔除

过滤阶段也叫Predicates(预选),它的职责是判断“这个节点能不能跑这个Pod”。任何一个条件不满足,节点直接淘汰。常见的过滤条件包括:

  • 资源充足性:节点剩余CPU、内存是否满足Pod的requests请求值。
  • 端口冲突:Pod想用宿主机某个端口(hostPort),但该端口已被其他Pod占用。
  • 节点状态:节点是否Ready、是否被标记为不可调度(SchedulingDisabled)。
  • 亲和性规则:节点是否满足nodeSelector、NodeAffinity表达式。
  • 污点容忍:节点带污点(Taint),Pod是否声明了对应的容忍(Toleration)。
  • 卷和存储限制:Pod引用的存储卷是否在节点上可用,比如Local PV只绑定特定节点。
  • 拓扑约束:比如Pod AntiAffinity要求同节点不能有另一个带特定标签的Pod。

这个阶段快、狠、准,能过滤的节点直接出局,不会给任何商量余地。很多你写YAML时没注意的细节,就是在这里把Pod卡死的。

2.3 打分阶段(Priorities):在可选范围内选出“最优解”

过了过滤阶段之后,剩下的节点都是“能跑”的,但“能跑”不等于“跑得最好”。打分阶段会把每个候选节点按一系列权重策略打分,分值最高的节点胜出。常见的打分策略包括:

  • LeastRequestedPriority:节点空闲资源越多分越高,倾向于把Pod散开到资源富裕的机器上。
  • BalancedResourceAllocation:CPU和内存的使用率越均衡,分越高,避免出现CPU打满内存闲着或者反过来。
  • ImageLocalityPriority:节点上已经有所需镜像的加分,省去拉镜像的时间。
  • NodeAffinityPriority:满足亲和性偏好(preferred)的节点加分。
  • InterPodAffinityPriority:满足Pod间亲和/反亲和的节点加减分。
  • TaintTolerationPriority:对污点容忍度好的节点加分,尽量避免把Pod调度到有污点的机器上。

这个阶段是“择优”,没有绝对的对错,不同策略之间权重不同,最终结果也不同。社区经常说的“调度器版本差异导致同样的Pod被调度到不同节点”,根源就在这里——打分策略在不同版本里可能被调整过。

2.4 调度器不负责“运行”,只负责“安排”

这里有个新手容易混淆的点:kube-scheduler只负责给Pod选好节点,并把Pod和节点的绑定关系(Binding)写入etcd。真正把Pod拉到节点上并启动容器的,是节点上的kubelet。

所以你会看到这样的现象:调度器明明给Pod选好了节点,但Pod到了节点上启动失败,比如镜像拉不下来、卷挂载失败。这类问题严格来说不属于调度问题,而是kubelet层面的事情。排查时候要分清界线:Pod卡在Pending,才找调度器;Pod已经Bound但ContainerCreating,就得去看节点上的kubelet日志了。

3. 动手实践:从默认调度器到自定义调度策略

3.1 先看默认调度器怎么工作

在学习怎么干预调度之前,最好先亲眼看一下默认调度器的运行过程。一个有用的方法是给调度器开日志,或者直接看Pod的调度事件:

kubectl describe pod <pod-name>

输出里有Events这一段,会明确记录:

  • 调度器何时开始调度
  • 选中的节点是哪台
  • 是否发生过抢占(Preemption)
  • 有没有成功绑定

有一次我遇到一个Pod一直在Pending,describe之后发现事件里写着“0/5 nodes are available”,下面列了每个节点失败的原因——有的节点是“Insufficient memory”,有的是“node(s) had taint...”,一目了然。这种排查方式效率极高,比盲猜快得多。

3.2 用nodeSelector做最简单的定向调度

nodeSelector是最原始的调度干预方式,说白了就是给节点打标签,然后在Pod里指定要调度到带哪些标签的节点上。先给节点打标签:

kubectl label nodes node01 disktype=ssd

然后Pod里这样写:

apiVersion: v1 kind: Pod metadata: name: test-pod spec: nodeSelector: disktype: ssd containers: - name: nginx image: nginx

这样调度器在过滤阶段就会把不带disktype=ssd标签的节点全部排除。

不过nodeSelector太“简单粗暴”了,它只能做精确匹配。比如你想表达“优先调度到ssd节点,但没有ssd节点时也可以放到普通节点”,nodeSelector做不到。这种场景就要用NodeAffinity。

3.3 NodeAffinity:从“必须”到“最好”的灵活表达

NodeAffinity有两大类型:

  • requiredDuringSchedulingIgnoredDuringExecution:硬性要求,相当于进阶版nodeSelector,调度时不满足直接不调度。
  • preferredDuringSchedulingIgnoredDuringExecution:软性偏好,满足加分,不满足也不排除,只是分低。

写法示例:

spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: disktype operator: In values: - ssd preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 preference: matchExpressions: - key: zone operator: In values: - east

这个例子里,Pod必须落到disktype=ssd的节点上,但如果同时满足zone=east,会被优先选择。权重weight可以理解为打分阶段的附加分,数值越大影响力越大。

这里要注意命名里那段很长的后缀IgnoredDuringExecution,它的意思是:调度阶段规则生效,但如果节点在运行过程中标签变了,导致规则不再满足,k8s也不会把已经跑在上面的Pod赶走。理解这个语义很重要,才不会在运维时被现象误导。

3.4 Taint和Toleration:节点怎么“拒绝”Pod

NodeAffinity是Pod主动选择节点,Taint则反过来,是节点主动拒绝Pod。节点上打了污点,默认任何Pod都不能调度上去,除非Pod声明了对应的容忍。

给节点打污点:

kubectl taint nodes node02 gpu=true:NoSchedule

Pod声明容忍:

spec: tolerations: - key: "gpu" operator: "Equal" value: "true" effect: "NoSchedule"

这个机制最常见的应用场景:给集群里的GPU节点打污点,只有声明了GPU需求的Pod才能调度上去,其他普通Pod全部绕开。

污点的effect有三种:

  • NoSchedule:不调度新Pod上去,已有Pod不受影响。
  • PreferNoSchedule:尽量不调度,但实在没地方放了也会放。
  • NoExecute:不仅不调度新Pod,还会把节点上已有、且不匹配的Pod全部驱逐。

我把三者比喻成物业的三种管理风格:NoSchedule是“别再带新租客来了”,PreferNoSchedule是“尽量别带新租客,老租客不赶”,NoExecute是“新租客不让进,老租客也请搬走”。使用效果要区分清楚,特别是在做节点维护、下线和故障隔离时,很多人因为混淆了NoSchedule和NoExecute的差异,导致节点上存量Pod在维护时被强制驱逐,影响了业务。

3.5 节点亲和性和污点容忍的组合拳

实际生产环境里,这几种策略往往配合使用,而不是单独出现。

比如一个常见的机器学习平台场景:集群里有8台CPU机器、2台GPU机器。GPU机器打了污点gpu=true:NoSchedule,GPU任务Pod声明对应容忍,并加nodeSelector强制落到gpu=true的节点上。CPU通用服务则不需要容忍,自然不会被调度到GPU机器上。这样一套组合下来,资源隔离清晰、互不干扰。

再比如数据库类的有状态应用,你要求它和另一个特定应用尽量处于同一可用区,减少跨可用区网络延迟,可以用PodAffinity让它们“粘”在一起;同时又要求它们不能部署在同一台物理机上,避免单点故障,属于典型的“既要亲和又要反亲和”的调度需求,在k8s里这两件事可以同时表达,调度器会综合考虑。

3.6 自定义调度器:当默认调度器不能满足你的时候

Kubernetes可以运行多个调度器。默认的kube-scheduler处理大部分工作负载,额外部署一个自定义调度器处理特殊需求,互不干扰。

Deployment里指定调度器:

spec: schedulerName: my-scheduler

如果Pod里写了schedulerName: my-scheduler,默认调度器会直接跳过它,由同名自定义调度器接管。

自定义调度器的写法可以是简单的Shell脚本加kubectl,也可以是基于scheduler framework的扩展插件。我见过有人用几百行Python写一个满足内部需求的调度器,逻辑就是轮询所有Pending Pod,检查资源、亲和性,然后调k8s API执行绑定。这种做法的价值在于:当你对默认调度策略实在不够满意、又不想啃一堆Go代码时,快速撸一个符合自己业务逻辑的调度器完全可行。

不过这里我得泼一盆冷水:自定义调度器等于你自己接管了“容器放哪里”的所有责任,调度失败、资源碎片化、节点负载不均这些问题都要你自己扛。如果你只是需要一个调度策略上的微调,优先考虑给默认调度器加扩展点(Scheduler Extender)或者直接用上面的Affinity/Taint原生能力,不要一上来就自研调度器。

3.7 PodTopologySpreadConstraints:让Pod在拓扑间均匀分布

还有一个比较现代的能力,叫PodTopologySpreadConstraints,它解决的是“Pod在节点/可用区之间分布不均”的问题。默认调度器打分会考虑资源,但不会特意把同一应用的Pod均匀摊开。如果你有3个节点,跑3个副本,很可能3个Pod全被丢到同一台机器上——虽然资源都满足,但一台挂了业务全挂。

通过声明拓扑分布约束,可以让调度器尽量把Pod分散开:

spec: topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: web

这段配置的意思:以节点主机名为拓扑维度,让同一app=web的Pod在不同节点上的数量差最多不超过1(maxSkew=1)。如果不满足,就干脆不调度(DoNotSchedule),等有合适节点了再调度。把whenUnsatisfiable换成ScheduleAnyway,则是“尽量均匀但别卡死”。

这个功能特别适合多可用区部署的场景,比如公有云上你希望Pod在3个可用区各放一个,防止整个可用区故障导致服务不可用。这时候topologyKey用topology.kubernetes.io/zone,比手动维护nodeSelector简单得多。

4. 调度的“隐藏玩法”:优先级、抢占与批处理

4.1 优先级高的Pod会挤掉低优先级的吗

Kubernetes里Pod有优先级概念,PriorityClass定义了优先级级别。当高优先级Pod调度不进去时,调度器会触发抢占(Preemption):把节点上低优先级的Pod挤掉,腾出资源给高优先级Pod。

PriorityClass示例:

apiVersion: scheduling.k8s.io/v1 kind: PriorityClass metadata: name: high-priority value: 1000000 globalDefault: false description: "用于核心业务Pod"

Pod里引用:

spec: priorityClassName: high-priority

这里有几个容易踩坑的细节:

  • PriorityClass的value越大,优先级越高。
  • 抢占只能发生在节点资源不足时,如果高优先级Pod调度过去了但低优先级Pod还在Terminating过程中,需要等被挤掉的Pod退出后,新Pod才真正启动。这个中间态容易让人误以为“抢占不生效”。
  • 被抢占的Pod是优雅退出还是被强制kill,受terminationGracePeriodSeconds影响。
  • 如果节点上所有低优先级Pod都被挤掉了还是不够放,调度器会尝试在其他节点抢占,如果都没有合适的节点,新Pod继续保持Pending。

我用这个机制做过一次线上故障紧急扩容:核心接口的Pod因为节点资源不足一直调度不上去,我给它们加了高优先级PriorityClass,几十秒内调度器挤掉了一批批任务型Pod,核心Pod成功跑起来。但那批批任务Pod被中断的代价也是真实的,所以不要随便给所有Pod都设高优先级,优先级膨胀会导致低优先级任务常年饿死。

4.2 批处理场景:把Pod塞满还是打散

工作负载类型不同,对调度的诉求可能完全相反。在线Web服务通常希望Pod分散开,避免单点故障;批处理任务(比如跑模型训练、数据清洗)反而希望把多个Pod尽量聚在一起,利用本地数据、本地缓存提升性能。

批处理场景最经典的需求是“把任务调度到已经缓存了数据的节点上”,如果那台节点资源不够,宁可等也不能去别的节点重新拉数据。这个诉求用nodeAffinity、taint/toleration、PodAffinity组合基本都能实现。

还有一个痛点:批处理任务通常短时、量大、创建销毁频繁,默认调度器每个Pod独立评分、独立决策,会产生资源碎片。社区有专门针对批处理的调度器(如Volcano、Kueue),我建议团队资源允许时考虑引入,用它对Gang Scheduling的支持来保证“要么一堆Pod全部调度成功,要么全部等待”,这对跑分布式训练非常有用——试过的人都知道,一半Pod在跑、一半Pod卡Pending是最让人崩溃的场景。

4.3 云边协同下的调度新挑战

随着边缘计算场景越来越多,调度不再只是“集群内部选一个节点”的问题,而是要跨云、跨边、跨网络层层协同。云边场景下,中间节点网络不稳定、算力有限、离线自治需求突出,传统调度器的一套模型很难直接搬过去。

现在比较常见的做法是在云端部署中心控制面,边缘侧通过分布式调度或缓存机制处理局部调度决策。数据面和控制面要分离,边缘节点即使断网,本地的任务调度、重启、拉起等操作也要能自主运行。这个方向正在快速迭代,我先提个思路,后面有机会单独写一篇展开。

5. 调度体质的养生法:资源申请、PodDisruptionBudget与调度失败排查

5.1 requests和limits:调度决策的基础数据

调度器判断资源是否充足,依据是Pod声明的requests,而不是实际运行时消耗的资源。也就是说,一个Pod运行时只用了0.1核CPU,但如果它声明requests为4核,调度器会按照它需要4核来预留资源。

所以写YAML时requests一定要贴近真实需求,写大了浪费集群资源,写小了可能引发CPU节流(throttling)甚至OOM Kill。limits是上限约束,超过之后会被杀死(内存)或限流(CPU)。

我见过一个最典型的资源浪费例子:某个服务真实内存用量只有200M,但YAML里requests写的是2Gi,整个集群几十个副本白白浪费了几十G内存调度容量。之后我把所有服务的requests按过去两周监控的P95用量重新校准,集群容量立刻多出来一大截。这里再补充一个经验:每次调整requests后,观察一段时间Pod是否出现OOM或频繁驱逐,如果没有,说明调整方向正确。

5.2 PodDisruptionBudget:主动驱逐时的保命符

当你要主动对节点做维护(比如升级内核、更换硬件、调整OS参数),通常需要把节点上的Pod驱走。如果不加任何保护,k8s一次可能把所有副本全部驱逐,造成服务不可用。

PodDisruptionBudget(PDB)就是干这个的:

apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: web-pdb spec: minAvailable: 2 selector: matchLabels: app: web

这个配置表示:主动驱逐时,app=web的Pod至少要保持2个可用。或者你也可以用maxUnavailable: 1表示最多允许1个不可用。

注意PDB只约束主动驱逐(比如drain节点),不约束节点宕机、抢占之类的情形。我多次在节点维护时遇到过类似状况:节点驱逐Pod之后一直不完成,查看PDB发现被卡住了,因为另一台节点上的Pod也处于不健康状态,可用数量达不到PDB阈值,导致节点drain死等。这时候要么先把不健康的Pod修好,要么临时把PDB调低,让维护流程继续。

5.3 调度失败排查思路:一个标准化的五步走流程

针对这类问题,我总结了一个自己的排查顺序,刚开始接触k8s调度的朋友可以直接照搬:

第一步:看Pod状态和事件

kubectl describe pod xxx永远是第一手信息,Events字段基本能把调度器拒绝的原因写清楚。如果事件里没有调度器相关内容,有可能是写错了schedulerName导致没有对应的调度器在处理。

第二步:看节点资源水位

kubectl top nodes看一下各节点剩余资源是否真的满足Pod的requests。这里特别注意:kubectl top看到的是实时使用量,而调度器判断依据的是已声明的requests总量。有可能节点CPU实时用量才30%,但所有Pod声明的requests已经把CPU给分配完了,新Pod依然调度不进去,这不是调度器坏了,而是资源模型设计如此。

第三步:检查亲和性和污点

用kubectl get nodes --show-labels看节点标签是否齐全,用kubectl describe node看节点Taints。很多时候是节点维护时打了污点,或者新增节点忘打标签,导致Pod的亲和性要求不能被满足。

第四步:检查存储卷

涉及PVC的Pod,调度器会检查PV的节点可用性。如果用的是Local PV,Pod只能调度到PV所在节点,其他节点全部过滤掉。如果那台节点资源不够,Pod就会一直Pending。

第五步:看是否有优先级和抢占逻辑干扰

高优先级Pod会打断正常的调度流程。遇到Pod Pending,先查集群是否有高优先级Pod在等待,它们可能正在触发抢占,导致了调度器行为和你预期的不一致。

5.4 常见调度问题速查表

现象常见原因排查方向
Pod一直Pending、无事件更新schedulerName写错、自定义调度器没部署describe看schedulerName,查调度器Pod状态
事件提示节点资源不足requests声明过大、节点配置过小kubectl describe node看Allocated resources
事件提示节点有污点节点维护、GPU专用、专有节点kubectl get nodes -o json | jq '.items[].spec.taints'
部分节点被过滤但原因不明NodeAffinity写错、标签不匹配对比Pod亲和语法和节点标签
高优先级Pod抢占后仍在Pending被抢占Pod尚未完全退出、抢占目标节点不够观察系统事件、查看被抢占Pod的Terminating状态
Pod调度到节点后启动缓慢镜像拉取量大、存储卷挂载慢节点上查看kubelet日志和容器运行时日志
应用副本数正常但负载不均没有TopologySpreadConstraints、打分策略天然分布检查Pod分布,考虑引入topologySpreadConstraints

这张表建议大家截图收藏,遇到问题先对着表看一遍,90%的调度异常都能对号入座。

5.5 集群规模大时的性能问题

调度器在大型集群里也会遇到瓶颈。一个数千节点、数万Pod的集群里,调度器每次调度都要对所有节点做过滤和打分,计算量不小。早期版本出现过调度性能下降、调度时延变长的问题,新版本针对性地优化了调度队列和缓存机制,但架构上依然要求我们尽量控制集群规模。

实践经验是把集群拆成多个小集群,按业务域隔离,每个集群几百到一千节点,性能和运维复杂度都在可控范围内。如果你确实需要在单集群里跑超大规模,建议认真给调度器做参数调优,设置合理的percentageOfNodesToScore值,限制打分节点比例,避免每次调度都遍历全部节点。

6. 我的一点体会

折腾k8s调度这几年,最大的感受是:调度器本身并不复杂,复杂的是你对自己的业务和底层资源到底有多少理解。你知道每个Pod需要多少资源、能容忍什么故障、必须靠近什么数据,调度策略自然清晰;反过来,什么都不清楚就指望调度器“自动优化好一切”,大概率只能得到一堆无序散落的Pod。

Taint、Affinity、TopologySpreadConstraints、PriorityClass、PDB,这些能力每一样单独看都不难,难的是组合运用。我建议你现在就打开自己集群里的Pod定义,看一遍每个字段有没有把资源预留、打散策略、故障域隔离这套组合拳打完整。补齐了这些,你再回头看那句“在k8s调度的花园里面挖呀挖”,会发现花园里能挖的东西,确实还有很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询