Selector、Affinity、Taint 与拓扑放置
一次常规发布把六个 API 副本全部调度到了同一可用区。Deployment 的副本数、PodDisruptionBudget 和节点总 CPU 看起来都充足,但那一区域网络抖动时,六个副本同时失去上游连接。复盘发现 Pod 只写了 nodeSelector: workload=online,而三个可用区的节点都带这个标签;调度器在满足硬约束的节点中按当时分数选择,副本数并不会自动等于故障域冗余。
团队随后叠加 zone affinity、专用节点 taint、Pod anti-affinity 和 topology spread,下一次发布却有一半 Pod 长期 Pending。集群还有数十核空闲 CPU,但同时满足“指定区域、NVMe 标签、容忍专用污点、与同应用不同 hostname、最大偏斜为一”的节点集合为空。扩容器继续增加通用节点,调度结论仍然不变。容量没有消失,它被互相求交的约束切成了无法使用的碎片。
用“过滤、评分、绑定”理解每个字段的位置
kube-scheduler 观察尚未绑定的 Pod,经过调度队列、PreFilter/Filter、PostFilter、PreScore/Score、Reserve、Permit、PreBind 和 Bind 等扩展点,为 Pod 选择一个节点。硬约束在 Filter 阶段排除节点;软偏好通常在 Score 阶段改变候选节点排序;如果没有任何可行节点,Pod 保持 Pending,Event 汇总各插件的失败原因。调度器不会因为约束不合理而自动删除字段,也不会移动已经运行的 Pod。
nodeSelector 是精确标签等值匹配。nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution 是更有表达力的硬约束,支持 In、NotIn、Exists、DoesNotExist、Gt、Lt;preferredDuringSchedulingIgnoredDuringExecution 是带权重的软偏好。名字中的 IgnoredDuringExecution 意味着节点标签以后变化时,已经运行的 Pod 通常不会因此被自动驱逐。
taint 从节点方向表达“默认拒绝哪些 Pod”,toleration 只让 Pod 有资格通过该拒绝条件,不保证它一定落到该节点。NoSchedule 阻挡新调度,PreferNoSchedule 是软拒绝,NoExecute 还会驱逐不容忍的既有 Pod,并可配 tolerationSeconds。拓扑分布约束则按标签选择一组 Pod,计算它们在 zone、hostname 等域上的数量偏斜。
这些对象的官方语义可分别查阅 节点分配、污点与容忍和拓扑分布约束。字段相似不代表可以互换:专用节点通常同时使用 taint 和 label/affinity,前者拒绝无关工作负载,后者把目标工作负载吸引回来。
先建立可信的节点标签与实验基线
原生调度能力随 Kubernetes 控制面提供,无需另外安装。实验需要至少三个可调度节点;若本地集群只有一个节点,可以验证 Pending 机制,但无法证明跨节点或跨 zone 分布。先确认调度器、节点污点、可分配资源和当前权限:
kubectl version
kubectl get nodes -o wide
kubectl get nodes --show-labels
kubectl describe nodes | grep -E 'Name:|Taints:|Allocatable:'
kubectl auth can-i patch nodes
kubectl auth can-i create deployments -n placement-lab
kubectl auth can-i list events -n placement-lab选择三台非生产实验节点,分别替换 <node-a>、<node-b>、<node-c>。标签 key 使用示例域名,避免冒充 Kubernetes 保留标签:
kubectl create namespace placement-lab
kubectl label node <node-a> placement.example.com/pool=general \
topology.kubernetes.io/zone=lab-a
kubectl label node <node-b> placement.example.com/pool=general \
topology.kubernetes.io/zone=lab-b
kubectl label node <node-c> placement.example.com/pool=general \
topology.kubernetes.io/zone=lab-c
kubectl get nodes -L placement.example.com/pool,topology.kubernetes.io/zone生产集群通常由云控制器或节点供应系统维护 topology.kubernetes.io/zone,不要随意覆盖真实值。实验若借用该 key,必须确认节点并非生产供给器管理;更稳妥的做法是使用 placement.example.com/zone 并在清理时移除。标签只是声明,标签值必须与真实故障域、硬件和合规事实一致。
nodeSelector 与 NodeAffinity 从简单约束递进
固定工作负载到通用池,最小写法是:
spec:
template:
spec:
nodeSelector:
placement.example.com/pool: general需要“通用或计算池”以及软偏好 lab-a 时,改用 node affinity:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: placement.example.com/pool
operator: In
values: [general, compute]
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 60
preference:
matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values: [lab-a]同一个 nodeSelectorTerm 内的 expressions 取 AND,不同 terms 取 OR;nodeSelector 与 required node affinity 同时存在时两者都必须满足。权重范围影响 NodeAffinity 插件加分,但最终分数还会与资源均衡、镜像位置、拓扑等插件共同归一化,不能把 weight: 100 理解为绝对绑定。
硬约束用于不可违反的事实,例如 CPU 架构、设备、数据驻留或卷拓扑;软偏好用于可降级目标,例如优先低成本池、尽量同区或缓存命中。把成本偏好写成 required,会在便宜节点缺货时让业务停摆;把合规域写成 preferred,则可能在压力下落到不合规节点。
Taint 与 Toleration 建立专用节点双向契约
给 <node-c> 增加专用池污点:
kubectl taint node <node-c> dedicated.example.com/team=payments:NoSchedule
kubectl get node <node-c> -o jsonpath='{.spec.taints}{"\n"}'Pod 若只有下面的 toleration,只是允许进入该节点,也可能被放到其他无污点节点:
tolerations:
- key: dedicated.example.com/team
operator: Equal
value: payments
effect: NoSchedule真正的专用池通常再加 placement.example.com/pool=payments 标签,并让目标 Pod 使用 required node affinity。这样“污点拒绝其他 Pod,亲和性要求目标 Pod 来这里”形成双向契约。系统 DaemonSet 是否需要容忍该污点必须逐项判断;CNI、CSI、监控和安全代理缺少 toleration,会让专用节点表面 Ready、实际不可用。
NoExecute 风险更高。给节点添加后,不容忍的既有 Pod 会被驱逐;带 tolerationSeconds 的 Pod 只暂留指定秒数。执行前列出节点上所有 Pod、PDB、本地数据和控制面组件。节点压力相关污点通常由 kubelet/控制器维护,不应手工删除来隐藏磁盘、内存或网络问题。
Topology Spread 把副本数转换为故障域分布
下面的 Deployment 要求三个副本在 hostname 上最大偏斜为一,同时倾向跨 zone 均匀分布。hostname 使用硬约束防止单机故障吞掉多个副本,zone 使用 ScheduleAnyway 让局部容量不足时仍可降级运行:
apiVersion: apps/v1
kind: Deployment
metadata:
name: placement-good
namespace: placement-lab
spec:
replicas: 3
selector:
matchLabels:
app: placement-good
template:
metadata:
labels:
app: placement-good
spec:
nodeSelector:
placement.example.com/pool: general
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: placement-good
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: placement-good
containers:
- name: pause
image: registry.k8s.io/pause:3.10
resources:
requests:
cpu: 20m
memory: 16MimaxSkew 的解释依赖 whenUnsatisfiable,minDomains 会改变合格域不足时的全局最小值,nodeAffinityPolicy 与 nodeTaintsPolicy 决定计算域时是否尊重 Pod 的节点亲和性和污点。labelSelector 若选错,会把不同版本、租户或无关 Pod 混入计数,或者完全不计数。集群级默认约束还可能由调度器配置注入,因此排障要读取最终 Pod spec 与调度器配置,而不只看 Deployment 模板。
正向实验:证明硬分散与软降级同时生效
将上一段保存为 placement-good.yaml,应用并观察:
kubectl apply -f placement-good.yaml
kubectl -n placement-lab rollout status deploy/placement-good
kubectl -n placement-lab get pods -l app=placement-good \
-o custom-columns=NAME:.metadata.name,NODE:.spec.nodeName,READY:.status.containerStatuses[0].ready
kubectl get nodes -L topology.kubernetes.io/zone,placement.example.com/pool
kubectl -n placement-lab get events --sort-by=.lastTimestamp在三台 general 节点均可用且标签准确时,预期三个 Pod 分布到不同 hostname;zone 分布也应尽量均衡。如果某个 zone 没有合格节点,zone 约束允许 ScheduleAnyway,Pod 仍可调度,但 hostname 硬约束继续生效。这里的“预期”是读者执行后的判据,不代表仓库环境已运行该实验。
继续 cordon 一个承载实验 Pod 的节点并删除该 Pod,观察 Deployment 重建:
kubectl cordon <node-hosting-placement-good>
kubectl -n placement-lab delete pod <one-placement-good-pod>
kubectl -n placement-lab get pods -w若只剩两个可调度 hostname,第三个副本应 Pending,因为 DoNotSchedule 不允许超过偏斜;这证明硬故障域约束优先于副本立即就绪。恢复节点后,Pending Pod 应获得落点:
kubectl uncordon <node-hosting-placement-good>
kubectl -n placement-lab get pods -l app=placement-good -o wide反向实验:制造交集为空并读取 FailedScheduling
只给 <node-a> 打上 NVMe 标签,却让 Pod 同时要求 zone 为 lab-b 和磁盘为 nvme。两个条件分别存在,但没有同一节点同时满足:
kubectl label node <node-a> placement.example.com/disk=nvme
kubectl -n placement-lab run impossible-placement \
--image=registry.k8s.io/pause:3.10 \
--overrides='{
"apiVersion":"v1",
"spec":{
"affinity":{"nodeAffinity":{"requiredDuringSchedulingIgnoredDuringExecution":{
"nodeSelectorTerms":[{"matchExpressions":[
{"key":"topology.kubernetes.io/zone","operator":"In","values":["lab-b"]},
{"key":"placement.example.com/disk","operator":"In","values":["nvme"]}
]}]
}}}
}
}'预期 Pod 保持 Pending。读取调度器生成的证据:
kubectl -n placement-lab get pod impossible-placement -o wide
kubectl -n placement-lab describe pod impossible-placement
kubectl -n placement-lab get events \
--field-selector involvedObject.name=impossible-placement \
--sort-by=.lastTimestampFailedScheduling 通常会汇总“不匹配 Pod 的 node affinity/selector”等原因;不同版本、插件和节点状态的计数文字可能不同,不应依赖整句日志做自动化。关键不变量是 spec.nodeName 为空、PodScheduled 条件为 False,且事件指出硬约束没有可行节点。给通用池扩容不会修复标签交集,只有修正错误约束或供给同时满足 lab-b + nvme 的节点才有效。
验证后删除反例并移除实验硬件标签:
kubectl -n placement-lab delete pod impossible-placement
kubectl label node <node-a> placement.example.com/disk-从事件反推 Filter 插件,而不是盲目扩容
Pending 排障先保存 Pod 最终 spec、调度条件和 Events,再依次计算候选集合。先看 Node 是否 Ready/unschedulable;再看 requests 与 allocatable;再求 nodeSelector/nodeAffinity;随后检查 taint/toleration、卷 node affinity、端口、Pod affinity/anti-affinity 和 topology spread。每加一层都记录剩余节点数,就能找到集合在哪一步变成零。
kubectl -n <namespace> get pod <pod> -o yaml
kubectl -n <namespace> describe pod <pod>
kubectl get nodes -o custom-columns=NAME:.metadata.name,READY:.status.conditions[-1].status,UNSCHEDULABLE:.spec.unschedulable
kubectl get nodes --show-labels
kubectl describe node <candidate-node>事件中的 Insufficient cpu 使用 requests 判断,不等于节点实时 CPU 使用率高;untolerated taint 表示接纳被阻挡;didn't match Pod's node affinity/selector 指向标签交集;volume node affinity conflict 表明卷拓扑与节点冲突。抢占“没有帮助”通常意味着即使删除低优先级 Pod,标签、污点、卷或拓扑硬约束仍无法满足。
调度器成功绑定后便不负责持续重平衡。节点标签变化、扩容出现更优节点、软偏好改变,都不会自动搬迁既有 Pod。重建 Deployment、节点排空或 Descheduler 驱逐只会让 Pod 再次进入调度;驱逐成功也不保证新落点更好,必须重新验证 PDB、容量和约束。
亲和性表达越强,调度计算与变更风险越高
Pod affinity/anti-affinity 按“其他 Pod 的标签 + 拓扑域”决定候选,适合让紧密通信组件靠近或让同一服务分散。它需要读取并匹配现有 Pod,规模大、标签宽泛时计算成本更高;硬 anti-affinity 还容易在滚动发布、故障域减少或节点数不足时卡住。能用 topology spread 表达副本均衡时,通常更容易审计。
对自身 Pod 使用 anti-affinity 时要理解首个 Pod 的特殊处理,否则零副本时可能形成自锁。滚动更新还要把 maxSurge 算入拓扑容量:三个节点恰好容纳三个硬反亲和副本时,第四个 surge Pod 无处可去,发布会停在旧副本尚未删除、新副本又无法调度的状态。
自定义调度器或 Scheduler Profile 适合需要不同插件权重、队列或扩展点的场景,不应用来修补混乱标签。Pod 通过 spec.schedulerName 选择调度器;名字错误会让 Pod 无人处理。调度器配置 API 的稳定入口是 kubescheduler.config.k8s.io/v1,升级前应查阅 kube-scheduler 配置文档并验证所有 profile 的 QueueSort 等兼容要求。
标签、RBAC 与 NodeRestriction 决定放置是否可信
能修改 Node 标签的人可以改变工作负载的硬件、区域、合规和租户落点。业务部署者通常只需在 namespace 内修改 Pod 模板,不应获得 Node patch;节点供应控制器只维护自己负责的标签前缀;云控制器维护区域和实例类型事实;安全团队审批受保护标签。
NodeRestriction admission plugin 会阻止 kubelet设置或修改带 node-restriction.kubernetes.io/ 前缀的标签。隔离、合规或高信任工作负载可使用组织域名前缀加该受限段,例如 example.com.node-restriction.kubernetes.io/pci-dss=true,再由独立平台身份写入。官方 节点隔离说明给出了这一安全用途。
toleration 不应由通用 Helm chart 无条件注入,尤其是空 key 的 Exists。它可能让普通业务进入控制面、Spot、故障或隔离节点。准入策略可以拒绝高风险 toleration、未知 schedulerName 和未经批准的硬节点标签。审计需要保留谁修改了 Node label/taint、谁修改了 workload placement,以及变更前后 Pending、故障域和成本的差异。
调度事件、Node 标签和 Pod 名可能暴露租户、区域、实例类型与业务拓扑。分享日志时去除真实集群名、内部域名、云实例 ID 和客户标签;云凭证不应出现在调度清单中,供给器使用独立工作负载身份读取子网、实例类型和配额。
用可行节点比例和碎片率治理容量成本
总 allocatable 减总 requests 只能描述账面余量。对某类 Pod 真正有意义的是“满足全部硬约束的节点集合”及集合内剩余资源。一个集群可能有 40% CPU 空闲,却因为 zone、架构、GPU、污点、卷拓扑和硬反亲和性而无法放入任何新副本。
容量模型应按 workload class 计算可行节点比例、最大可放副本数、最小故障域余量、requests 碎片、DaemonSet 税和滚动发布 surge。硬约束每增加一个维度,都要问供应系统能否创建对应节点、缺货时是否允许降级、迁移数据需要多久,以及谁承担预留成本。
架构取舍可以遵循三个层次:不可违反的安全、架构和数据事实使用 required;故障域目标优先使用 topology spread,并明确硬失败还是软降级;成本、缓存和低延迟倾向使用 preferred。专用池使用 taint + affinity 双向契约,共享池保持较少硬标签。这样既保留供应弹性,也让扩容器知道应创建哪一类节点。
升级、回滚和责任治理围绕不变量展开
控制面升级前,在 canary namespace 回放代表性的 Pod 模板:通用、专用、跨 zone、带卷、Spot、GPU 和滚动发布。保存升级前后的 FailedScheduling 类型、候选节点、调度延迟和分布偏斜。若要启用新的 topology 字段或 feature gate,先确认 API 稳定级别、默认行为和旧版本回滚能否读取对象;不要让 GitOps 在回滚后持续重写不兼容字段。
修改放置策略采用小批量 rollout。先给节点加新标签,再让少量 Pod 同时接受新旧池;观察新池分布、容量和故障演练;随后把 preferred 提升为 required;最后才删除旧标签或污点。反向迁移则先恢复旧池容量与 toleration,再降低硬约束。直接改 required 条件不会搬迁旧 Pod,却可能让下一次重启全部 Pending。
平台团队拥有节点标签词典、调度器配置和供应映射;应用团队拥有 Pod 标签、requests、PDB 与降级选择;安全团队拥有隔离标签和高风险 toleration 审批;存储团队拥有 StorageClass 与卷拓扑;容量 owner 定期审查可行节点比例、碎片与专用池利用率。每个硬约束都应记录业务理由、owner、验证实验、供应类型和退出动作,没有 owner 的约束应被视为容量债务。
最后清理实验对象和节点元数据:
kubectl delete namespace placement-lab --wait=true
kubectl taint node <node-c> dedicated.example.com/team=payments:NoSchedule- \
--ignore-not-found
kubectl label node <node-a> placement.example.com/pool- --ignore-not-found
kubectl label node <node-b> placement.example.com/pool- --ignore-not-found
kubectl label node <node-c> placement.example.com/pool- --ignore-not-found
kubectl get nodes -L placement.example.com/pool,topology.kubernetes.io/zone若实验覆盖了平台维护的 zone 标签,应通过平台声明恢复原值,而不是凭记忆手工改回。清理完成的证据是 namespace 消失、实验 Pod 与事件不再增长、专用污点和自定义标签归零、所有节点回到原有可调度状态,并且没有为反向实验遗留错误类型的云节点。
