凌晨被叫起来处理一个线上问题:一批做日志归档的业务 Pod,原本是给几台挂了本地盘的机器准备的,结果调度器把它们摊到了普通计算节点上,跑了不到二十分钟就因为本地存储空间不够被反复驱逐,整个归档任务链断掉。这类场景在 Kubernetes 集群里不算罕见,很多人第一反应是给 Deployment 加个 nodeSelector 就完事,真到落地时才发现,nodeSelector、nodeAffinity、nodeName、污点容忍这四样东西的行为差异相当大,选错了就会出现"配了但没生效""生效了但迁移不动""迁移了把集群搞挂"这些连锁反应。
把 Pod 调度到指定 node 节点,说到底是让调度器在候选节点里做筛选。但筛选的入口有好几个,每个入口的语义、生效时机、失败表现都不一样。这篇内容适合两类人看:一类是刚接手集群、被 Pending 的 Pod 折磨过的新手,需要把几种方案的边界摸清楚;另一类是在生产环境做节点分组、专机专用、混合部署的运维和平台开发,需要在"硬绑定"和"留余量"之间做取舍。下面会从调度链路讲到具体写法,再讲到约束冲突时怎么一层层排出来,中间穿插一些文档里不太会写、但实际会踩的坑。
1. 先厘清"指定节点"在业务语境下的三种真实诉求
1.1 硬指定、软偏好与反向排除不是一回事
很多需求描述到工单上只有一句"这个服务要跑在指定节点",但拆开来其实是三种完全不同的诉求。第一种是硬性绑定,比如节点上装了特定的硬件、挂了特定的本地盘、跑了特定的守护进程,Pod 跑在别处就是错的,宁可 Pending 也不能乱跑。第二种是软性偏好,比如同一个机房、同一批机型更好,但如果容量不够,跑到别的地方也能接受,只是性能差一点。第三种是反向排除,比如某些节点是给核心交易链路预留的,任何非核心业务都不许上去。
这三种诉求对应的实现方式完全不同。硬绑定用 nodeSelector 或 nodeAffinity 的 required 模式,软偏好用 nodeAffinity 的 preferred 模式加权重,反向排除最稳的是节点侧打污点、业务侧加容忍,而不是在业务侧写一堆 NotIn。这个区分必须先做清楚,因为一旦把软需求写成硬约束,集群里就会出现一批"明明有资源但就是调度不上去"的 Pod;反过来把硬需求写成软偏好,就会出现前面说的那种跑到一半被驱逐的事故。
我在实际项目里见过一个典型的错配:某个团队为了"尽量"让缓存服务靠近数据库,把节点标签写进了 required 模式。结果某次扩容时那批节点全满了,缓存服务全部 Pending,而旁边的节点空着一半。改起来很简单,把 required 改成 preferred 加权重 100 就行,但故障已经发生了。所以每次动手写约束之前,先问自己一句:这个条件不满足时,我是希望 Pod 等着,还是希望它去别处跑?
1.2 从 API Server 到 kubelet:调度链路决定了哪种写法能生效
要把这件事讲透,得先看清楚一个 Pod 是怎么落到节点上的。当你提交一个 Pod(或由 Deployment 创建 ReplicaSet 再创建 Pod)时,API Server 做完准入校验后把它写进 etcd,此时spec.nodeName字段是空的。调度器通过 watch 感知到这批"未绑定"的 Pod,进入调度循环:先做一轮过滤(Filter),把所有不满足条件的节点剔除,再做一轮打分(Score),在剩下的节点里挑一个最合适的,最后发起 Bind 操作,把节点名写回spec.nodeName。目标节点的 kubelet 一直在 watch 属于自己名字的 Pod,看到之后就拉镜像、挂卷、起容器。
这条链路里有两个关键分叉点。第一个分叉在过滤阶段:NodeName、NodeAffinity、TaintToleration、NodeUnschedulable、NodeResourcesFit、InterPodAffinity、PodTopologySpread这些插件依次执行,任何一个不通过,这个节点就被排除。第二个分叉在更前面:如果你在创建 Pod 时就直接把spec.nodeName写死了,调度器根本不会处理这个 Pod,kubelet 会直接接管。这就是为什么 nodeName 能"绕过"调度器,也是它一系列副作用的总根源。
理解了这条链路,很多现象就顺了:Pod 一直 Pending 且事件里出现0/6 nodes are available,说明卡在过滤阶段;Pod 显示 Running 但容器起不来,说明已经绑定成功,问题在 kubelet 那一侧;改了 YAML 里的 nodeSelector 但 Pod 纹丝不动,是因为它早就绑定完了,调度是一次性动作。后面所有的排错思路,基本都是从这三个判断往下走的。
1.3 一个容易忽略的前提:节点标签和污点本身要先规划好
不管用哪种方案,都绕不开节点侧的准备工作。Kubernetes 默认会给节点打上一批标签,比如kubernetes.io/hostname、kubernetes.io/os、node.kubernetes.io/instance-type(云上环境),但业务维度的标签需要自己加。加标签这件事看着简单,实际最容易出问题的是命名规范不统一:有人写disk=ssd,有人写disktype=ssd,有人写storage=ssd,最后调度规则和节点标签对不上,Pod 就一直 Pending,而kubectl get nodes --show-labels的输出长得让人眼花,很难一眼看出来拼写差异。
我的习惯是维护一份节点标签字典,固定几个维度:用途(workload=general|data|gateway)、硬件特征(disktype=ssd|hdd、gpu=true)、可用区(zone=a|b|c)、生命周期(lifecycle=spot|reserved)。所有调度规则只允许引用字典里已有的键值,新加维度必须先更新字典再使用。这个约定听起来有点重,但能省掉大量"为什么 Pod 调不上去"的排查时间。
# 打标签的标准写法,注意 --overwrite 只在确实需要改值时使用 kubectl label node node-a workload=data kubectl label node node-a disktype=ssd kubectl label node node-b workload=general # 查看标签时建议配合 grep 或 jsonpath,避免被默认标签淹没 kubectl get nodes -L workload,disktype,zone最后一条命令用的是-L参数,把指定标签作为额外的列展示出来,比--show-labels清爽得多。做节点规划时我基本都用这个方式确认标签是否打全了。
2. nodeSelector 与 nodeName:最省事的两种写法,代价各在哪
2.1 nodeSelector 的匹配语义是"全等",没有模糊空间
nodeSelector 是PodSpec里最古老也最简单的字段,类型是一个map[string]string,语义只有一条:节点的标签必须完全包含这里列出的所有键值对。注意是"包含"而不是"相等",节点上可以有很多其他标签,只要多出来的键不影响匹配就行;但列出的每一个键值对都必须精确命中,少一个、值差一个字符,节点就被排除。
apiVersion: apps/v1 kind: Deployment metadata: name: archive-worker spec: replicas: 3 selector: matchLabels: app: archive-worker template: metadata: labels: app: archive-worker spec: nodeSelector: workload: data disktype: ssd containers: - name: worker image: registry.example.internal/archive-worker:1.4.2 resources: requests: cpu: "500m" memory: "1Gi"上面这段的意思很明确:只有同时带workload=data和disktype=ssd两个标签的节点才能接这个 Pod。它的优点是语义直白、可读性好、所有 K8s 版本都支持,写出来谁都能看懂。缺点也同样明显:只支持"与"关系,没法表达"或"、没法表达"不存在某个标签"、没法表达数值大小比较,也没法做优先级。当约束条件超过两三个,或者需要"首选 A 机房、备选 B 机房"这种逻辑时,nodeSelector 就不够用了。
还有一个细节值得提醒:nodeSelector 是Pod 模板的一部分,改它等于改模板,会触发 Deployment 滚动更新。这听起来是好事,但滚动更新的默认策略是maxSurge=25%,会先创建新 Pod 再销毁旧 Pod。如果新规则指向的节点容量只够放现有副本数,新 Pod 就会卡在 Pending,滚动更新永远不完成,此时旧 Pod 还在跑,集群进入一个半新半旧的中间状态。所以调整 nodeSelector 之前,先确认目标节点的可分配资源够不够,别在业务高峰期动这个。
2.2 nodeName 直接跳过调度器,快是快但账要自己算
nodeName 是最"暴力"的方案,直接在 PodSpec 里写上节点名:
spec: nodeName: node-a containers: - name: debug-agent image: registry.example.internal/debug-agent:0.9.1这个 Pod 提交之后,调度器看都不看,kubelet 直接接管。它的适用场景其实很窄:调试用的临时 Pod、必须固定在某个节点上执行一次的运维任务、以及某种极端的自举场景(比如集群刚建好,调度器组件本身还没就绪)。日常业务负载不该用它。
原因在于它把调度器替你做的那些事全跳过了。资源够不够、节点是否被 cordon、节点有没有打污点、端口是否冲突、亲和性是否满足——这些检查统统不做了。kubelet 会尽力启动容器,资源超卖就超卖,被 cordon 的节点照样往上放。更麻烦的是可维护性:节点名一旦写进 YAML,节点重命名、重建、扩缩容都会让这份 YAML 失效,而且它跟 nodeSelector 同时出现时行为很容易让人困惑,两者冲突时到底以谁为准,不同版本的实现细节还不完全一样。我的一般建议是:业务负载永远不要写 nodeName,需要固定位置就用 nodeAffinity 的 required 模式加matchFields匹配metadata.name,效果一样,但走的是正规调度链路,该有的检查一个不少。
| 对比维度 | nodeSelector | nodeName |
|---|---|---|
| 是否经过调度器 | 是 | 否 |
| 是否检查资源余量 | 是 | 否 |
| 是否检查污点与 cordon | 是 | 否 |
| 支持逻辑表达式 | 否 | 不涉及 |
| 节点改名后是否失效 | 否(用标签) | 是(用名字) |
| 推荐使用场景 | 常规业务绑定 | 临时调试、自举 |
# 想按节点名固定,正确写法是用 matchFields,而不是 nodeName spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchFields: - key: metadata.name operator: In values: - node-amatchFields目前只支持metadata.name这一个字段,但足够解决"必须落在某台机器上"的问题,而且它享受调度器的全部检查,节点被 cordon 或者资源不够时 Pod 会老老实实 Pending 并给出事件,不会闷头往上冲。
3. nodeAffinity:把"指定节点"写成带逻辑的表达式
3.1 required 与 preferred 的执行时机差别,比字面意思更重要
nodeAffinity 分两个字段,名字很长但含义很清楚:requiredDuringSchedulingIgnoredDuringExecution和preferredDuringSchedulingIgnoredDuringExecution。前半段DuringScheduling说的是调度阶段强制,后半段IgnoredDuringExecution说的是运行阶段不强制——这一点经常被误解。
具体来说,required 模式在过滤阶段生效,节点不满足条件就直接被排除,Pod 会 Pending。但一旦 Pod 已经跑在某台节点上,之后你把这台节点的标签改了、删了,Pod不会被驱逐,它会继续在那儿跑。这个设计是有意为之的:标签变更如果触发大规模驱逐,集群会瞬间雪崩。所以如果你需要"标签一变就把 Pod 赶走"的效果,nodeAffinity 做不到,得靠污点的NoExecute。
preferred 模式则是在打分阶段生效,它不排除任何节点,只是给满足条件的节点加分。权重范围是 1 到 100,可以写多条,最终得分是加权累加。这意味着 preferred 永远不会导致 Pending,但反过来也永远不保证 Pod 一定落在你想要的节点上——容量紧张时它照样会去别处。
spec: affinity: nodeAffinity: # 硬约束:必须在 data 组且是 SSD 节点 requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: workload operator: In values: ["data"] - key: disktype operator: In values: ["ssd"] # 软偏好:优先 a 区,其次 b 区 preferredDuringSchedulingIgnoredDuringExecution: - weight: 80 preference: matchExpressions: - key: zone operator: In values: ["a"] - weight: 30 preference: matchExpressions: - key: zone operator: In values: ["b"]这段配置的读法是:先过滤出workload=data且disktype=ssd的节点,在这些节点里优先挑 a 区的,a 区没有就退到 b 区,都没有就随便挑一个满足硬约束的。这种"硬约束保底 + 软偏好排序"的组合是我在生产里用得最多的形态,既能保证业务不跑到不该去的地方,又不会因为某个区临时没容量就整体卡死。
3.2 operator 的适用边界:In、NotIn、Exists、Gt、Lt 各有脾气
nodeAffinity 支持五种 operator,用错了要么静默失效,要么直接报错。
In和NotIn后面必须跟非空的values列表。Exists和DoesNotExist只看键在不在,不能写 values,写了会校验失败。Gt和Lt只对可以解析为整数的值有效,且values只能有一个元素。我见过有人在Gt后面写了两个值,滚动更新直接失败,报的是字段校验错误,但错误信息埋在一长串输出里,找起来挺费劲。
# 只要求节点有这个标签,值是什么无所谓 - matchExpressions: - key: gpu operator: Exists # 排除 spot 节点 - matchExpressions: - key: lifecycle operator: NotIn values: ["spot"] # 数值比较:只允许核数大于 32 的节点 - matchExpressions: - key: cpu-cores operator: Gt values: ["32"]NotIn的语义要特别注意:它要求节点存在这个键,且值不在列表里。如果节点压根没有lifecycle这个标签,NotIn匹配是失败的。这个行为和很多人的直觉相反——大家会以为"没有这个标签当然就不在列表里"。想同时覆盖"没有标签"和"值不匹配"两种情况,得写成Exists加NotIn的组合,或者干脆改用DoesNotExist单独处理。这个坑我在节点标签分批打齐的过渡期踩过,一批新节点还没打标签,Pod 全 Pending,事件里只写didn't match Pod's node affinity,看不出具体是哪条不满足。
3.3 多组 nodeSelectorTerms 之间的"或"逻辑,容易被忽略
requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: workload operator: In values: ["data"] - matchExpressions: - key: workload operator: In values: ["cache"]nodeSelectorTerms是一个数组,数组元素之间是"或"关系,每个元素内部的matchExpressions之间是"与"关系。上面这段的意思是"data 组的节点或者 cache 组的节点都行"。如果需求是"data 组并且 SSD",那必须写在同一个 term 里的多个 matchExpressions 中,写成两个 term 就变成了"data 组或 SSD 节点",匹配范围一下子扩大了。
这个结构的组合能力其实很强,配合matchFields还能实现按节点名分组。但它在可读性上不太友好,尤其是条件多了以后,缩进和括号的关系很容易看错。我的建议是:超过三个条件的硬约束,先在纸上画出逻辑树,确认"与"和"或"的层级关系,再落成 YAML,别在编辑器里边写边想。
4. 从节点侧反向控制:污点与容忍的配合方式
4.1 三种 effect 的语义差异决定了业务影响面
前面讲的都是从 Pod 侧挑选节点,污点是反过来的:节点给自己贴一个"别随便上来"的标记,Pod 必须带对应的容忍才能上去。污点有三个 effect,效果差别很大。
NoSchedule只影响新调度,已经在节点上跑的 Pod 不受影响。这是最常用的类型,用来做节点分组、预留资源。PreferNoSchedule是软性的,调度器会尽量避开,但实在没地方去还是会放上去,适合做"不推荐但不禁止"的场景。NoExecute最猛,不但阻止新 Pod 上来,还会把节点上所有不容忍的 Pod驱逐掉,同时支持tolerationSeconds做延迟驱逐。
NoExecute这套机制其实是被系统组件大量使用的:节点失联时,控制器会给节点打上node.kubernetes.io/unreachable:NoExecute,节点资源紧张时打memory-pressure、disk-pressure。这也是为什么你有时候会发现 Pod 在没有人为操作的情况下被赶走——节点进入了压力状态,触发了系统污点。
# 给专用节点打污点,注意 effect 和值的组合 kubectl taint node node-a dedicated=data:NoSchedule # 查看节点的污点 kubectl describe node node-a | grep -A3 Taints # 临时摘掉某个污点(末尾加个减号) kubectl taint node node-a dedicated=data:NoSchedule-4.2 专用节点的标准操作顺序:先打标签、再打污点、最后验容忍
给一组节点做专用化,顺序是有讲究的。先打标签,让它们能被 nodeSelector 或 nodeAffinity 选中;再打污点,把"不相关的人"挡在外面;最后确认目标业务的容忍配置写对了。顺序颠倒的话,中间会有一段时间节点上既有旧 Pod 又打不进新 Pod,状态很难看。
spec: nodeSelector: workload: data tolerations: - key: dedicated operator: Equal value: data effect: NoSchedule这段配置要连起来读:nodeSelector 保证只去 data 组,toleration 保证能通过 data 组的污点检查。两个条件缺一不可。只写 nodeSelector 不写 toleration,Pod 会被污点挡住,Pending 等死;只写 toleration 不写 nodeSelector,Pod 有可能跑到别的没打污点的节点上去,因为容忍只是"允许",不是"指定"。
这是新手最常混淆的一点:容忍不等于亲和。容忍只是说"我不怕这个污点",它不构成任何倾向性。想真正固定位置,必须靠 nodeSelector 或 nodeAffinity 主动拉过去。所以正确的心智模型是"标签负责吸引,污点负责排斥,容忍负责放行",三者配合才形成完整的绑定。
operator的取值也要注意:Equal需要同时写value,表示键值都要对上;Exists不写value,表示只要节点有这个键(不管值是什么)就放行。
# 容忍这个键下的任意值,适合多团队共用一组专用节点的场景 tolerations: - key: dedicated operator: Exists effect: NoSchedule # key 为空 + Exists 表示容忍一切污点,慎用 tolerations: - operator: Exists第二条那种"全容忍"的写法,用在 DaemonSet 或者集群级别的监控组件上还算合理,业务负载上千万不要这么写——写完之后节点分组就彻底失效了,任何节点它都能去。
4.3 容忍写对了但 Pod 还是 Pending:按调度器视角逐层排查
这种情况我遇到过好几次,容忍配置肉眼看着没问题,Pod 就是不上去。排查应该按调度器的过滤顺序走,而不是靠猜。
第一步看事件。kubectl describe pod最后的 Events 段落会明确告诉你卡在哪一步。
kubectl describe pod archive-worker-7d9f8b6c4-x2k9m | tail -20常见的几条提示与对应原因:
| 事件提示片段 | 真实原因 |
|---|---|
node(s) had untolerated taint {dedicated: data} | 污点键值或 effect 与容忍不匹配 |
node(s) didn't match Pod's node affinity/selector | 标签没打、拼错,或 NotIn 遇到无标签节点 |
node(s) were unschedulable | 节点被 cordon 了,还没 uncordon |
Insufficient cpu/Insufficient memory | 资源不足,跟绑定规则无关 |
node(s) had volume node affinity conflict | PVC 绑定的 PV 只存在于其他节点 |
node(s) didn't match pod anti-affinity rules | 反亲和规则把自己挡住了 |
第二步针对性地验证。怀疑污点问题,就直接看节点污点:kubectl get node node-a -o jsonpath='{.spec.taints}'。怀疑标签问题,用kubectl get nodes -l workload=data看能不能筛出节点,筛不出来就是标签的事。这两个命令比读 describe 输出快得多。
第三步注意一个容易忽略的细节:effect 必须严格匹配。节点上的污点是NoSchedule,容忍里写的却是NoExecute,两者不匹配,Pod 一样上不去。同样,PreferNoSchedule的污点不需要容忍也能调度,所以如果节点上只有PreferNoSchedule,你写不写容忍都不影响结果,但一旦后面改成NoSchedule,没写容忍的 Pod 就开始 Pending 了。这种"改动时才会暴露"的问题,最好在打污点的时候就统一用NoSchedule。
5. 多种约束叠加时的冲突与优先级
5.1 所有硬约束是"与"关系,没有谁能覆盖谁
一个 Pod 上可以同时存在 nodeSelector、nodeAffinity.required、tolerations、podAffinity、topologySpreadConstraints,调度器的处理方式是全部都要满足,不是后者覆盖前者。这一点理解错了,就会写出自相矛盾的配置。
比如 nodeSelector 写着disktype=ssd,nodeAffinity 的 required 里写着disktype In [hdd],结果就是没有任何节点能同时满足,Pod 永远 Pending,而且事件会同时报两条不满足的提示,看上去像两个独立问题,实际是配置打架。
spec: nodeSelector: workload: data affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: zone operator: In values: ["a", "b"] podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: archive-worker topologyKey: kubernetes.io/hostname这段配置叠加了三个硬约束:只去 data 组、只在 a 或 b 区、同一节点上不能有两个 archive-worker。三个条件全过才能调度。反亲和那条尤其危险,因为它引入了"节点之间互相影响"的动态条件——副本数一多,节点数不够,后面的副本就会 Pending。所以反亲和我基本只写preferred模式,硬反亲和留给真正必须独占的场景。
5.2 用打分权重给"软约束"排出优先级
硬约束解决"能不能去",软约束解决"更想去哪"。当有多条 preferred 规则时,权重决定了排序。Kubernetes 的打分阶段是归一化之后加权求和的,权重差距太小的规则实际上看不出效果。
我的经验值是:核心偏好给 70 到 100,次要偏好给 20 到 50,聊胜于无的给 5 到 10。差距拉到 30 以上才能在多节点场景里稳定体现出来。写过两条权重都是 50 的规则,实测下来调度结果的分布几乎是随机的,因为两条规则的加分在多数节点上互相抵消了。
preferredDuringSchedulingIgnoredDuringExecution: # 主偏好:同机房,权重高 - weight: 90 preference: matchExpressions: - key: zone operator: In values: ["a"] # 次偏好:SSD 节点,权重中 - weight: 40 preference: matchExpressions: - key: disktype operator: In values: ["ssd"]5.3 冲突场景对照表:出问题时先对照再动手
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 全部副本 Pending | 硬约束条件在现有节点上无解 | 用kubectl get nodes -l逐个验证条件 |
| 部分副本 Pending | 反亲和或拓扑分布约束导致位置不够 | 检查节点数是否大于等于副本数 |
| Pod 上天了但不在预期节点 | 只写了 toleration,没写选择器 | 补 nodeSelector 或 required 亲和 |
| 改了约束但 Pod 没动 | Pod 已绑定,调度是一次性动作 | 触发重建或滚动更新 |
| 新 Pod Pending,旧 Pod 还在跑 | 滚动更新卡在 maxSurge 限制 | 确认目标节点资源,必要时调策略 |
| 事件提示 volume 亲和冲突 | PVC 绑定的本地 PV 只在一台节点 | 换 StorageClass 或调整 PV 布局 |
这张表不是穷举,但覆盖了八成以上的常见情况。遇到没见过的现象,回到调度链路那个模型上推一遍:是没绑定还是绑错了,是过滤没过还是打分没选上,沿着这条线走一般都能定位。
6. 特殊工作负载的绑定方式:DaemonSet 与多调度器
6.1 DaemonSet 的自动容忍是有边界的
DaemonSet 的语义是"每个节点跑一个",它的调度不走常规路径,由 DaemonSet 控制器直接指定节点。默认情况下,控制器会给 DaemonSet 的 Pod 注入一批容忍,覆盖系统级污点:
node.kubernetes.io/not-ready(NoExecute)node.kubernetes.io/unreachable(NoExecute)node.kubernetes.io/disk-pressure(NoSchedule)node.kubernetes.io/memory-pressure(NoSchedule)node.kubernetes.io/pid-pressure(NoSchedule)node.kubernetes.io/unschedulable(NoSchedule)node.kubernetes.io/network-unavailable(NoSchedule,仅 hostNetwork 场景)
关键点在最后一行之外:自定义污点不在这个列表里。你给节点打了dedicated=data:NoSchedule,DaemonSet 不会自动上去,日志采集、监控代理这类组件会在专用节点上直接断档,而且因为其他节点都有实例在跑,从监控大盘上看不出来缺了几台,只能靠节点列表和 Pod 列表做交叉比对。
apiVersion: apps/v1 kind: DaemonSet metadata: name: log-agent spec: selector: matchLabels: app: log-agent template: metadata: labels: app: log-agent spec: tolerations: - key: dedicated operator: Exists effect: NoSchedule containers: - name: agent image: registry.example.internal/log-agent:2.1.0给 DaemonSet 用operator: Exists是合理的,因为它本来就该覆盖所有节点。但要注意别顺手把nodeSelector也加上去,那样就退化成"只在部分节点跑",跟 DaemonSet 的初衷相悖了。真需要排除某些节点,用nodeAffinity的DoesNotExist更清晰。
6.2 schedulerName 与自定义调度器:什么时候才值得走这条路
Kubernetes 允许一个集群里同时跑多个调度器,Pod 通过schedulerName字段声明由谁来调度,默认值是default-scheduler。
spec: schedulerName: my-scheduler containers: - name: batch-job image: registry.example.internal/batch:1.0.0这个机制的典型用途是批处理场景:默认调度器优先保证在线业务的稳定性和资源碎片最小化,而批处理任务更关心吞吐和整机装箱率,两者的打分逻辑天然冲突。用独立调度器可以给批处理一套完全不同的策略,互不干扰。
代价也很实在:多一个组件要部署、要监控、要保证高可用;自定义调度器一旦挂了,声明了它的 Pod 全部 Pending,而且默认调度器不会兜底接管。另外,基于调度器框架的扩展点(Filter、Score、Permit 等)实现的插件,版本兼容性需要跟着集群版本走,升级时容易出问题。我的判断标准是:能用亲和性和污点表达的需求,就别引入自定义调度器;只有当调度策略需要读外部状态(比如实时价格、实时负载)时,才值得上。
还有一类更轻量的做法是调度器配置文件,通过KubeSchedulerConfiguration定义多个 profile,每个 profile 有不同的插件开关和权重,Pod 通过schedulerName指向对应 profile 名。这种方式不用额外部署组件,适合"同一套调度器、不同策略"的场景。
6.3 topologySpreadConstraints 与反亲和的分工
这两个东西经常被混用,但解决的是不同问题。podAntiAffinity 关心的是"同一个标签的 Pod 不要凑在一起",它用的是相对判断——只要两台机器上都有,就算打散了,不管每台上有几个。topologySpreadConstraints 关心的是分布均衡,它要求各个拓扑域之间的 Pod 数量差不超过maxSkew。
举个例子:6 个副本,3 个可用区,用反亲和只保证同一个节点上没有两个,但可能出现 4 个在 a 区、1 个在 b 区、1 个在 c 区的情况。用 topologySpreadConstraints 配maxSkew: 1、topologyKey: topology.kubernetes.io/zone,就能强制压成 2/2/2。
topologySpreadConstraints: - maxSkew: 1 topologyKey: topology.kubernetes.io/zone whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: archive-workerwhenUnsatisfiable有两个取值:DoNotSchedule是硬约束,不满足就 Pending;ScheduleAnyway是软约束,尽量均衡但不阻塞调度。选硬还是软,判断标准跟前面一样——业务能不能接受不均衡。跨区高可用的服务,硬约束更合适;只是想让负载好看一点的,软约束更安全。
这里有个坑要注意:maxSkew的计算基数是符合 labelSelector 的现有 Pod,如果一个节点上还没有任何匹配的 Pod,它算 0。当集群节点数远大于副本数时,硬约束很容易导致后面的副本 Pending,因为无论放哪都会让某个域的偏差超过 1。副本数少的时候,我一般直接用ScheduleAnyway。
7. 配置改完之后,Pod 为什么原地不动
7.1 调度是一次性动作,这句话要刻在脑子里
刚接触这块的人几乎都会经历同一个困惑:改了 Deployment 的 nodeSelector,kubectl get pod -o wide一看,Pod 还在老节点上。原因很简单,Pod 一旦完成绑定,调度器就不再管它了。kubelet 只看spec.nodeName是不是自己,是就继续跑,Spec 里的亲和性、选择器对已运行的 Pod 没有任何约束力。
那为什么有时候改完配置 Pod 确实动了?因为改的是 Deployment 的spec.template,这触发了滚动更新——新模板生成的 Pod 按新规则调度,旧 Pod 被删掉。看起来是"Pod 搬家了",实际上是"新 Pod 建在了新地方"。这个区别很关键:如果你直接kubectl edit pod改 nodeSelector,API 可能接受这个变更(这个字段是可变的),但 Pod 不会挪窝,白白折腾。
# 触发滚动更新最简单的方式:加一个无意义的注解 kubectl annotate deployment archive-worker \ kubernetes.io/change-cause="switch to data nodes" --overwrite # 或者直接 rollout restart kubectl rollout restart deployment/archive-worker # 观察滚动过程 kubectl rollout status deployment/archive-worker7.2 滚动更新、删除重建与 drain 的取舍
让 Pod 换节点有三条路,选哪条取决于业务能不能中断、数据会不会丢。
第一条是滚动更新,适合无状态服务。优点是平滑,缺点是受maxSurge和maxUnavailable限制,如果新节点容量不够,滚动会卡住,集群停留在混合状态。这时候要么扩容目标节点,要么临时调整策略。
第二条是直接删除 Pod,让控制器重建。简单粗暴,但会有短暂的副本数下降,而且如果是 StatefulSet,删除顺序和 PVC 绑定关系需要提前确认。
第三条是对节点做drain,把上面的 Pod 全部赶走。这是节点下线、机型替换时的标准动作,但要特别注意两点:一是不加--ignore-daemonsets会报错,因为 DaemonSet 的 Pod 无法被驱逐;二是不加--delete-emptydir-data,用了 emptyDir 的 Pod 会阻塞 drain。后者尤其危险,因为 emptyDir 里的数据在 Pod 重建后就没了,归档、缓存类业务最好别用 emptyDir 存需要保留的东西。
# 标准的节点下线流程 kubectl cordon node-a kubectl drain node-a --ignore-daemonsets --delete-emptydir-data --timeout=300s # 维护完成后解封 kubectl uncordon node-a先 cordon 再 drain 是有意拆开的:cordon 只标记不可调度,新 Pod 不会再来,存量 Pod 继续跑;drain 才是驱逐。分两步做能给自己留出观察窗口,确认没有新 Pod 反弹上来再动手驱逐。
7.3 上线前的自查清单
调完调度规则,我一般会走一遍这几项,五分钟能省掉后面几小时的排查。
| 检查项 | 命令或方法 | 关注点 |
|---|---|---|
| 标签是否打全 | kubectl get nodes -L workload,disktype | 有没有空列 |
| 污点是否生效 | kubectl describe node X | grep Taints | effect 是否为预期值 |
| 选择器能否筛到节点 | kubectl get nodes -l 完整选择器 | 结果不能为空 |
| 目标节点资源余量 | kubectl describe node X | grep -A5 Allocated | requests 合计留出 20% 余量 |
| 系统组件是否覆盖 | kubectl get ds -A对照节点数 | 专用节点上有没有 log/monitor |
| PVC 与节点关系 | kubectl get pv -o wide | 本地卷的 nodeAffinity 是否匹配 |
第五项特别容易被漏掉。把一批节点划成专用之后,日志采集、监控代理、网络插件这些 DaemonSet 如果没加对应容忍,在这些节点上就是缺的。平时不显山不露水,等到某台专用节点出故障,你才发现上面压根没有监控数据,只能靠猜。
最后一件事,也是我踩过最疼的一次:给节点打完污点之后,忘了检查集群里有没有 Pod 用了nodeName直连。那些 Pod 会无视污点继续往上跑,而且在节点被 drain 之后还会被重建回同一个节点,形成"永远驱逐不掉"的死循环。集群里凡是出现nodeName的地方,都值得单独拉一个清单定期过一遍。
这个领域真正难的不是写出那条 YAML,而是判断某个需求到底该用硬约束还是软偏好、该从 Pod 侧拉还是从节点侧推。我个人的体会是,凡是涉及"这块资源专属于某类业务"的,一律用标签加污点的组合,让节点自己声明归属;凡是涉及"这类业务更希望在哪里"的,用 preferred 加权重的软约束,给调度器留出腾挪空间。硬约束每多一条,集群的调度自由度就少一分,密集堆积几个月之后,你会发现自己被当初随手写的 required 困住了。