K8s调度实战:从Pod Pending到kube-scheduler故障排查与策略优化
2026/9/22 6:36:06 网站建设 项目流程

一直有个有意思的现象:很多人k8s集群搭起来了,Node也加入集群了,然后高高兴兴去部署一个服务,结果Pod死死卡在Pending,describe一看满屏的FailedScheduling。这时候才开始琢磨,k8s底层的调度到底是怎么工作的?为什么明明看着有很多空闲节点,Pod就是起不来?

把这个过程想成在花园里种花其实很贴切。你有若干块地(Node),手上有一堆种子(Pod),每颗种子还写了各自的种植要求:要阳光充足的地块(节点标签)、不能和某种作物种在一起(Pod反亲和)、这块地上必须没有害虫(污点容忍)、最好优先选之前种过类似作物的地方(镜像本地优先)。而kube-scheduler就是那个拿着种子、眼观六路的老园丁,负责决定把每颗种子放到哪块地上。

这篇文章就把k8s调度这摊事从原理到实战完整梳理一遍,包括调度器的决策链路、常用的定向调度手段、污点容忍机制的深坑、以及Pod一直Pending时怎么一步步定位问题。适合刚接触k8s的运维同学,也适合已经跑了一段时间但遇到调度问题就抓瞎的开发者。

1. 调度到底在解决什么问题

1.1 “把Pod放到哪”才是调度的本质

先绕开那些术语。k8s集群里有master节点,有worker节点,这只是物理角色上的划分。真正决定一个Pod在哪个Node上运行的,是一套独立的逻辑决策机制,这套机制在k8s里叫调度(Scheduling)。

调度做的最核心一件事是:为新创建且尚未绑定Node的Pod,找到一个合适的Node。这个“合适”不是拍脑袋定的,而是经过一连串约束检查之后的结论。约束包括但不限于:Node的CPU和内存是否够用、Pod声明的端口是否和Node上已有端口冲突、Node上有没有对应的标签、Node的污点是否被Pod容忍、Pod之间有没有亲和或反亲和的硬性要求。

很多人把调度和容器运行时搞混,以为Pod启动不起来是因为镜像拉不到或者Docker有问题。其实调度失败和容器启动失败是两个完全不同的阶段。调度器只需要决定“这个Pod去哪个Node”,一旦决定就会写入Pod对象的nodeName字段,接下来Node上的kubelet才会接手,去拉镜像、起容器。所以当你看到Pod一直Pending,大概率是调度阶段出了问题,跟镜像仓库、运行时状态没有关系。

1.2 集群里的资源坐标:Node和Pod靠什么关联

要理解调度器的决策,得先理解Node和Pod之间是通过什么东西关联起来的。

第一层关联是标签。Node可以打任意标签,比如disktype=ssdzone=east-1agpu=true。这些标签本身没有任何业务含义,纯粹是为了给调度器提供决策依据。Pod通过nodeSelector、nodeAffinity来表达对标签的需求。这是最关键的关联手段。

第二层关联是污点与容忍。Node上的污点类似“我不欢迎某类Pod”,而Pod上的容忍表示“我不怕这个污点”。注意,这里不是按标签匹配的,而是按键值、效果(effect)精确匹配的。

第三层关联是资源量。Node上有可分配的资源总量,Pod上有requests和limits声明。调度器按照requests做判断,而不是看limits,更不是看容器进程实际占用多少。这是一个非常容易误解的地方:CPU 500m指的是调度时候需要保证的最小请求量,不是限定上限;而limits更多是运行时对单个Pod的资源限制。

理解了这三层关联,调度器的工作就变得清晰了:先把所有满足硬性条件的Node筛出来,再从这些Node里依据各种打分规则挑一个得分最高的,然后把Pod绑上去。

2. kube-scheduler的工作流程拆解

2.1 从watch到bind:一次调度的完整链路

