把一个 Pod 安放到某台特定机器上,在 K8s 里算是暴露最早、被问得最多的操作之一。表面上有很多办法,实际上每种办法解决的问题完全不同:有的只负责“把 Pod 放上去”,有的同时要处理“节点出问题之后怎么办”,还有的会在节点扩容、标签调整、节点池变更的时候悄悄失效。我在实际维护几个 k8s 集群的过程中,因为“Pod 被调度到不该去的地方”和“Pod 一直 Pending 起不来”这两类问题,排查过很多次,也整理出了自己的一套判断顺序。这篇就把 k8s 集群如何将 pod 调度到指定 node 节点这件事拆开讲:从调度器内部的两阶段流程说起,逐个过一遍 nodeName、nodeSelector、nodeAffinity、taints/tolerations、podAffinity、topologySpreadConstraints,最后落到排查思路和我踩过的坑。不管你是刚接触 K8s、只会敲kubectl get pods,还是已经在管生产集群、需要给异构硬件做定向调度,应该都能从里面找到能直接照着写的东西。
1. 先搞清楚调度器在做什么,问题才定位得准
很多人上来就问“怎么把 Pod 固定到某台机器”,其实这句话里藏着两个不同的问题:一是调度决策怎么做出,二是决策做出之后怎么维持。前者归 kube-scheduler 管,后者归 kubelet 和各个控制器管。混在一起想,就容易写出“一开始调度对了,节点重启之后跑偏了”的配置。
1.1 从调度框架看一次完整的筛选与打分
一个 Pod 被创建出来之后,如果在 spec 里没有指定nodeName,就会进入调度队列。kube-scheduler 在每一个调度周期里做两件大事:Filter(过滤)和Score(打分)。Filter 阶段把不满足硬性条件的节点直接划掉,剩下一批“可行节点”;Score 阶段给每个可行节点算一个 0 到 100 的分数,加权求和后选最高分。如果 Filter 之后一个节点都不剩,Pod 就进入 Pending,并在 Event 里写一条类似0/6 nodes are available: 3 node(s) didn't match Pod's node affinity/selector的记录。
理解这个流程有两个直接好处。第一,你会知道硬约束写错的表现是 Pending,而不是被忽略。第二,你会知道软约束写错的表现是“能跑但位置不对”,这种问题最隐蔽,因为 Pod 是 Running 的,监控不报警,直到某天网络延迟异常或者本地盘没挂上才发现。
调度框架本身是一串插件,按顺序执行:PreFilter、Filter、PostFilter、PreScore、Score、NormalizeScore、Reserve、Permit、PreBind、Bind。其中和“指定节点”相关的主要是NodeName、NodeUnschedulable、NodeAffinity、TaintToleration、InterPodAffinity、PodTopologySpread、NodeResourcesFit、NodeResourcesBalancedAllocation、ImageLocality这几个。知道插件名字的一个实际用途是:当你在日志里看到某个插件报错,能立刻判断是哪一类配置有问题,而不是盲目去改 YAML。
提示:
kubectl describe pod里的事件只保留最近若干条,如果 Pod 反复被调度失败,事件可能被刷掉。排查时优先看kubectl -n kube-system logs <scheduler-pod>,或者临时把调度器日志级别调高到 4 以上,能看到每个插件的详细判定过程。
1.2 五种“指定节点”手段的能力边界对比
先把全貌摆出来,后面再逐个展开。这五种方式不是互相替代的关系,而是从“粗暴直给”到“声明式约束”的光谱。
| 方式 | 作用层级 | 是否经过调度器 | 硬/软 | 典型场景 | 主要风险 |
|---|---|---|---|---|---|
nodeName | Pod spec | 否,直接跳过 | 硬 | 调试、静态 Pod、极端兜底 | 绕过资源检查、容忍、亲和,极易起不来 |
nodeSelector | Pod spec | 是 | 硬 | 节点池按硬件/机房打标 | 标签写错直接 Pending,无回退 |
nodeAffinity | Pod spec | 是 | 硬+软 | 需要多条件组合、软偏好 | matchExpressions写反语义 |
taints/tolerations | Node + Pod | 是 | 硬(可配软) | 专用节点池、隔离 | NoExecute误配导致驱逐 |
podAffinity/AntiAffinity | Pod spec | 是 | 硬+软 | 同业务就近、跨故障域打散 | 大集群下性能开销明显 |
选型时的判断顺序,我自己的习惯是这样的:如果目标节点集合是稳定的、由运维统一管理的,用 nodeSelector 打标签最省心;如果还需要“尽量靠近但允许例外”,加 nodeAffinity 的 preferred 段;如果节点是专用的、不允许其他业务混跑,加 taint;如果需要跟随其他 Pod 的位置,才上 podAffinity。至于nodeName,除了调试和 DaemonSet 的极特殊情况,我基本不用。
有一个容易被忽略的点:这几种方式可以叠加,叠加之后是逻辑与的关系。nodeSelector 满足、nodeAffinity 不满足,照样 Pending。所以配置越多,越需要一套“约束自检”的方法,这个后面第 8 节会给出具体命令。
2. nodeName:最快也最危险的一条路
写nodeName: node-03然后kubectl apply,Pod 确实会出现在那台机器上。但这条路绕过的东西比很多人想象的多,我见过不止一次因为图省事写死 nodeName,导致节点维护时 Pod 一直卡在 Pending、还占着 ReplicaSet 的期望副本数。
2.1 nodeName 跳过的那些检查
当 Pod 的 spec 里显式写了nodeName,kube-scheduler 会直接跳过这个 Pod(NodeName插件的Filter阶段判定为不可调度,随后Bind阶段直接被跳过)。这意味着以下检查全部不发生:
- 资源检查:节点 CPU/内存已经满了,kubelet 照样会尝试启动容器,结果可能是容器起不来或者被 OOM 杀掉,而不是在调度阶段就被拦住。
- 污点容忍检查:节点上打了
NoSchedule污点,写了 nodeName 的 Pod 依然会被 kubelet 接受(kubelet 侧的 taint manager 只处理NoExecute)。 - 亲和性与拓扑检查:所有 nodeAffinity、podAffinity、topologySpread 全部失效。
- 节点可调度性:节点处于
Unschedulable(cordon)状态时,nodeName 的 Pod 照样能上去。
换句话说,nodeName 是“绕过调度器直接下达命令”,它把 K8s 最核心的一层抽象给短路了。这在一台机器上手动跑个排查容器时没问题,但绝对不能出现在业务 Deployment 的模板里。
2.2 什么场景下可以放心用
我实际会用 nodeName 的场合只有三类。第一类是临时排查:节点网络有问题,想跑一个带netshoot或busybox的容器上去抓包,此时用kubectl run加--overrides指定 nodeName 最快。第二类是静态 Pod:kubelet 直接管理/etc/kubernetes/manifests下的 YAML,本来就不过调度器,这时候 nodeName 是必需字段。第三类是极端兜底:集群调度器完全不可用(比如升级期间),需要临时把一个关键控制器挪到某台机器上,用完立刻删掉。
除此之外还有一种半被迫的情况:某些自研的运维工具需要用“确定性位置”来做节点级备份,会用一个挂了 nodeName 的一次性 Job。这种我建议至少加上restartPolicy: Never和明确的activeDeadlineSeconds,避免 Job 卡在 Pending 无限重试。
apiVersion: v1 kind: Pod metadata: name: node-debug namespace: default spec: nodeName: node-03 restartPolicy: Never activeDeadlineSeconds: 600 tolerations: - operator: Exists # 显式兜底,防止节点上残留污点 containers: - name: shell image: busybox:1.36 command: ["sh", "-c", "ip addr; sleep 3600"]上面这段里,tolerations写operator: Exists是关键。虽然 nodeName 会绕过调度器,但 kubelet 的 taint manager 会检查NoExecute污点,如果节点上有NoExecute类型的污点而 Pod 不容忍,Pod 会被立刻驱逐。这个坑我在一次节点排障时踩过:Pod 创建成功、状态闪了一下 Running 就消失了,查了半天才发现节点上有维护用的NoExecute污点。
2.3 一个容易被抄错的写法
网上不少示例会写spec.nodeName但忘了spec.containers必须存在,或者把 nodeName 写到 metadata 下面。这些是低级错误,但真实发生过——因为 YAML 不会因为多一个未知字段就报错(除非开了 strict 校验),字段位置错了会被 API Server 的 schema 校验拦住或者直接忽略。我的建议是:生产环境一律开启 server-side apply 或kubectl apply --validate=strict,让字段错误在提交阶段就暴露,而不是等到 Pod Pending 才回头翻 YAML。
3. nodeSelector:九成场景的正确答案
如果要用一句话概括 nodeSelector,就是“给节点贴标签,让 Pod 按标签找家”。它足够简单——一个map[string]string,全部键值对必须同时匹配——所以不容易写错;也足够稳定——标签变更由运维统一管理,业务方只需要约定好键名。
3.1 标签命名必须有规范,否则一定会乱
kubernetes.io/和k8s.io/前缀是保留给系统用的,业务自定义标签要带自己的域名前缀,比如example.com/disktype、example.com/zone、example.com/gpu-model。我见过集群里同时存在disk=ssd、disktype=ssd、storage=ssd三套标签,最后没人说得清哪个是权威来源,新来的同事按文档打标,结果 Pod 全 Pending。
我通常建议在集群里维护一份“标签字典”,至少固定这几类维度:
| 维度 | 示例键 | 示例值 | 用途 |
|---|---|---|---|
| 硬件类型 | example.com/disktype | ssd/hdd/nvme | 本地盘业务定向 |
| 加速卡 | example.com/accelerator | a100/none | 训练任务定向 |
| 可用域 | topology.kubernetes.io/zone | zone-a/zone-b | 跨域打散 |
| 节点池 | example.com/nodepool | general/memory-opt | 集群分层管理 |
| 环境 | example.com/env | prod/staging | 混部隔离 |
topology.kubernetes.io/zone和topology.kubernetes.io/region是 K8s 内置约定键,用它们能让 topologySpreadConstraints 自动识别故障域,比自己造一个az键更通用。这个细节在第 7 节讲拓扑打散时会再提。
打标签的批量操作不复杂,但要成组做:
# 给节点池打标,键名统一带域名前缀 kubectl label nodes node-01 node-02 node-03 \ example.com/nodepool=ssd-pool example.com/disktype=nvme --overwrite # 确认结果,按标签反查节点最直观 kubectl get nodes -l example.com/disktype=nvme -o wide--overwrite是必需的,因为标签一旦存在,再次赋值会报错。批量脚本里如果不加这个参数,第二次执行就会集体失败,而脚本通常只打印第一行错误,很容易被忽略。
3.2 nodeSelector 的匹配语义与失效排查
nodeSelector 的语义是“全等匹配,全部键值对都要满足”。不支持表达式,不支持范围,不支持正则。这个限制看起来很死板,但恰恰是它的优点——不会有人写出语义模糊的配置。
失效排查其实很机械,三步:
# 1. 看 Pod 当前被卡在哪 kubectl -n prod describe pod web-7d8f9c6b5-x2k4l | sed -n '/Events/,$p' # 2. 看节点上实际有哪些标签 kubectl get nodes --show-labels # 3. 反向验证:用同样的选择器查节点,应该非空 kubectl get nodes -l example.com/disktype=nvme第 3 步是我最推荐的习惯。写 YAML 之前先跑一遍kubectl get nodes -l <selector>,如果返回空,就不要往下写了。这个动作花三秒钟,能省掉一次 Pending 排查。
还有一个更隐蔽的情况:标签被其他运维脚本覆盖。有些云厂商的节点池管理组件会周期性同步标签,把手工打的某些键给冲掉。表现是“早上还能调度,下午就 Pending 了”。判断方法是看节点标签的最后修改痕迹,或者直接对可疑键做变更记录。我现在的做法是:业务相关的标签只用自己域名的前缀,不碰云厂商管理组件会改的那批键(比如node.kubernetes.io/*、kubernetes.io/*)。
3.3 配置预检:把 Pending 挡在提交之前
真正让 Pending 变少的不是排查能力,而是提交前的检查。我给团队加的流程是在 CI 里跑一条 dry-run 校验:
# 用 --dry-run=server 让 API Server 做完整校验 kubectl apply -f deployment.yaml --dry-run=server--dry-run=server不会真的创建资源,但会走完准入控制链(Admission),包括 LimitRange、ResourceQuota、PodSecurity 等。它抓不到“标签不存在”这种逻辑错误,但能抓到字段写错、配额超限这类问题。至于标签是否存在的校验,需要自己写个几十行的脚本:解析 YAML 里的 nodeSelector,逐个键值对去kubectl get nodes -l验证。
注意:
--dry-run=server在某些老版本上会因为没有对应处理而表现得像 client 端 dry-run,判断依据是返回信息里有没有(server dry run)。如果版本不确定,用kubectl apply -f x.yaml --dry-run=server -v=6看请求日志更可靠。
4. nodeAffinity:当 nodeSelector 不够用的时候
nodeSelector 的短板很明确:只能全等匹配,无法表达“磁盘是 ssd 或者 nvme 都行”,也无法表达“优先靠近某类节点,实在没有也能跑”。这两件事分别对应 nodeAffinity 的 required 段和 preferred 段。
4.1 硬约束:写错就是 Pending,且没有商量
requiredDuringSchedulingIgnoredDuringExecution这串名字很长,拆开看就清楚了:required表示硬性条件,IgnoredDuringExecution表示Pod 运行之后节点标签变化不会驱逐它。这一点非常重要,很多人误以为打了 nodeAffinity 之后节点标签一变 Pod 就会跑掉,其实不会。
apiVersion: apps/v1 kind: Deployment metadata: name: nvme-cache namespace: prod spec: replicas: 2 selector: matchLabels: app: nvme-cache template: metadata: labels: app: nvme-cache spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: example.com/disktype operator: In values: ["nvme", "ssd"] - key: example.com/nodepool operator: NotIn values: ["staging"] containers: - name: cache image: registry.local/cache:1.4.2这里有两个语义点必须说清。第一,nodeSelectorTerms数组内部的关系是或,matchExpressions数组内部的关系是与。上面这段的意思是:((disktype 是 nvme 或 ssd)且(nodepool 不是 staging))。如果写成两个 term,就变成“disktype 是 nvme”或者“nodepool 不是 staging”,范围大得多。第二,In、NotIn、Exists、DoesNotExist、Gt、Lt六个运算符里,Gt和Lt只支持单个整数值,且节点的标签值必须能被解析成整数,写values: ["4"]而不是values: [4]在某些版本上行为不一致,稳妥起见统一用字符串。
4.2 软约束:preferred 的 weight 怎么定
preferredDuringSchedulingIgnoredDuringExecution是一个列表,每项带weight(1 到 100)和一个preference。同一个节点可能命中多条偏好,最终得分是各条命中项 weight 之和,再按插件规则归一化到 0 到 100。
affinity: nodeAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 80 preference: matchExpressions: - key: example.com/disktype operator: In values: ["nvme"] - weight: 20 preference: matchExpressions: - key: topology.kubernetes.io/zone operator: In values: ["zone-a"]weight 怎么分配,我的经验是抓“主要意图”和“次要意图”的比值。上面这段表达的是“强烈希望用 nvme 节点,顺带希望落在 zone-a”,80 比 20 的四倍差距足以让 nvme 优先于同域。如果两条权重接近(比如 50 和 50),结果会依赖其他打分插件(资源均衡、镜像本地性)的得分,行为就不那么可预期了。
有个反直觉的点值得强调:preferred 全部不满足时,Pod 仍然会被调度。所以软约束不能用来做安全隔离,只能用来做性能优化。任何“绝对不能跑在某类节点上”的需求,都必须用 required 或者 taint。
4.3 硬约束与软约束组合的正确姿势
一个典型的生产需求:业务主流量必须落在 SSD 节点池,但如果 SSD 池资源紧张,允许临时溢出到普通池,只是要尽量少。这种需求单靠 required 会直接 Pending,单靠 preferred 又控制不住,正确写法是“required 划大边界 + preferred 排优先级”。
affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: example.com/nodepool operator: In values: ["ssd-pool", "general-pool"] preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 preference: matchExpressions: - key: example.com/nodepool operator: In values: ["ssd-pool"]required 段把候选范围限定在两个池里,preferred 段让 ssd-pool 得满权重。这样只要 ssd-pool 还有余量,Pod 一定优先去那里;资源耗尽时才落到 general-pool。这个模式我在好几个集群里用过,效果比单纯写 nodeSelector 好很多,因为 nodeSelector 是硬等值,没有“溢出”这个中间状态。
4.4 排查 nodeAffinity 不生效的四个常见原因
第一,标签值大小写。SSD和ssd是两个不同的值,YAML 里写成大写而节点是小写,Filter 阶段直接失败。第二,matchExpressions 与 matchFields 混用。matchFields只支持metadata.name一个字段,用来按节点名精确指定,写法是key: metadata.name, operator: In, values: ["node-03"],很多人不知道有这个能力,白白去写 nodeName。第三,required 段写成数组格式错误,nodeSelectorTerms必须是数组,漏了-会解析失败。第四,旧版本兼容,1.18 之前 nodeAffinity 是 beta,字段名带beta.kubernetes.io的节点标签已经被移除,老示例里常见beta.kubernetes.io/os,现在应该用kubernetes.io/os。
这里顺带把matchFields的用法完整写一遍,它在“按节点名指定”这个需求上比 nodeName 安全得多,因为它仍然走调度器:
affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchFields: - key: metadata.name operator: In values: ["node-03", "node-04"]提示:
matchFields只认metadata.name,写别的字段不会报错但永远不会匹配,表现就是 Pending。这个坑很隐蔽,因为 YAML 校验是通过的。排查时先kubectl get pod -o yaml确认字段拼写,再看调度器日志里的NodeAffinity插件输出。
5. 污点与容忍:从节点侧做反向选择
前面几节都是“Pod 主动挑节点”。污点(taint)反过来,是“节点声明自己不欢迎谁”。这两个方向的组合,才是完整的隔离模型。生产集群里“专用节点池”的标准做法一定是 taint 打底、tolerations 放行,而不是只靠 nodeSelector。
5.1 三种 effect 的语义差别
| effect | 对已调度 Pod | 对新 Pod | 典型用途 |
|---|---|---|---|
NoSchedule | 不影响 | 不容忍则不上 | 专用池隔离,最常用 |
PreferNoSchedule | 不影响 | 软性劝阻,仍可上 | 过渡期引导,不建议长期用 |
NoExecute | 不容忍则驱逐 | 不容忍则不上 | 节点下线、故障隔离 |
PreferNoSchedule在实践中我基本不用,因为它的“劝阻”会参与打分,但效果取决于其他插件得分,行为不够确定,团队里没人能准确预期结果。要么用NoSchedule明确隔离,要么什么都不写。
NoExecute要格外小心,它带一个可选字段tolerationSeconds,表示“容忍多少秒之后再驱逐”。这个字段在节点故障场景里非常有用,但也容易配错。比如写成tolerationSeconds: 0,等于不容忍,Pod 立刻被踢走;写成一个很大的值(比如 86400),又失去了故障隔离的意义。
5.2 专用节点池的完整配置
需求:三台高内存机器只跑内存密集型业务,其他任何 Pod 都不许上。完整做法是节点打标 + 打污点,业务侧同时写 nodeSelector 和 toleration。
# 节点侧:标签 + 污点,两步都要做 kubectl label nodes mem-01 mem-02 mem-03 example.com/nodepool=memory-opt --overwrite kubectl taint nodes mem-01 mem-02 mem-03 dedicated=memory-opt:NoSchedule --overwrite# 业务侧:选择器 + 容忍,缺一不可 spec: nodeSelector: example.com/nodepool: memory-opt tolerations: - key: dedicated operator: Equal value: memory-opt effect: NoSchedule为什么标签和污点都要?只打污点,业务方需要知道污点的键值才能容忍,接口不直观;只打标签,别的业务不小心写了同样的 nodeSelector 就混进来了。两个都做,标签负责“我要去哪里”,污点负责“别人不许来”,职责清晰。
operator: Equal和operator: Exists的区别也要说清楚。Equal要求 value 完全一致,更安全;Exists只要求键存在,不校验值,适合“只要是为专用目的打的污点我都容忍”的宽泛场景。我倾向在生产里用Equal,因为Exists会让一个 Pod 意外容忍了本来不该容忍的节点。
5.3 NoExecute 误配导致的一次批量驱逐
有个真实场景值得记下来。某次为了做节点维护,给一台机器临时打了NoExecute污点,本意是“把上面的 Pod 赶走”。结果发现集群里有一批 Pod 的 tolerations 写的是operator: Exists且不限 effect,也就是容忍所有污点。这批 Pod 原本分布在各个节点,NoExecute一打,它们反而全部往这台待维护的机器上涌——因为其他节点没有这个污点,而这台节点它们也“容忍”,调度器在打分时还会因为资源空闲给高分。
这个反直觉的结果说明一件事:operator: Exists不加约束是危险配置。正确的写法是显式限定 key 和 effect:
tolerations: - key: node.kubernetes.io/not-ready operator: Exists effect: NoExecute tolerationSeconds: 300 - key: node.kubernetes.io/unreachable operator: Exists effect: NoExecute tolerationSeconds: 300这两条是 K8s 默认给每个 Pod 加的(通过 Admission 注入),作用是节点失联 5 分钟后才驱逐,而不是立刻驱逐。如果你在 YAML 里手写了tolerations数组,会覆盖默认注入的内容,导致这些保护消失。这是一个非常容易踩的坑:想让 Pod 容忍某个专用池的NoSchedule,结果顺手把默认的NoExecute容忍弄没了,节点一抖 Pod 就被踢走,业务抖动。
注意:想在不覆盖默认容忍的前提下追加,正确做法是只追加自己的那一条,让默认注入继续生效。如果你在
kubectl get pod -o yaml里看不到node.kubernetes.io/not-ready的容忍,说明默认注入被覆盖了,需要补回来。
6. Pod 亲和与反亲和:以 Pod 为中心的位置约束
有一类需求没法用节点标签表达:不是“我要去哪台机器”,而是“我要离某个 Pod 近一点”或者“我要离某个 Pod 远一点”。典型例子是缓存服务和它的后端服务同节点部署降低跨节点延迟,或者同一副本集的多个实例必须打散到不同故障域。这就是 podAffinity 和 podAntiAffinity 的领域。
6.1 topologyKey 是这套机制的核心
podAffinity 的判断逻辑是:找出符合labelSelector的已有 Pod,取它们所在节点的topologyKey标签值,组成一个集合;然后检查候选节点的同名标签值是否在这个集合里。所以topologyKey决定了“近”的粒度。
| topologyKey 取值 | 粒度 | 效果 |
|---|---|---|
kubernetes.io/hostname | 单节点 | 必须/尽量同机或不同机 |
topology.kubernetes.io/zone | 可用域 | 同域或跨域 |
topology.kubernetes.io/region | 地域 | 同地域或跨地域 |
自定义键(如example.com/rack) | 机架 | 需自行打标 |
一个必须注意的前提:所有节点都必须有这个 topologyKey 标签。如果只有部分节点打了example.com/rack,那么没有这个标签的节点在 Filter 阶段会直接不匹配,表现是“莫名其妙只有几台能用”。这个坑我在一次机架感知调度里踩过,排查了半天才发现是新扩容的机器漏打了标签。
apiVersion: apps/v1 kind: Deployment metadata: name: api-server namespace: prod spec: replicas: 6 selector: matchLabels: app: api-server template: metadata: labels: app: api-server spec: affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: api-server topologyKey: kubernetes.io/hostname containers: - name: api image: registry.local/api:2.3.1上面这段的意思是“同一个 app=api-server 的 Pod 不能出现在同一台机器上”。6 个副本就需要至少 6 台可调度节点,少于 6 台就会有副本 Pending。这个数量关系在做容量规划时必须算清楚:required 反亲和的副本数上限等于符合条件的节点数。
6.2 软反亲和:高可用打散的常用写法
required 反亲和太硬,节点数一少就 Pending。生产里更常见的是软反亲和,配合topologySpreadConstraints使用:
affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchLabels: app: api-server topologyKey: kubernetes.io/hostname软反亲和的权重建议直接给 100,因为它是“尽量打散”这个意图的唯一表达,如果只给 30,会被资源均衡插件的得分盖过去,最终可能还是扎堆。
6.3 大集群下的性能开销,别忽略
podAffinity/podAntiAffinity 的判定需要遍历已有 Pod,复杂度大致是 O(节点数 × 待检查 Pod 数) 的量级,而且每个调度周期都要重算。在 Pod 数量达到几千、节点上百的集群里,这个开销会体现在调度延迟上。我见过一个集群因为给所有 Deployment 都加了 podAntiAffinity,调度器 P99 延迟从几十毫秒涨到几百毫秒,最终 solution 是把大部分改成 topologySpreadConstraints。
topologySpreadConstraints是更新的机制,语义更清晰、性能也更好,做“均匀打散”这类需求应该优先用它,而不是软反亲和。
topologySpreadConstraints: - maxSkew: 1 topologyKey: topology.kubernetes.io/zone whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: app: api-server - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: api-servermaxSkew是允许的最大倾斜度,whenUnsatisfiable决定不满足时是硬失败(DoNotSchedule)还是放行(ScheduleAnyway)。上面两条的组合意思是“跨可用域尽量均匀,同域内跨节点严格均匀”。这种分层的打散需求,用 podAntiAffinity 写起来会很别扭,用 spread 就直观得多。
7. 约束叠加时的执行顺序与冲突处理
真实环境里,一个 Pod 身上往往同时挂着 nodeSelector、nodeAffinity、tolerations、podAntiAffinity、topologySpread。它们之间是与关系,冲突时表现是 Pending,而不是某个约束被忽略。理解这个,排查思路就很清晰了。
7.1 逐层收窄的排查法
我在遇到 Pending 时的固定做法是先“放大范围”,再逐层收窄。具体是:
# 第一步:确认节点池本身是否可调度 kubectl get nodes -o wide # 第二步:按 nodeSelector 反查,缩小到候选集 kubectl get nodes -l example.com/nodepool=ssd-pool --show-labels # 第三步:看候选集里哪些节点被打上了不可调度的标记 kubectl get nodes -l example.com/nodepool=ssd-pool -o json | \ jq -r '.items[] | select(.spec.taints != null) | .metadata.name + " " + (.spec.taints|tostring)' # 第四步:看候选集剩余资源 kubectl describe node node-03 | sed -n '/Allocated resources/,/Events/p'四步走完,基本就能定位是哪一层约束过不去。如果 Event 里明确写了node(s) had untolerated taint,就是污点问题;写node(s) didn't match Pod's node affinity/selector,就是选择器问题;写Insufficient cpu,就是资源问题;写node(s) didn't satisfy existing pods anti-affinity rules,就是反亲和问题。这些文案很具体,读懂了就不用猜。
7.2 亲和权重和打分插件的相互影响
软约束的最终结果不是单一插件决定的,而是多个 Score 插件加权求和。默认权重下,NodeAffinity(软亲和)和InterPodAffinity(软反亲和)各占 1,NodeResourcesFit和NodeResourcesBalancedAllocation也占 1,ImageLocality占 1。这意味着你给了 100 权重的软亲和,最终归一化后只相当于 1 分里的满分,其他插件同样能贡献 1 分。
所以在多约束场景里,软约束只能表达“倾向”,不能保证“结果”。如果你需要保证,就必须写进 required 段。这点在面试和实际排查里都是高频误解,很多人以为权重 100 就一定优先,实际上只是相对该插件内的得分。
7.3 给关键业务加第二道保险
即便配置都写对了,也还有“配置被覆盖”的风险——比如有人执行了kubectl label node xxx key-把标签删了,或者节点池管理组件重建了节点。我的做法是给关键业务加一个轻量的告警:定期跑一个校验脚本,比对“Pod 实际所在节点的标签”和“Pod 期望的 nodeSelector”是否一致,不一致就报警。
#!/usr/bin/env bash # 简化版校验:列出所有带 nodeSelector 的 Pod 及其所在节点的实际标签值 kubectl get pods -A -o json | jq -r ' .items[] | select(.spec.nodeSelector != null and .spec.nodeName != null) | .metadata.namespace + "/" + .metadata.name' | while read -r p; do ns="${p%%/*}"; name="${p##*/}" sel=$(kubectl -n "$ns" get pod "$name" -o jsonpath='{.spec.nodeSelector}') node=$(kubectl -n "$ns" get pod "$name" -o jsonpath='{.spec.nodeName}') actual=$(kubectl get node "$node" -o json | jq -c --argjson sel "$sel" \ '[.metadata.labels | to_entries[] | select(.key as $k | $sel|has($k)) | {(.key):.value}] | add // {}') echo "$p node=$node expect=$sel actual=$actual" done脚本粗糙但够用,重点是把它放进定时任务,每周跑一次并把差异输出到日报里。真正出问题的往往不是写错配置,而是配置之外的变动。
8. 调度失败排查实录与速查表
这一节把前面散落的排查经验收拢成可以直接对号入座的表。我建议在值班手册里就放这张表,比翻文档快。
8.1 从 Event 文本直接定位问题类型
| Event 中的关键文本 | 含义 | 优先检查 |
|---|---|---|
didn't match Pod's node affinity/selector | 节点标签不满足 | nodeSelector 键值、nodeAffinity 运算符 |
had untolerated taint | 污点不容忍 | 节点 taints、Pod tolerations 的 effect |
Insufficient cpu/memory | 资源不足 | 节点 Allocated、requests 配置 |
didn't satisfy existing pods anti-affinity rules | 反亲和冲突 | 副本数与节点数关系 |
node(s) were unschedulable | 节点被 cordon | kubectl get nodes的 SchedulingDisabled |
node(s) had volume node affinity conflict | PV 绑定的节点冲突 | PV 的 nodeAffinity、StorageClass |
no nodes available to schedule pods | 候选集为空 | 集群是否有 Ready 节点 |
最后一条容易被忽略:volume node affinity conflict经常和“指定节点”需求一起出现。本地盘 PV 会自带nodeAffinity,Pod 用了这个 PV 就会被隐式绑定到特定节点。这时候你再写 nodeSelector 选了另一个节点,两边冲突,Pod 就 Pending。判断方法是kubectl get pv <pv-name> -o yaml | grep -A10 nodeAffinity。
8.2 常见问题速查与处置
| 现象 | 可能原因 | 处置动作 |
|---|---|---|
| 单副本能起,扩到多副本就 Pending | required 反亲和节点数不足 | 改 preferred 或加 topologySpread |
| 早高峰批量 Pending,下午自行恢复 | 资源碎片 + 硬选择器过窄 | 放宽 required 范围,加 preferred |
| 换了节点池后全部 Pending | 新节点漏打标签 | 补打标签,校验脚本纳入 CI |
| Pod 反复重建位置漂移 | nodeName 与控制器冲突 | 去掉 nodeName,改 nodeSelector |
| 驱逐后 Pod 不回来 | NoExecute 污点残留 | 检查 taints,清理维护污点 |
| 调度成功但容器起不来 | 镜像架构不匹配、本地盘未挂 | 看 kubelet 日志,确认节点架构 |
倒数第二条“驱逐后 Pod 不回来”是我遇到过最耽误时间的一种。节点维护完,污点忘了清,Pod 一直被拒之门外,状态显示 Pending,Event 里写着had untolerated taint {dedicated: memory-opt},但因为污点键名是运维自己起的、业务方不认识,两边扯了很久。后来我们规定所有维护类污点统一用node.kubernetes.io/之外的自定义前缀,并在变更单里写明清除步骤,这类问题就少了。
8.3 三个我常用的验证动作
第一个,用kubectl run造一个最小 Pod 去验证标签,比改业务 YAML 快得多:
kubectl run probe --image=busybox:1.36 --restart=Never \ --overrides='{"spec":{"nodeSelector":{"example.com/nodepool":"ssd-pool"},"tolerations":[{"key":"dedicated","operator":"Equal","value":"ssd-pool","effect":"NoSchedule"}]}}' \ -- sleep 3600 kubectl get pod probe -o wide第二个,看调度器的 Pending 队列。kubectl get events --field-selector reason=FailedScheduling -A --sort-by=.lastTimestamp能看到全集群的调度失败记录,按时间排序,一眼看出是普遍问题还是个别 Pod。
第三个,用--dry-run=server加-o yaml看最终生效的 spec。有些字段会被准入控制改写(比如默认 tolerations、默认 topologySpread),看最终结果比看源文件准确。
9. 生产环境里几条踩出来的硬经验
配置层面讲完了,最后说说那些只有跑过一段时间才会总结出来的东西。这些不属于任何单个 API 的语法,但直接决定了“指定节点”这件事在长期运维里是省心还是添乱。
9.1 标签治理比调度配置本身更重要
我待过的集群里,调度出问题的根因八成不在 YAML,而在标签体系。三条经验:键名带域名前缀,避免和系统键冲突;键的数量控制在 10 个以内,每多一个维度就多一份维护成本;标签变更走变更流程,不能让人随手kubectl label改生产节点的池归属。第三条听着官僚,但确实救过我们一次——有人把一台 ssd-pool 的节点误标成 general-pool,导致一批缓存 Pod 调度到了普通盘机器上,性能监控报警才发现。
9.2 不要把副本钉死在少数节点上
用 required 反亲和或者nodeSelector配合少量节点时,一定要算清楚“节点故障后的剩余容量”。6 个副本、硬反亲和、只有 6 台可调度节点,意味着任何一台机器下线就有一个副本 Pending。我现在的习惯是让可调度节点数至少比副本数多 30%,或者干脆用软反亲和加 topologySpread,把“必须打散”降级成“尽量打散”,换取故障时的弹性。
9.3 升级和扩容时的隐性失效
集群扩容新节点、节点池滚动更新、K8s 版本升级,这三件事都可能让原本有效的调度配置失效。新节点没打标签是最常见的;滚动更新后节点名变了,matchFields按名字指定的配置就失效;版本升级后某些 beta 字段被移除,YAML 里的字段被静默忽略。我的建议是把“调度约束校验”做成发布流水线的一环,每次节点池变更后自动跑一遍校验脚本,把差异作为变更验收的一部分。
9.4 一个容易被低估的小技巧
如果只是想让某个 Pod“尽量”落在某台机器上做临时观察,其实不需要写任何亲和配置,用kubectl cordon把其他节点临时封掉,调度器自然会把新 Pod 放到剩下的那台上,观察完再uncordon。这个方法不侵入业务 YAML,也不留后患,比改配置、等滚动更新快得多。唯一的注意点是别在业务高峰做,因为 cordon 会阻止所有新 Pod 落到被封锁的节点,可能引发其他业务的容量问题。
上面这些就是我围绕“把 Pod 调度到指定节点”这件事积累下来的全部判断依据和操作习惯。从 nodeName 的直给,到 nodeSelector 的稳,再到 nodeAffinity 的灵活、taint 的反向隔离、podAffinity 的跟随,每一层都有它恰当的位置。真正决定成败的往往不是选哪个 API,而是有没有把“约束的边界”和“故障时的退路”提前想清楚。我现在写任何调度配置之前,都会先问自己一句:这台节点明天挂了,这个 Pod 还能去哪儿。想明白这个问题,配置自然就写对了。