这两天帮朋友看一个K8s集群的问题,有个推理服务的Pod一直Pending,kubectl describe pod拉出来,Events里一行字写得明明白白:
0/12 nodes are available: 4 node(s) had taint, 3 node(s) didn't match pod anti-affinity rules, 5 node(s) didn't match nodeSelector.这行字其实是调度器给你的“代数题答案”:4个节点被污点挡住、3个节点被反亲和规则排除、5个节点没满足节点选择条件。只要把污点、亲和性、节点选择这三件事想清楚,绝大多数调度问题都能当场定位。所以我一直建议别死记调度器的字段,先把调度器执行调度时的“过滤 + 打分”链路搞明白,再看这些策略各自扮演什么角色,之后不管是配置Pod、调性能、还是排查Pending,都能直接对症下药。
这篇文章就按这个思路展开:调度器完整工作链路、节点选择的三种手段、它们互相之间的优先级关系、以及一份基于实际排查记录的避坑清单。适合正在学调度器的开发者,也适合被Pending Pod折磨过的运维朋友。
1. 调度器把Pod放到哪:从创建到Bind的完整链路
1.1 调度器的边界:它只负责分房子,不负责装修
很多初学者会把调度器和kubelet混在一起,总觉得“调度器把Pod调度过去了,那容器就在那个节点上跑起来了”。实际上完全不是。调度器的唯一职责是给一个还没有nodeName的Pod分配一个Node,它只负责把这个关系通过API Server写入etcd。真正去拉镜像、创建容器、写CNI的,是节点上的kubelet。
举个例子:调度器跟前台分房的人,kubelet是保洁阿姨。分房的人说“这间房子分给A同学了”,保洁阿姨才动手打扫布置。要是分房的人一直不吭声,阿姨根本没活儿干。对应到K8s里,就是Pod一直卡在Pending状态,Events里反复出现FailedScheduling。
这里有个容易忽略的前提:Pod如果显式设置了spec.nodeName,调度器会直接跳过,不会参与决策。换句话说,你用pod.spec.nodeName指定了节点,调度器就默认“你自己搞定了”,哪怕这个节点不存在或者已经故障,调度器也不会帮你纠正。这个特例在排查时经常遇到。
1.2 一轮调度到底做了四件事:Watch、Filter、Score、Bind
kube-scheduler不是随机找个空节点就把Pod丢过去,它每轮调度都很机械,流程拆开就是四件事:
- Watch:从API Server同步所有“未调度”的Pod,也就是没有nodeName的那些。
- Filter(过滤):把所有不满足硬约束的节点一次性淘汰掉。资源够不够、端口是否冲突、卷能不能挂上去、污点是否容忍、亲和性硬规则是否满足,全部在这一步检查。
- Score(打分):对过滤后剩下的节点打分。资源余量是否均匀、是否存在软亲和性、数据局部性如何,都会转换成分数。
- Bind(绑定):选分数最高的节点,通过API Server为Pod写一条绑定记录。绑定写完了,kubelet才收到通知,开始真正干活。
Filter阶段对应旧版本里的Predicates,Score对应Priorities,新版本里都插件化了。下面这张表能更清楚地看出“硬约束”和“软偏好”的差别:
| 阶段 | 代表插件/检查项 | 作用 |
|---|---|---|
| Filter | NodeSelector、NodeAffinity、TaintToleration、NodePorts、NodeResources | 只留满足硬性条件的节点 |
| Score | NodeResourcesFit、InterPodAffinity、ImageLocality | 对通过过滤的节点排序打分 |
记住一个核心结论:Filter阶段不满足,直接就淘汰,根本轮不到打分。后面要讲的nodeSelector、required亲和性、NoSchedule污点,全部是Filter阶段的工作;而preferred亲和性、PreferNoSchedule污点,是Score阶段的工作。这两个阶段的分工,才是整个调度策略的主线。
2. nodeSelector:最初级的节点选择,以及它的天花板
2.1 标签是调度决策的数据地基
不管是用nodeSelector还是亲和性,底层基础永远都是节点标签。节点没有好标签,再高级的调度策略都是空中楼阁。给节点打标签的命令很简单:
kubectl label node <node-01> disk=ssd kubectl label node <node-01> zone=az-a kubectl label node <node-01> gpu=a100但生产环境里真正难的不是“会打标签”,而是“标签怎么规划”。我之前在某集群里就吃过亏:有人给节点打了role=monitor,后来另一个团队也打了role=service,同一种键两种含义,调度配置越堆越乱。
建议一开始就把标签分成几类:硬件标签订如disk、gpu、memory-big;拓扑标签用zone、rack、kubernetes.io/hostname;业务用途标签像node-role.kubernetes.io/control-plane这类。尽量不要把“一次性项目名”打进节点标签里,标签是长期基础设施,不是给临时需求用的。
2.2 nodeSelector的用法和四条硬伤
nodeSelector的写法很简单:
spec: nodeSelector: disk: ssd意思就是:这个Pod只能去存在disk=ssd标签的节点。条件不满足,Pod就一直Pending。
这个机制足够简单,但天花板也很明显:
- 只能做“等于”匹配。你想表达“disk是ssd或nvme都行”,nodeSelector表达不出来。
- 多个条件之间只有AND。
disk=ssd和zone=az-a必须同时满足,不能表达“磁盘在这个范围或区域在那个范围”。 - 没有“尽量”和“偏好”。它只有硬约束,一旦节点标签不匹配就直接失败,没法让调度器“优先选某个区域,实在不行换别的”。
- 没有打分能力。多个节点都满足条件时,nodeSelector不参与优劣比较,调度器只能靠资源余量这类通用指标打分,体现不出业务侧的偏好。
正是这些限制,才催生了亲和性机制。理解了这个演进逻辑,后面看nodeAffinity的字段就不会觉得难。
3. 亲和性:用“要求”和“偏好”把Pod和节点绑定
3.1 nodeAffinity:把条件升级成“必须”和“最好”
nodeAffinity是nodeSelector的升级版,它把选择条件分成两类:强制满足和偏好满足。先看一段典型的YAML:
affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: disk operator: In values: ["ssd", "nvme"] preferredDuringSchedulingIgnoredDuringExecution: - weight: 80 preference: matchExpressions: - key: zone operator: In values: ["az-a"]这个配置读起来就是:Pod必须到disk=ssd或disk=nvme的节点;如果这些节点里正好有zone=az-a的,优先级更高。
字段名很长,但拆开理解并不复杂:
- requiredDuringSchedulingIgnoredDuringExecution:调度时必须满足的硬条件。“IgnoredDuringExecution”的意思是:调度已经完成了,运行期间节点标签变了,Pod不会被赶走。
- preferredDuringSchedulingIgnoredDuringExecution:调度时尽量满足的软条件,每个项有weight,范围是1到100。满足的权重越高,节点在Score阶段得分越高。
这里有一个很多资料都不讲的细节:nodeSelectorTerms里如果有多个term,term之间是“或”的关系;但每个term内部的多个matchExpressions是“与”的关系。也就是说:
nodeSelectorTerms: - 条件A且条件B - 条件C且条件D调度器会先求(A AND B),再求(C AND D),两个term只要有一个成立就算匹配。很多人误以为所有表达式都是AND,结果写出来的规则比预期严格得多,Pod就被卡死在Pending上。
运算符支持也比较丰富,我用一张表整理一下:
| 运算符 | 含义 | 示例 |
|---|---|---|
| In | 标签值属于给定集合 | disk In (ssd, nvme) |
| NotIn | 标签值不属于给定集合 | zone NotIn (az-b) |
| Exists | 只要存在该key即可 | gpu Exists |
| DoesNotExist | 不能存在该key | gpu DoesNotExist |
| Gt | 标签值大于给定整数 | core-count Gt 8 |
| Lt | 标签值小于给定整数 | core-count Lt 24 |
注意Gt和Lt是按字符串转整数比较的,如果你的标签值是"a1"这种,会报错或比较不按预期来。
3.2 podAffinity与podAntiAffinity:安排“和谁住在一起”
nodeAffinity只关心Pod和节点的关系,podAffinity和podAntiAffinity则关心Pod与Pod在拓扑上的关系。它们是两个方向:
- podAffinity:新Pod希望和某些符合条件的Pod落在同一个拓扑域里,比如“和redis住在同一个可用区”。
- podAntiAffinity:新Pod希望避开某些Pod所在的拓扑域,比如“这组副本不要落在同一台节点上”。
拓扑域由topologyKey决定。最常用的是kubernetes.io/hostname——节点级别;topology.kubernetes.io/zone——可用区级别。举个例子:
affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: web topologyKey: kubernetes.io/hostname这段配置的含义是:调度web的Pod时,不能把它放到已经有另一个app=webPod跑的节点上。多个副本因此被强制打散到不同节点,这对高可用很有用。
用podAntiAffinity做高可用打散本身没问题,但有个隐藏风险:硬性反亲和会把集群切成碎片。如果你的副本数是6,集群正好有6个节点满足条件,那是一张完美合影;但如果你扩到7个副本,第7个Pod找不到任何一个“没有web副本的节点”,整个滚动更新可能就卡住。所以线上我一般更推荐用preferredDuringSchedulingIgnoredDuringExecution的反亲和,或者直接上topologySpreadConstraints做分布控制,后者在做多副本散布时更稳定,也更适合表达“尽量均匀”这类需求。
3.3 一份比较完整的亲和性配置样例
把nodeAffinity和podAntiAffinity组合起来,常见的形态长这样:
apiVersion: apps/v1 kind: Deployment metadata: name: web spec: replicas: 3 selector: matchLabels: app: web template: metadata: labels: app: web spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: disk operator: In values: ["ssd", "nvme"] podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 90 podAffinityTerm: labelSelector: matchLabels: app: web topologyKey: kubernetes.io/hostname containers: - name: web image: nginx这里的思路是:保证Pod不会落到普通机械盘节点上,同时尽量把3个副本分散到不同节点。用preferred而不是required,就不至于在节点不够时把Pod卡死。
4. 污点与容忍度:反过来,让节点拒绝Pod
4.1 污点的基本逻辑和三个Effect
亲和性和nodeSelector是Pod主动“往节点身上贴”;污点这套机制是反过来,节点主动“把Pod推开”。具体到K8s里就是:节点上可以打污点(Taint),默认所有Pod都不能被调度上去,除非Pod声明了容忍(Toleration)。
打污点的方式:
kubectl taint node <node-01> gpu=true:NoSchedule这条命令的意思是:给这个节点打一个gpu=true的污点,效果是NoSchedule。此后不带对应容忍的Pod,默认不会被调度到这个节点。这种隔离非常适合GPU节点、专用硬件节点、以及你不想让普通业务混上去的机器。
污点有三个Effect,行为完全不同,我列个表:
| Effect | 调度期行为 | 运行期行为 | 典型用途 |
|---|---|---|---|
| NoSchedule | 新Pod不带容忍则不能调度 | 已运行Pod不受影响 | 默认隔离,适合GPU、专用节点 |
| PreferNoSchedule | 优先不调度,实在没地方也会去 | 已运行Pod不受影响 | 柔性隔离,比如让系统Pod优先避开 |
| NoExecute | 新Pod不能调度 | 已运行Pod且无容忍会被驱逐 | 节点故障隔离、强制疏散 |
其中NoExecute是最“凶”的,它不只是拒绝新来的,还会把已经跑在节点上、而且没有对应容忍的Pod赶走。生产环境里节点失联、磁盘出问题,K8s会给节点自动添加类似的污点以保证故障Pod最终被调度到别处。
这里补充一个系统级背景:节点处于NotReady或Unreachable时,控制器会给节点打上node.kubernetes.io/not-ready、node.kubernetes.io/unreachable污点。正常情况下每个Pod会被自动注入对这些污点的容忍,时长默认300秒。也就是说节点出故障后,Pod不会立刻被驱逐,而是会等300秒。如果你想缩短这个等待时间,可以通过显式声明自己的容忍度来覆盖默认行为。
4.2 容忍度的匹配逻辑、驱逐时间与常见坑
容忍度的写法很灵活:
tolerations: - key: gpu operator: Equal value: "true" effect: NoSchedule也可以写成:
tolerations: - key: gpu operator: Exists effect: NoSchedule重点逻辑:
operator: Equal表示key、value、effect必须全匹配才能容忍;operator: Exists表示只要key存在就容忍,value必须省略。effect可写可不写。不写effect时,表示这个容忍对所有effect都生效。这很容易埋坑——你只想容忍NoSchedule,结果NoExecute也一起容忍了,节点故障时原本该被驱逐的Pod,硬生生多待了很长一段时间。tolerationSeconds只对NoExecute有效。它表示Pod在节点上还能存活多长时间,到期后即使没有触发驱逐,调度器也会把它赶走再重新调度。
我自己就踩过一个比较经典的坑:某个业务Pod声明了容忍gpu=true,但用的是operator: Exists并且没写value,当时我以为是“容忍GPU节点的NoSchedule污点”,实际上它还把gpu=true:NoExecute也容忍了。后来GPU节点真的进了维护流程,Pod原地不走,服务一直起不来,排查半天才发现在这个不起眼的字段上。
还有一点要注意:给节点去掉污点,不会让已经Pending的Pod自动调度成功。调度是一次性的,去掉污点后,如果Pod还是旧的那个Pending状态,它不会自己重新跑一轮调度。你需要删除Pod、滚动更新,或者把Deployment的副本数强制调一下,才会触发重新调度。
5. 多个调度策略打擂台时,调度器到底听谁的
5.1 Filter和Score的分工决定了调度优先级
把前面的策略全部摆在一起后,自然的疑问就是:如果同时有nodeSelector、nodeAffinity、podAntiAffinity、污点容忍,谁听谁的?
其实答案在前面已经埋下伏笔:所有硬约束都进Filter阶段,所有软偏好都进Score阶段;Filter不通过,讨论Score毫无意义。
举个例子,一个Pod同时写了:
nodeSelector: disk=ssdrequired nodeAffinity: zone=az-arequired podAntiAffinity: 不让两个本服务Pod同节点tolerations: 容忍某节点上的NoSchedule污点
那么调度器会先做一次大过滤,候选节点必须同时满足“是ssd”、“是az-a”、“节点上没有本服务Pod”、“有污点但Pod能容忍”。中途任何一个条件不满足,节点直接出局。之后才轮到preferred亲和性、资源均匀度这类打分项去排序。
这也是“策略叠加放大失败风险”的由来。比如你的nodeSelector限定了10个节点,nodeAffinity又限定了其中4个,podAntiAffinity又排掉了其中3个,最后只剩1个节点能调度。问题不在某个策略本身,而是所有硬约束都在“交集合集”。
5.2 一个组合场景的完整推导:AI推理服务落点设计
我拿一个很常见的AI推理服务场景走一遍完整推导。假设集群里有:
- 三个可用区节点,标签分别是
zone=az-a、zone=az-b、zone=az-c - 部分节点有GPU卡,标签为
gpu=available,并且打了污点gpu=true:NoSchedule - 业务要求:推理服务必须用GPU;多个副本不要落在同一节点;流量入口在az-a,所以最好往az-a靠
最终配置核心段可以写成:
affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: gpu operator: Exists preferredDuringSchedulingIgnoredDuringExecution: - weight: 70 preference: matchExpressions: - key: zone operator: In values: ["az-a"] podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 90 podAffinityTerm: labelSelector: matchLabels: app: inference topologyKey: kubernetes.io/hostname tolerations: - key: gpu operator: Exists effect: NoSchedule这里最容易被忽略的就是tolerations段。很多第一次接触污点的同学,写好了nodeAffinity.gpu Exists,以为Pod就能上GPU节点了,结果发现永远Pending,因为节点上还挂着gpu=true:NoSchedule污点。nodeAffinity的“我只要GPU节点”和污点的“你就算要我也不一定要你”并不冲突,它们是一个加法一个减法,必须同时成立,Pod才能到那个节点上。
5.3 除了三大策略,别忘了还有这些调度手段
标题里提到的三种策略是调度器最核心的“业务弹药”,实际调调度还不止这些。pod.spec.topologySpreadConstraints可以做跨节点、跨可用区的均匀分布;nodeName是跳过所有调度逻辑的强绑定;gang scheduling、优先级抢占(PriorityClass + Preemption)也在更复杂的场景里发挥作用。
写这篇文章时我只提一点:如果你做多副本打散,能优先用topologySpreadConstraints就别滥用required podAntiAffinity。前者能表达“节点之间尽量均衡”,后者只能表达“避开同种Pod所在的域”,稍不留神就把可用节点集合切得非常小。这也是从“把调度跑通”到“把调度做得优雅”之间重要的一步。
6. Pod一直Pending?一份基于现场日志的排查记录
6.1 Events里的FailedScheduling是最直接的证据
遇到Pod Pending,第一步永远是看Events:
kubectl describe pod <pod-name>调度器会在Events里反复输出失败原因,最常见的格式是这样:
Events: Type Reason Age From Message ---- ------ ---- ---- ------- Warning FailedScheduling 32s default-scheduler 0/12 nodes are available: 2 node(s) had taint, 3 node(s) didn't match pod affinity/anti-affinity, 4 node(s) didn't match nodeSelector, 3 Insufficient cpu.这行消息是排查的“第一现场”,要把每条原因拆出来:
| 报错片段 | 含义 | 下一步动作 |
|---|---|---|
| node(s) had taint | 这些节点有污点但Pod没容忍 | 核对节点Taints与Pod Tolerations |
| didn't match pod affinity/anti-affinity | 亲和性或反亲和约束不匹配 | 检查podAffinity配置、labelSelector |
| didn't match nodeSelector | nodeSelector条件没满足 | 检查节点标签和nodeSelector |
| Insufficient cpu/memory | 资源不足 | 看节点可分配资源,或降低request |
注意“node(s) had taint”不代表节点坏了,更不代表要马上删污点。它只是说“这个节点有污点,而你的Pod没有对应的容忍”。先确认这个节点是不是应该被隔离,如果是,就给Pod补toleration;如果不是,再考虑清理污点。
6.2 从节点侧核对Taints、Labels与真实可用资源
看完了Pod侧,还要看节点侧。我常用的三个命令:
kubectl describe node <node-01> | grep -iE 'Taints|Labels' kubectl get nodes --show-labels kubectl get pods -o widedescribe node里能直接看到两个关键字段:Labels和Taints。把这两项抄下来,再回头对比Pod的affinity和toleration配置,基本就能定位是哪一条规则把Pod挡在外面。
还有一个容易忽略的情况:节点本身是NotReady状态。调度器会把NotReady节点直接过滤掉,哪怕它的标签完全匹配、污点也能容忍,也不会被选为候选。而且系统会给NotReady节点自动打上node.kubernetes.io/not-ready污点,默认容忍的300秒只对已经运行在上面的Pod有效,新Pod不会因为能容忍这个污点就强行调度过去。所以排查时先看节点状态,比一头扎进亲和性配置里更高效。
6.3 经验:别把Label当数据库,也别把Taint当万能开关
长期和调度策略打交道,我积了几个比较重要的习惯,分享给你参考:
- 标签要风化得要慢。节点标签是一票基础设施,改了标签不会自动触发已有Pod重调度,只会影响后续新Pod的落点。改之前想清楚:到底是要改这个节点,还是应该改调度策略?
- 污点变更走变更单。尤其NoExecute污点,一旦打上,所有不带容忍的Pod都会被立刻驱赶,等于在集群里做了一次批量重启。线上临时“隔离一下节点”这种操作,优先用
kubectl cordon,不要随手打NoExecute污点。 - ** required条件要慎用**。要求越高,越容易把自己逼进死角。如果只是希望,就写
preferredDuringSchedulingIgnoredDuringExecution;只有业务确实无法接受降级(比如必须使用GPU),才写required。 - 多副本打散优先用preferred反亲和或者topologySpreadConstraints。这条上面提过,但值得再强调一次。硬性反亲和做三副本很高明,做三十副本就是事故预警。
我自己的排查套路其实很简单:先把describe node的Labels和Taints抄在一张纸上,再打开Pod的YAML看affinity和tolerations,两边一比对,90%的问题都能在5分钟内定位。剩下10%大多出在资源不足或节点NotReady上。这个习惯比我记几十个调度参数名有用得多,你下次排查Pending Pod的时候,可以试试。