kube-scheduler这个组件平时以静态Pod或者独立二进制进程运行在master节点上。它通过监听(watch)API Server上的Pod、Node、Service相关变化来触发调度逻辑。

一次完整的调度周期分为两段:调度周期和绑定周期。调度周期内部是那一堆过滤、打分逻辑;绑定周期则是把调度结果写回API Server,给Pod设置nodeName并创建绑定关系。这两个周期加起来通常要求在毫秒级到秒级完成,不然会影响整体调度效率。

具体执行顺序是:

  1. watch到新Pod被创建,此时Pod没有任何nodeName字段,也没有调度器选择的痕迹。
  2. 调度周期开始,先把Pod放入调度队列。
  3. 对集群里每一个Node执行过滤逻辑,得到候选Node列表。
  4. 如果候选列表为空,进入抢占逻辑:看是否能驱逐一些低优先级Pod来腾出资源。能则抢占,不能则等待。
  5. 对候选Node执行打分逻辑,按得分排序。
  6. 绑定周期开始,将Pod绑定到得分最高的Node上。
  7. API Server返回绑定成功,Node上的kubelet看到nodeName已经指向自己,开始干活。

这里有个细节值得注意:调度器读到的“当前资源使用情况”可能已经过期。因为调度器看到的是API Server存储的状态,而节点真实的资源使用由kubelet定期上报。所以实际生产里偶尔会出现调度器把一个Pod分配到某个节点、但节点的实际负载已经比较高的情况。这也是为什么光靠默认调度还不够,需要结合监控数据做均衡。

2.2 过滤阶段:先把不合格的节点筛掉

过滤阶段由一组调度插件完成,任何一个插件失败,节点就直接出局。这个阶段的特点是“一票否决制”,条件极苛刻,任何硬性约束不满足都不行。

常见的过滤插件包括:

  • NodeResourcesFit:检查Node剩余可分配资源是否满足Pod的requests。
  • NodeName:检查Pod是否指定了spec.nodeName,指定了就只能用这个节点。
  • NodePorts:检查Pod声明的hostPort是否和Node上已有的端口冲突。
  • NodeAffinity:检查Node的标签是否满足Pod声明的nodeAffinity硬性要求。
  • TaintToleration:检查Pod是否容忍Node上的污点。
  • PodTopologySpread:检查Pod分布是否满足拓扑分布约束。

举个例子,一个Pod声明了spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution,要求节点必须带有disktype=ssd标签。这时候调度器会把全集群没有这个标签的Node全部过滤掉,哪怕这些节点资源再充足也不会考虑。

2.3 打分阶段:从剩下的节点里选最优

过滤完成后,如果候选节点不止一个,就进入打分环节。打分插件会给每个Node计算一个0到100的分数,最终总分由多个维度加权累加。这个权重无法在运行中动态修改,要看各插件自身的实现。

我这里列几个对决策影响最大的打分维度:

  • NodeResourcesLeastRequested:优先选择资源请求量占比低的节点。这个策略默认权重很高,所以Pod密集程度越低的节点越容易被选中。
  • NodeResourcesBalancedResourceAllocation:优先选择CPU和内存利用率更均衡的节点。如果你有一个Node CPU快满了但内存很空闲,另一个Node CPU和内存都中等,后者分数通常更高。
  • NodeAffinityPriority:匹配上Pod声明的preferred亲和规则时加分。注意这里是“尽量匹配”,不满足也不会被过滤掉。
  • ImageLocalityPriority:Node本机已经存在Pod所需镜像的加分。这也是为什么新扩容的Pod经常被调度到之前运行过的同一个节点上——镜像已经拉好了,启动更快。
  • InterPodAffinityPriority:满足Pod间亲和或反亲和规则时调整分数。

当同时存在多个Node得分相同,调度器会按节点名称的哈希结果随机选择一个作为最终目标。这种微观上的不确定性,宏观上反而避免了所有Pod都挤向同一个Node的“羊群效应”。

3. “种花”工具包:常用调度策略怎么选

3.1 nodeSelector:最简单的定向播种

如果只想让某个工作负载固定跑到一组打了特定标签的节点上,nodeSelector是最直白的手段。用法是在Pod模板里写:

spec: nodeSelector: disktype: ssd

这个字段的意思是:Pod必须调度到带disktype=ssd标签的节点上。没有匹配节点,Pod就一直Pending。它的问题是表达能力很弱,只能做等值匹配,没法表达“非某个值”“大于某个值”“在某个集合内”这类条件。

实际操作中我的经验是:nodeSelector适合在业务初期快速划分节点池。比如把GPU节点统一打上gpu-node=true的标签,凡是需要GPU的推理服务就在模板里加这一行。节点池规模不大、条件不复杂的时候,nodeSelector比亲和性更容易被人看明白,排障成本也低。

3.2 nodeAffinity:更灵活的表达方式

nodeAffinity是对nodeSelector的升级,它把匹配语法扩展成了In、NotIn、Exists、DoesNotExist、Gt、Lt等操作符,并且区分硬性要求和软性要求。

硬性要求写成requiredDuringSchedulingIgnoredDuringExecution,如果不满足,Pod不会被调度。软性要求写成preferredDuringSchedulingIgnoredDuringExecution,不满足也可以调度,只是打分会低一些,排序落后。这个“DuringScheduling”和“IgnoredDuringExecution”的含义是:只在调度时刻检查节点标签;Pod运行之后,即使节点标签变了,也不会把Pod挪走。k8s从1.16前后一直沿用这个命名,看着绕口,但语义上没有歧义。

一个典型使用场景是两套可用区:

spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: zone operator: In values: ["east-1a", "east-1b"] preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 preference: matchExpressions: - key: disktype operator: In values: ["ssd"]

这段配置表达的意思很清楚:只能调度到zoneeast-1aeast-1b的节点(硬性),相同条件下优先选disktype=ssd的节点(软性)。软性条件的weight(权重)越大,在打分阶段对结果的影响越大。

实际操作中容易踩的一个坑是:硬性亲和和nodeSelector同时存在时,条件是And关系。也就是说两者都必须满足,不是任选一个。这在一些同学的认知里是反直觉的,排障时如果发现Pod始终无法调度,记得两种条件都列出来检查一遍。

3.3 podAffinity与podAntiAffinity:让Pod做邻居还是做冤家

podAffinity解决的是“我希望Pod和另一组Pod待在同一个拓扑区域”。这个拓扑区域可以是一个Node,也可以是一个可用区。podAntiAffinity正好相反,解决的是“我希望Pod和另一组Pod不要待在同一个拓扑区域”。

这两个机制最有价值的场景是两个:一个是把互相之间访问极其频繁的服务放在一起降低延迟;另外一个更重要,把高可用副本尽量打散,避免一个节点挂掉导致整个服务的所有副本全部不可用。

比如要把应用的两个副本分散到不同节点:

spec: affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchExpressions: - key: app operator: In values: ["web-server"] topologyKey: kubernetes.io/hostname

这段配置的意思是:调度的时候尽量避开已经运行了app=web-server的Pod的节点。topologyKey指按节点的kubernetes.io/hostname这个标签来计算拓扑域。如果拓扑域是主机名,那语义就是“不要和同一应用的Pod跑在同一个主机上”。这是高可用部署里最常用的组合。

有一个必须提醒的点:把podAntiAffinity配成硬性(required)会带来一个隐患。如果服务期望副本数是3,而集群里只有2个满足反亲和条件的节点,第三个副本会一直Pending,而且永远不会得到一个“自动换节点”的机会。硬性反亲和在集群扩容前就是个死局。所以能用软性(preferred)尽量用软性,或者结合PodDisruptionBudget做资源规划。

3.4 topologySpreadConstraints:让Pod在拓扑维度上均匀分布

在多个可用区或者多机架的集群里,光靠反亲和只能避开同一个Node,但没法保证跨可用区的均匀分布。topologySpreadConstraints就是干这个的,它把拓扑均匀分布的控制做得更精准。

spec: topologySpreadConstraints: - maxSkew: 1 topologyKey: zone whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: web-server

含义是:在zone这个维度上,任意两个可用区的匹配Pod数量差值最多为1。当不满足条件时的处理方式有两种,DoNotSchedule表示不调度(硬性),ScheduleAnyway表示依然调度(软性)。

这个机制在扩容多可用区服务时非常有用。比如一个服务已经有30个副本,分散在3个可用区,每区10个。想扩到33个副本,如果硬性要求差值不超过1,那新增的3个副本会被平均分配到3个可用区,不会出现全压到一个可用区的情况。

实践中如果同时使用podAntiAffinity和topologySpreadConstraints,要注意它们可能会互相打架。一个要尽量打散,一个要根据差值均匀分配,表面上看目标一致,但拓扑域粒度不一样时,计算结果可能出现冲突,导致Pod迟迟排不出来。遇到这种灵异现象,先审视是不是两套约束叠加后把可选节点数压缩到零了。

4. 污点与容忍:能决定Pod“能不能进花园”的守门员

4.1 Taints的三个属性:key、value、effect

污点(Taint)是打在Node上的标记,表示这个节点“不欢迎一般的Pod”。一个污点由key、value、effect三部分组成。value可以为空,effect有且只有三种:

  • NoSchedule:不把新Pod调度到这个节点,不影响已经运行在这上面的Pod。
  • PreferNoSchedule:尽量不调度到这个节点,但集群没有其他可选节点时可以调度过来。
  • NoExecute:不调度新Pod,同时立刻驱逐已经有资格限制的Pod。这个是三种effect里最严厉的。

默认情况下,k8s的control-plane节点会带上node-role.kubernetes.io/control-plane:NoSchedule污点,这就是为什么普通业务Pod不会跑到master节点的原因。并不是master节点被禁用了,而是有个污点挡着。

4.2 常用污点操作与真实场景

给节点打污点用kubectl taint命令:

# 给node-1打上污点:ssd=true,效果NoSchedule kubectl taint nodes node-1 ssd=true:NoSchedule # 移除这个污点 kubectl taint nodes node-1 ssd=true:NoSchedule-

等号后面是effect,注意结尾有个减号表示移除。打污点之前最好确定这个污点的key在节点上唯一的,不然移除时容易误删同key不同value的其他污点。

生产环境里常用污点做“独占节点”:

  • 给GPU节点打上gpu=true:NoSchedule,然后给GPU服务加一个对应容忍,这样普通无容忍的业务Pod永远不会被调度到GPU节点,避免把显存跑满导致推理任务OOM。
  • 给某台新加进集群的机器打上maintenance-node=true:NoSchedule,验证完应用后再移除,保证测试期间不接收集群流量。

4.3 容忍的语义坑:宽容不是无限放宽

Pod上的容忍写起来很简单:

spec: tolerations: - key: gpu operator: Exists effect: NoSchedule

如果operator是Exists,不需要写value,表示只要key存在就容忍。如果operator是Equal,要写value和effect都匹配才算容忍。

最常见的误操作是配了一个通配容忍:

spec: tolerations: - operator: Exists

这表示容忍Node上所有污点,不管什么key、什么value、什么effect。配了这个容忍的Pod会无视一切污点机制。一旦某些业务类型都用了这种“宁滥勿缺”的容忍,那前面精心设计的污点点位就全部失效了,Pod会跑到任何节点上,包括GPU节点和维护中的节点。所以我个人强烈建议:除非你有明确理由,不要使用不带条件限制的全量容忍。

还有一个容易被忽略的细节是tolerationSeconds。这个字段只在effect为NoExecute时生效,意思是“容忍这个污点多少秒”。如果污点一直存在,Pod在超过tolerationSeconds后会被驱逐。很多人以为容忍就是永久容忍,忽略了NoExecute配合tolerationSeconds的驱逐效果,导致频繁出现Pod被莫名驱逐的现象。

5. 把Pod卡在Pending的排查链路

5.1 describe pod读取调度事件的经验

Pod调度失败时第一件事永远是:

kubectl describe pod <pod-name> -n <namespace>

重点看Events这段。常见的调度失败事件长这样:

Events: Type Reason Age From Message ---- ------ ---- ---- ------- Warning FailedScheduling 12s default-scheduler 0/5 nodes are available: 2 Insufficient cpu, 3 node(s) had taint {node-role.kubernetes.io/control-plane: }, that the pod didn't tolerate.

这条Message信息量很大,它直接告诉你当前一共5个节点,其中2个CPU不足,3个有control-plane污点。把这两类排除,已经没有可用节点,Pod只能处于Pending。

排查时不光看单个Pod的事件,还可以用field selector看全集群范围内所有调度失败事件:

kubectl get events --field-selector reason=FailedScheduling -A

这个命令能一次性列出所有命名空间下的调度失败事件,非常适合在集群里批量部署一堆工作负载后发现大量Pod Pending的情况。

5.2 逐层定位:资源、标签、污点、端口冲突

收到FailedScheduling事件后,按下面顺序排查:

  1. 资源不足。确认Pod声明了多少requests,Node总容量有多少,已分配出去多少,还剩多少。一条命令查看节点资源情况:
kubectl describe nodes | grep -A5 "Allocated resources"

注意这里看的是Allocated resources,不是单纯看当前top命令显示的CPU使用率。因为调度器按“已分配值”判断,不按“实时使用率”判断。就算物理机上CPU使用率只有5%,只要先前调度上去的Pod声明了大量CPU requests,新Pod就会因为资源不足被拒。

  1. 标签不匹配。确认Pod要求的nodeSelector或nodeAffinity里的标签,是否存在于某个Node上:
kubectl get nodes --show-labels

存在这种问题时事件信息通常显示0/N nodes are available: N node(s) didn't match Pod's node affinity/selector

  1. 污点未容忍。事件里如果出现N node(s) had taint {...},就是污点堵路。这时候要么给Pod加容忍,要么移除节点上的污点,要么确认这个Node本来就是被隔离做特殊用途的。

  2. 端口冲突。如果事件信息里出现N node(s) had existing hostPort之类,说明Pod要绑定的宿主机端口已经被其他Pod占用。注意k8s默认不会自动换一个端口继续跑,所有候选节点都冲突的话,调度只会失败。

5.3 排查中容易忽略的三个隐蔽点

排障经验丰富了之后,会发现上面这四类原因只覆盖了80%的场景,剩下20%是那些初看不明显、细查才能发现的隐蔽点。

第一个隐蔽点是Webhook在搞鬼。集群里如果安装了MutatingAdmissionWebhook(比如Istio、cert-manager这类组件),Pod创建时会被动态注入sidecar容器、修改nodeSelector或者增加容忍。这会导致describe pod看到的requests远比你写的yaml大,资源不足的判断结果也就不奇怪了。排查方法是:

kubectl get pod <pod-name> -n <namespace> -o yaml

直接看最终落库的Pod完整对象,确认节点上实际运行的配置和源码仓库里的定义是否一致。

第二个隐蔽点是PriorityClass参与抢占时带来的连锁反应。默认集群中所有Pod使用同一个优先级,不会发生抢占。但一旦设置了多个PriorityClass,高优先级Pod调度不进去时,调度器可能通过抢占驱逐掉低优先级Pod。你可能看到低优先级Pod被反复杀进程、重建,而真正的高优先级Pod其实还没调度成功。排查方式是用kubectl get priorityclasses查看集群里的优先级配置,检查业务Pod的priorityClassName字段是否设置得过低。

第三个隐蔽点是kubelet自身的资源预留。节点上的可分配资源并不是完整机器规格。kubelet通过--system-reserved--kube-reserved参数会让出一部分资源给系统进程和kubelet自身,如果这两个参数设置得比较大,那节点实际可给业务用的资源比机器总容量少很多。查看某个Node被预留了多少资源:

kubectl describe node <node-name>

Allocated resources这一块显示的System-ReservedKube-Reserved字段。如果机器总内存是64G,但是可分配内存只有48G,先不要惊讶,去查一下kubelet的启动参数就知道了。

6. 默认调度器不够用怎么办:进阶玩法与学习思路

6.1 从修改约束到自定义调度器

默认调度器已经覆盖了绝大多数调度需求,但当你遇到“必须考虑网络带宽”“必须考虑数据所在机架”“必须优先调度到价格更低的节点”这类业务约束时,默认能力就不够用了。k8s给了两条路。

一条是Scheduler Extender。它允许你在调度器的Filter阶段和Score阶段之后,再调用一个外部HTTP服务做额外判断。这个方案的好处是实现门槛低,不需要重新编译调度器,写一个Web服务即可。

另一条是Scheduler Framework。这是目前推荐的扩展方式,它把调度器内部逻辑做成了一套可插拔的插件系统。编写一个自定义插件,编译进调度器,或者通过调度器配置文件动态加载。扩展点覆盖了Queuesort、PreFilter、Filter、PostFilter、Score、Reserve、Permit等完整链路。要深入玩k8s调度的同学,这个方向值得研究。

6.2 集群跑了一段时间之后:用descheduler重新平衡

调度器是“调完就不管”的,它只负责把新Pod放到合适节点,不会主动搬走已经运行很久但分布已经严重不均衡的旧Pod。集群跑几个月以后,节点间的资源利用率可能差距巨大。这个问题的解法是引入descheduler。

descheduler是一个辅助组件,它会定期扫描集群中Pod的分布情况,根据配置的策略(比如LowNodeUtilizationRemoveDuplicatesRemovePodsViolatingTopologySpreadConstraint)找出不合理的Pod,然后驱逐它们。被驱逐的Pod会重新走一遍调度流程,从而被放到更合适的节点上。

实际使用时注意给descheduler设置合理的evictionStrategynodeSelector,并且为重要的无状态服务配置好PodDisruptionBudget,避免它在驱逐时把可用副本数减到零。

6.3 学习路径与实战建议

最后说点个人经验。k8s调度的学习路径,我的建议是反着来:先学会排障,再研究调度策略,最后才去看调度器源码。

第一步,把kubectl describe podkubectl get events用熟练,能读懂FailedScheduling的每一个字段。所有调度相关的困惑,90%能在这一步解决。

第二步,亲手在测试集群里给Node打标签、打污点,然后用nodeAffinity、podAntiAffinity、topologySpreadConstraints各做一组实验,观察Pod落在哪里,再看describe里的调度打分输出。这一步能建立“约束如何影响落点”的直觉。

第三步,再看调度器的实现。推荐先读scheduler framework的扩展点定义,再顺着源码里scheduler.go的调度周期和绑定周期完整走一遍。如果时间紧,可以先跑起来一个调度器,配合-v=4级别的日志观察它在每个节点上的决策过程。

我自己的体会是,把调度摸透之后,很多“奇怪问题”会变得非常可预测。比如Pod频繁在几个节点之间迁移,大概率是反亲和或拓扑分布约束在起作用;比如线上某个节点负载明显高于其他节点,去看它的标签和污点,很可能发现它缺少某些约束导致被当成了“软柿子”。调度这块确实像是在花园里挖呀挖,挖对了地方是宝藏,挖偏了位置全是坑,但掌握方法之后,每一铲子都能挖出有用的东西。

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

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

立即咨询