1. 调度器到底在忙什么:从一次Pod pending说起
很多人第一次意识到 Scheduler 的存在,不是因为读文档,而是因为一个 Pod 卡在 Pending 状态半天不动。kubectl describe pod一看,Events 里躺着0/5 nodes are available: 5 node(s) didn't match Pod's node affinity。这时候你才反应过来,原来集群里有个叫 kube-scheduler 的组件,它一直在后台默默决定每个 Pod 该落到哪台机器上。
Scheduler 调度策略总结这件事,说白了就是把“一个 Pod 从创建到绑定到某个 Node”这条链路上,所有能干预、能配置、能出问题的环节捋清楚。它解决的核心问题是:在资源有限、约束多样的集群里,如何让每个 Pod 找到一个“合适”的落脚点。合适这两个字很关键——不是随便找台有空闲的机器塞进去,而是要同时满足资源够用、亲和性匹配、污点容忍、拓扑分布合理等一堆条件。
这篇内容适合谁看?如果你已经能熟练跑kubectl get pods,知道 Deployment 和 Node 的关系,但一遇到调度相关的 Pending、Evicted、节点倾斜就抓瞎,那正好。我会从调度框架的默认行为讲起,把过滤和打分的逻辑拆开,再逐条过一遍亲和性、污点、拓扑分布这些策略的实际配置方式,最后落到排查技巧上。全程按我实际踩过的坑来讲,配置能直接抄,参数会告诉你为什么这么填。
需要先明确一个前提:不同版本的 Kubernetes 在调度框架上有差异,调度器在 1.19 之后逐步引入了 Scheduling Framework 的插件化机制,很多老的 Predicates/Priorities 概念被重构了。我下面讲的内容以较新的稳定版本行为为准,涉及版本差异的地方会单独点出来。你如果还在用很老的版本,部分插件名和配置字段可能对不上,这点心里要有数。
2. 调度框架的整体设计与思路拆解
2.1 为什么调度要拆成“过滤+打分”两段
理解 Scheduler 最省力的方式,是把它想成一个招聘流程。假设你是一个 HR,手里有一批候选人(待调度的 Pod),公司有几间办公室(Node)。你要做的第一件事是筛掉那些硬性条件不满足的——比如岗位要求会某门语言,不会的直接淘汰,这就是过滤(Filtering)。剩下的候选人再按匹配度排序,谁更合适谁优先,这就是打分(Scoring)。最后挑分数最高的那个发 offer,也就是绑定(Binding)。
这个两段式设计的好处很直接:过滤阶段只做布尔判断,快;打分阶段才做加权计算,贵。如果一上来就对所有节点做全量打分,集群规模一大,调度延迟会很难看。先过滤把候选集缩小,再在少量节点上打分,是性能上的必然选择。我见过有同学问“为什么不能直接按资源排序选最优”,答案就是:资源只是众多维度之一,而且硬约束(比如节点必须带某个标签)不满足的话,资源再多也不能用,必须先筛。
在 Scheduling Framework 里,这两个阶段被进一步拆成了多个扩展点。过滤对应Filter插件,打分对应Score插件,中间还有PreFilter做预处理、PostFilter在过滤失败后做补救(比如抢占)。这套插件化设计的意义在于:你可以通过配置启用或禁用某个插件,甚至写自己的调度插件,而不用改调度器源码。早期版本里这些逻辑是硬编码在pkg/scheduler/algorithm里的,改起来很痛苦,现在灵活多了。
2.2 默认调度器到底装了哪些插件
很多人以为调度策略就是自己配的那点亲和性规则,其实默认调度器本身就带了一堆插件在干活。你不配任何东西,它也在按一套默认逻辑做过滤和打分。搞清楚这些默认插件,才能明白“为什么我的 Pod 被调到了那台机器”。
过滤侧默认启用的插件里,比较关键的有几个。NodeUnschedulable负责把打了unschedulable标记的节点排除掉,也就是你kubectl cordon之后节点不再接收新 Pod,靠的就是它。NodeResourcesFit检查节点资源是否满足 Pod 的 requests,这是最常见的过滤失败来源。TaintToleration处理污点与容忍的匹配。NodeAffinity和InterPodAffinity分别处理节点亲和与 Pod 间亲和。还有PodTopologySpread处理拓扑分布约束。
打分侧默认插件里,NodeResourcesBalancedAllocation倾向于让 CPU 和内存的使用率更均衡,避免某台机器 CPU 打满但内存空着。NodeResourcesLeastAllocated倾向于选资源使用率最低的节点,也就是“摊大饼”式分散。InterPodAffinity在打分阶段也会参与,给满足亲和条件的节点加分。ImageLocality会给已经缓存了 Pod 所需镜像的节点加分,这个细节很多人不知道,但它实实在在影响调度结果——镜像大的 Pod 更容易被调到已经有镜像的节点上。
提示:这些默认插件的权重是可以调的。通过 KubeSchedulerConfiguration 里的
plugins字段,你可以给某个打分插件设置weight,也可以禁用某个插件。调权重是优化调度倾斜的常用手段,但改之前一定要清楚默认值,别拍脑袋。
2.3 调度周期与绑定周期的边界
Scheduler 的工作其实分成两个周期:调度周期(Scheduling Cycle)和绑定周期(Binding Cycle)。调度周期是串行的,一次只处理一个 Pod,从队列里取出来、过滤、打分、选出节点。绑定周期是异步的,选好节点后把绑定操作丢给一个 goroutine 去执行,不阻塞下一个 Pod 的调度。
这个设计的意义在于吞吐量。如果绑定也串行,那每次写 API Server 的网络延迟都会拖慢整个调度队列。异步绑定让调度器可以快速处理下一个 Pod。但代价是:绑定可能失败。比如你选好了节点,结果在绑定前那一刻节点被删了,或者资源被别的 Pod 抢了,绑定就会报错,Pod 重新回到队列。这也是为什么偶尔会看到 Pod 短暂地被“调度过”又回到 Pending。
理解这个边界对排查问题很有用。如果你在日志里看到FailedScheduling,那是调度周期过滤失败;如果看到FailedBinding或者绑定相关的错误,那是绑定周期的问题,两者排查方向完全不同。前者看亲和性、资源、污点,后者看 API Server 连通性、节点状态变化。
3. 核心调度策略的细节解析与实操要点
3.1 资源请求与限制:调度只看 requests
这是最容易踩坑的一点,必须放在最前面讲。调度器在过滤阶段判断节点资源是否够用,看的是 Pod 的requests,不是limits,也不是节点当前的实际使用量。换句话说,调度器是个“账面主义者”,它只认你声明的资源请求,不关心节点上实际跑了多少负载。
举个例子,一个节点 8 核 16G,上面已经跑了几个 Pod,requests 加起来 6 核 12G,但实际 CPU 只用了 2 核。这时候来一个 requests 是 3 核的 Pod,调度器一算:6+3=9 > 8,超了,过滤失败,Pod Pending。哪怕节点实际还有大把空闲 CPU,调度器也不管。反过来,如果 requests 加起来没超,但实际负载已经把节点压垮了,调度器照样会把新 Pod 塞进去,因为它看不到实际使用率。
这个机制的直接后果是:requests 填得太小,节点会超卖,实际运行时互相抢资源;requests 填得太大,节点账面被占满,实际却很闲,浪费严重。我见过不少团队为了“让 Pod 能调度上去”把 requests 填得极小,结果节点上 Pod 密度过高,一到高峰期就集体卡顿。也见过 requests 按峰值填的,节点利用率长期在 30% 以下。
实操上,requests 的设定应该基于压测后的稳定运行值,而不是拍脑袋。CPU 的 requests 可以适当低于峰值(因为 CPU 是可压缩资源,超了会限流但不会 OOM),内存的 requests 必须接近实际使用峰值(内存不可压缩,超了直接 OOMKill)。这个区别很关键,配置的时候要分开对待。
resources: requests: cpu: "500m" # 按稳定运行值填,可略低于峰值 memory: "512Mi" # 按实际峰值填,宁大勿小 limits: cpu: "1000m" # 允许突发到 1 核 memory: "512Mi" # 与 requests 持平,避免 OOM 风险注意:内存的 limits 如果设得比 requests 大很多,Pod 在突发时会用到超过 requests 的内存,但调度器只按 requests 算账。多个这样的 Pod 挤在一台节点上,实际内存可能远超节点容量,触发节点级 OOM。所以内存的 requests 和 limits 建议持平,或者 limits 只略高一点。
3.2 节点亲和性:硬约束与软偏好的分界线
节点亲和性(Node Affinity)分两种:requiredDuringSchedulingIgnoredDuringExecution和preferredDuringSchedulingIgnoredDuringExecution。名字很长,但拆开看就清楚了。required是硬约束,调度时必须满足,不满足直接过滤掉;preferred是软偏好,满足就加分,不满足也能调度,只是分数低。
后半截IgnoredDuringExecution的意思是:这个规则只在调度那一刻生效,Pod 跑起来之后节点标签变了,也不会把 Pod 赶走。这一点经常被误解,有人以为改了节点标签 Pod 会自动迁移,其实不会。要让运行中的 Pod 响应标签变化,得靠别的机制。
硬约束的典型场景是“这个 Pod 必须跑在带 GPU 的节点上”。配置大概长这样:
affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: accelerator operator: In values: - gpu软偏好的典型场景是“尽量跑在 SSD 节点上,没有就算了”。配置时每个偏好项带一个weight(1 到 100),权重越高越优先:
affinity: nodeAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 80 preference: matchExpressions: - key: disktype operator: In values: - ssd这里有个实操细节:matchExpressions支持In、NotIn、Exists、DoesNotExist、Gt、Lt几种操作符。Gt和Lt只能用于整数类型的标签值,而且比较的是字符串转整数后的结果。我见过有人用Gt比较版本号字符串,结果行为不符合预期,因为它是按整数解析的,"1.10"这种带小数点的会解析失败。
还有一个容易忽略的点:nodeSelector和nodeAffinity是可以共存的,但nodeSelector本质上是硬约束的简化写法,最终会被转换成requiredDuringScheduling的匹配条件。两者同时存在时,条件是与的关系,都得满足。排查时如果发现 Pod 调不上去,别忘了检查nodeSelector这个“隐形”约束。
3.3 污点与容忍:节点主动拒绝的艺术
亲和性是 Pod 挑节点,污点(Taint)是节点拒绝 Pod。方向反过来,用途也不同。污点适合表达“这台节点有特殊情况,普通 Pod 别来”,比如专用节点、正在维护的节点、有特殊硬件的节点。
污点的格式是key=value:effect,effect 有三种:NoSchedule、PreferNoSchedule、NoExecute。NoSchedule是硬拒绝,没有对应容忍的 Pod 不会被调度上来,但已经在上面跑的 Pod 不受影响。PreferNoSchedule是软拒绝,尽量不调度,但没别的地方去还是可以来。NoExecute最狠,不仅新 Pod 不来,已经在上面跑的、没有容忍的 Pod 会被驱逐。
# 给节点打污点 kubectl taint nodes node-1 dedicated=gpu:NoSchedule # Pod 里配置容忍tolerations: - key: "dedicated" operator: "Equal" value: "gpu" effect: "NoSchedule"operator可以是Equal(值必须匹配)或Exists(只要 key 存在就容忍,不管值)。用Exists时可以省略value,表达“只要这个 key 的污点我都忍”。还有一个特殊写法:operator: Exists且不写key,表示容忍所有污点,这基本等于“我哪都能去”,慎用。
NoExecute的容忍可以带tolerationSeconds,表示“被驱逐前还能赖多久”。这个在节点维护场景很有用:给 Pod 配一个 300 秒的容忍,节点打上NoExecute后,Pod 还能跑 5 分钟做优雅退出,而不是立刻被赶走。
注意:Master 节点默认带有
node-role.kubernetes.io/control-plane:NoSchedule污点(老版本是master)。如果你想让普通 Pod 跑上去,要么加容忍,要么去掉污点。生产环境不建议去掉,控制面节点混跑业务 Pod 会影响稳定性。
3.4 Pod 间亲和与反亲和:让 Pod 抱团或分散
节点亲和性管的是 Pod 和节点的关系,Pod 间亲和性(Inter-Pod Affinity)管的是 Pod 和 Pod 的关系。典型场景:缓存服务和业务服务尽量放一起减少网络延迟(亲和),同一服务的多个副本尽量分散到不同节点提高可用性(反亲和)。
配置里有个关键字段topologyKey,它定义了“在一起”或“分开”的粒度。topologyKey: kubernetes.io/hostname表示以节点为单位,topologyKey: topology.kubernetes.io/zone表示以可用区为单位。这个字段选错了,效果完全不对。
affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: web topologyKey: kubernetes.io/hostname上面这段的意思是:这个 Pod 不能和任何app=web的 Pod 跑在同一台节点上。如果集群只有 3 台节点,而app=web已经有 3 个副本,那第 4 个副本就会 Pending,因为硬反亲和找不到满足条件的节点。这是硬反亲和最常见的坑——副本数超过拓扑域数量时,多出来的副本无处可去。
所以生产环境里,反亲和通常用软约束preferredDuringSchedulingIgnoredDuringExecution,允许在节点不够时“挤一挤”,而不是直接 Pending。硬反亲和只用在“绝对不能共存”的场景,比如两个实例抢同一个本地端口。
Pod 间亲和还有一个命名空间的概念。默认只在 Pod 所在命名空间内匹配,如果要跨命名空间匹配,需要配namespaces字段,而且从某个版本开始还要求目标命名空间有对应的NamespaceSelector标签。这个细节在跨命名空间部署时经常被漏掉,导致亲和规则“看起来配了但不生效”。
3.5 拓扑分布约束:比反亲和更精细的分散
Pod 拓扑分布约束(PodTopologySpread)是后来引入的机制,可以看作反亲和的升级版。反亲和只能表达“不要在同一节点”,拓扑分布能表达“每个节点最多差 1 个副本”这种更精细的均衡。
核心参数有四个:maxSkew允许的最大不均衡度,topologyKey拓扑域,whenUnsatisfiable不满足时的行为(DoNotSchedule硬约束或ScheduleAnyway软约束),labelSelector匹配哪些 Pod 参与计算。
topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: web这段配置的意思是:app=web的 Pod 在各节点上的数量差不能超过 1,不满足就不调度。假设 3 个节点,已有分布是 2、1、0,新 Pod 只能去数量为 0 的节点,因为去 1 的节点会变成 2、2、0,skew 是 2 超过 maxSkew 1;去 0 的节点变成 2、1、1,skew 是 1,满足。
maxSkew的理解要小心:它算的是“任意两个拓扑域之间数量的最大差值”,不是“和平均值的差”。我见过有人把maxSkew设成 0,以为要求完全平均,实际上maxSkew: 0在数量不能整除时会永远无法满足,导致 Pod 全部 Pending。一般设 1 就够了。
whenUnsatisfiable: DoNotSchedule是硬约束,和硬反亲和一样有“副本数超过拓扑域”导致 Pending 的风险。ScheduleAnyway是软约束,调度器会尽量均衡但不强求,生产环境更常用这个。
3.6 优先级与抢占:让重要的 Pod 先跑
优先级(PriorityClass)解决的是“资源不够时谁先上”的问题。高优先级的 Pod 调度不上时,调度器会尝试驱逐低优先级的 Pod 来腾位置,这叫抢占(Preemption)。
apiVersion: scheduling.k8s.io/v1 kind: PriorityClass metadata: name: high-priority value: 1000000 globalDefault: false preemptionPolicy: PreemptLowerPriorityvalue越大优先级越高,系统保留了一批内置的高值给关键组件,自己定义时别超过 10 亿这个量级。preemptionPolicy设为PreemptLowerPriority才允许抢占,设为Never则只排序不抢占。
抢占的机制值得多说两句。当高优先级 Pod 调度失败时,调度器会模拟“如果驱逐某些低优先级 Pod,能不能腾出足够资源”。如果能,就在被驱逐的 Pod 上打上nominatedNodeName标记,然后异步驱逐它们。注意是“模拟”和“异步”,所以抢占不是瞬时的,中间可能有延迟,而且被选中的“牺牲者”不一定是最终被驱逐的——如果在这期间有别的节点空出来了,高优先级 Pod 可能改去别处。
注意:抢占会驱逐正在运行的 Pod,这对有状态服务或有长连接的服务影响很大。给关键业务配高优先级之前,一定要评估被抢占方的容忍度。另外,抢占不考虑 PodDisruptionBudget,被驱逐的 Pod 可能一次性全没,这点和自愿驱逐不同。
4. 实操过程与核心环节实现
4.1 从零搭一个带调度约束的部署
光看配置不过瘾,走一遍完整流程。假设有个 Web 服务,要求:跑在带env=prod标签的节点上,尽量分散到不同节点,资源 requests 是 500m CPU / 512Mi 内存,优先级中等。
先给节点打标签:
kubectl label nodes node-1 node-2 node-3 env=prod然后写 Deployment:
apiVersion: apps/v1 kind: Deployment metadata: name: web-demo spec: replicas: 3 selector: matchLabels: app: web-demo template: metadata: labels: app: web-demo spec: priorityClassName: medium-priority affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: env operator: In values: - prod topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: app: web-demo containers: - name: app image: nginx:stable resources: requests: cpu: "500m" memory: "512Mi" limits: cpu: "1000m" memory: "512Mi"应用之后,用kubectl get pods -o wide看分布。3 个副本在 3 台节点上各一个,说明拓扑分布生效了。如果某台节点资源不够,ScheduleAnyway会让它挤到别的节点,而不是 Pending。
这里有个实操顺序问题:先打标签再创建 Deployment。如果顺序反了,Pod 创建时节点还没标签,硬亲和过滤失败,Pod 会 Pending,等你打完标签,调度器会重新尝试(因为节点标签变化会触发重新调度),但中间会有延迟。养成先准备节点再部署的习惯,能省掉很多“为什么 Pending 了”的困惑。
4.2 用 describe 和 events 定位调度失败原因
Pod Pending 时,第一手信息永远在kubectl describe pod的 Events 里。调度失败的信息格式很固定:0/N nodes are available: <原因汇总>。常见的原因和对应策略我整理成表:
| Events 中的提示 | 对应策略 | 排查方向 |
|---|---|---|
Insufficient cpu/Insufficient memory | NodeResourcesFit | 节点 requests 余量不足,看kubectl describe node的 Allocated resources |
node(s) didn't match Pod's node affinity | NodeAffinity | 节点标签是否匹配,nodeSelector是否冲突 |
node(s) had untolerated taint | TaintToleration | 节点污点与 Pod 容忍是否匹配 |
node(s) didn't match pod affinity rules | InterPodAffinity | 亲和的目标 Pod 是否存在,topologyKey 是否正确 |
node(s) didn't match pod anti-affinity rules | InterPodAntiAffinity | 反亲和导致无处可去,考虑改软约束 |
node(s) didn't satisfy topology spread constraints | PodTopologySpread | maxSkew 是否过严,拓扑域是否够多 |
node(s) were unschedulable | NodeUnschedulable | 节点是否被 cordon |
kubectl describe node里的Allocated resources段落特别有用,它列出了每个节点上 requests 的汇总和百分比。我排查资源类 Pending 时,第一步就是看这个,比逐个 Pod 加 requests 快得多。
kubectl describe node node-1 | grep -A 10 "Allocated resources"输出里会显示 CPU Requests、CPU Limits、Memory Requests、Memory Limits 的已分配量和百分比。如果 Requests 已经 95% 以上,那基本就是资源不够了,要么加节点,要么调小 requests,要么清理无用 Pod。
4.3 调度器配置的调整与生效
默认调度器行为不满足需求时,可以通过 KubeSchedulerConfiguration 调整。这个配置通过 ConfigMap 挂载给调度器,或者用--config参数指定。一个典型的配置长这样:
apiVersion: kubescheduler.config.k8s.io/v1 kind: KubeSchedulerConfiguration profiles: - schedulerName: default-scheduler plugins: score: disabled: - name: NodeResourcesBalancedAllocation enabled: - name: NodeResourcesLeastAllocated weight: 2 pluginConfig: - name: NodeResourcesFit args: scoringStrategy: type: LeastAllocated这段配置禁用了均衡分配插件,把最少分配插件的权重调到 2,让调度更倾向于把 Pod 摊到空闲节点上。NodeResourcesFit的scoringStrategy可以选LeastAllocated(最少分配优先)或MostAllocated(最多分配优先,适合装箱提高利用率)。
改完配置后,调度器需要重启才能生效。如果是 kubeadm 部署的集群,改/etc/kubernetes/manifests/kube-scheduler.yaml里的挂载配置,然后 kubelet 会自动重建静态 Pod。改之前务必备份,配置写错会导致调度器起不来,整个集群新 Pod 全部无法调度。
注意:多调度器场景下,Pod 通过
schedulerName字段指定用哪个调度器。默认是default-scheduler,如果你部署了自定义调度器,记得在 Pod spec 里显式指定,否则还是走默认的。这个字段写错的话,Pod 会一直 Pending 且 Events 里没有任何调度失败信息,因为根本没有调度器认领它。
4.4 一个真实的调度倾斜修复过程
说个我实际处理过的场景。某集群 6 台节点,跑一个 12 副本的服务,结果发现 4 台节点各跑了 3 个,另外 2 台一个都没有。查下来是那 2 台节点有NoSchedule污点,但服务没配容忍,所以全挤在剩下 4 台上。
修复分两步。第一步,确认那 2 台节点的污点是否还有必要。查下来是早期做专用节点时打的,后来业务调整已经不需要了,直接去掉:
kubectl taint nodes node-5 dedicated=old:NoSchedule-注意末尾的减号,表示删除这个污点。第二步,给服务加上拓扑分布约束,防止以后再出现倾斜:
topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: app: my-service改完之后滚动更新,新 Pod 按分布约束重新铺开,最终 6 台节点各 2 个。这个过程里有个细节:滚动更新时旧 Pod 还在,新 Pod 创建时拓扑分布会把旧 Pod 也算进去(因为 labelSelector 匹配的是同一批 Pod),所以分布计算是准确的,不会出现新旧叠加导致倾斜。
5. 常见问题与排查技巧实录
5.1 调度相关高频问题速查
把我在实际运维中反复遇到的问题整理成表,遇到类似现象可以直接对照:
| 现象 | 可能原因 | 快速验证 | 解决方向 |
|---|---|---|---|
| Pod 一直 Pending,Events 无调度信息 | schedulerName 写错或无调度器 | kubectl get pod -o yaml看 schedulerName | 改回 default-scheduler |
| 大量 Pod Pending,提示资源不足 | 节点 requests 占满 | describe node看 Allocated | 扩容或调小 requests |
| 单节点 Pod 密度异常高 | 无拓扑约束或反亲和 | get pods -o wide看分布 | 加 topologySpreadConstraints |
| 滚动更新卡住,新 Pod Pending | 硬反亲和导致无处可去 | 看 Events 反亲和提示 | 改软约束或调 maxSurge |
| Pod 被驱逐后不重建 | 节点 NotReady 或 NoExecute | describe node看状态和污点 | 恢复节点或加容忍 |
| 高优先级 Pod 抢占后仍 Pending | 抢占目标资源不够或 PDB 阻挡 | 看 Events 抢占记录 | 检查牺牲者资源量 |
5.2 硬约束导致 Pending 的连锁反应
硬约束(硬亲和、硬反亲和、DoNotSchedule的拓扑分布)是 Pending 的重灾区。它们的共同特点是:条件不满足时 Pod 直接卡住,不会退而求其次。设计调度策略时,要时刻问自己一个问题:当集群规模变化、节点故障、副本扩缩时,这个硬约束还成立吗?
举个典型例子。一个服务配了硬反亲和,要求副本不在同一节点,副本数 5,集群 5 台节点。平时正好一台一个。某天一台节点故障下线,剩 4 台,第 5 个副本立刻 Pending,因为硬反亲和找不到第 5 台节点。如果这时候正好在做滚动更新,新副本起不来,更新就卡住了。
这种场景的正确做法是用软反亲和或拓扑分布软约束,允许在节点不足时“挤一挤”,保证服务可用性优先。硬约束只留给真正不能妥协的场景,比如“两个实例不能抢同一个 hostPort”。
5.3 资源碎片与装箱策略的取舍
集群跑久了,经常出现“总资源够但单个 Pod 调度不上”的情况,这叫资源碎片。比如节点 A 剩 2 核 4G,节点 B 剩 1 核 8G,来了个需要 3 核 6G 的 Pod,两台都放不下,但加起来资源是够的。
这是分散调度(LeastAllocated)的副作用——它倾向于把 Pod 摊开,导致每台节点都剩一点零碎资源。解决办法是切换到装箱策略(MostAllocated),让 Pod 尽量往已经比较满的节点上堆,把空闲节点整块留出来给大 Pod。
pluginConfig: - name: NodeResourcesFit args: scoringStrategy: type: MostAllocated但装箱策略也有代价:节点利用率高了,故障时影响面大,而且容易触发资源争抢。我的经验是,在线服务用分散策略保稳定,离线批处理用装箱策略提利用率,通过多调度器 profile 区分不同工作负载。KubeSchedulerConfiguration 支持配多个 profile,每个 profile 有自己的插件配置,Pod 通过 schedulerName 选择。
5.4 调度器日志的查看姿势
调度器日志默认输出到标准错误,kubeadm 部署的话在/var/log/下或者用kubectl logs看静态 Pod。日志里搜FailedScheduling能看到所有调度失败的记录,搜Attempting to schedule能看到调度尝试。
日志级别默认是 2,调到 4 或 5 能看到更详细的过滤和打分过程,包括每个节点在每个插件上的得分。排查“为什么调到了这台而不是那台”时,把日志级别调高,能看到打分明细。但注意高日志级别会产生大量输出,排查完记得调回去,否则日志会撑爆磁盘。
kubectl logs -n kube-system kube-scheduler-node-1 --tail=200 | grep -i "score\|filter"5.5 几个容易忽略的细节
第一个是Pod 的nodeName字段。如果你在 Pod spec 里直接写了nodeName,调度器会完全跳过这个 Pod,直接由 kubelet 接管。这相当于手动指定节点,绕过了所有调度策略。有人用它来“强制”调度,但代价是失去了调度器的所有保护,节点资源不够也照塞,容易出问题。
第二个是schedulerName与多调度器。集群里如果装了多个调度器,每个 Pod 只会被schedulerName指定的那个调度器处理。默认调度器只处理schedulerName为空或为default-scheduler的 Pod。自定义调度器要处理 Pod,得让 Pod 显式指定名字,否则两边都不管,Pod 永远 Pending。
第三个是节点资源预留。kubelet 有system-reserved和kube-reserved配置,会从节点可分配资源里扣掉一部分给系统进程。所以describe node里的Allocatable往往小于Capacity。算资源余量时要用Allocatable减去已分配 requests,用Capacity算会高估。
第四个是DaemonSet 的调度特殊性。DaemonSet 创建的 Pod 会自带一些容忍,能容忍节点的一些标准污点(如node.kubernetes.io/not-ready),而且调度器对 DaemonSet Pod 有特殊处理,会尽量保证每个节点一个。但 DaemonSet 也受资源约束,节点资源不够时它的 Pod 一样会 Pending,只是优先级处理上有些差异。
6. 调度策略的组合与演进思路
6.1 策略不是越多越好,而是越清晰越好
我见过一些团队的 Pod spec,亲和性、反亲和、拓扑分布、污点容忍全配上了,层层叠叠几十行。问为什么这么配,答不上来,说是“之前出过问题就加了一条”。这种堆叠式配置的问题在于:一旦出问题,根本不知道是哪条规则导致的,排查成本极高。
我的建议是,调度策略要分层设计。第一层是硬约束,只放真正不能妥协的,比如必须跑在特定硬件节点上。第二层是软偏好,表达倾向但不强制,比如尽量分散、尽量靠近缓存。第三层是兜底机制,比如优先级和抢占,保证关键服务在资源紧张时能挤进来。每一层都要能说清楚“为什么需要它”,说不清楚的就删掉。
配置写完要做一次“反向测试”:故意制造节点不足、标签缺失、副本超限的场景,看 Pod 的行为是否符合预期。调度策略的坑往往在异常场景才暴露,正常场景下怎么配都能跑。
6.2 从单集群到多集群的调度延伸
单集群内的调度策略玩熟之后,会遇到跨集群调度的问题。比如两个集群,一个资源紧张一个空闲,怎么让 Pod 跑到空闲的那个去。这超出了单集群 Scheduler 的范畴,需要上层的多集群调度方案,通过联邦或者自定义控制器把 Pod 分发到不同集群。
但底层的调度逻辑是相通的:过滤、打分、绑定这套思路在跨集群场景依然适用,只是“节点”变成了“集群”,“资源”变成了“集群剩余容量”。理解了单集群的调度原理,往上扩展到多集群时,只是换了一层抽象,核心的判断逻辑没变。
6.3 调度策略与集群容量的联动
最后说个容易被忽视的联动关系:调度策略直接影响集群的“有效容量”。同样一批节点,用分散策略可能只能跑 70% 的负载就出现碎片,用装箱策略能跑到 90% 但故障影响面大。所以容量规划不能只看节点总数,要结合调度策略算有效容量。
我的做法是,在容量评估时跑一遍模拟:用当前调度策略,按典型 Pod 规格,算出每个节点能容纳的 Pod 数量,再乘以节点数,得到理论容量,然后打个折(考虑碎片和预留)作为实际可用容量。这个数字比单纯看 CPU 内存总量靠谱得多,因为它把调度约束的影响算进去了。
调度这块内容,配置本身不难,难的是理解每条规则背后的取舍,以及在异常场景下的行为。我踩过的坑基本都集中在“硬约束太硬导致 Pending”和“requests 填得不合理导致超卖或浪费”这两类。把这两类问题想清楚,大部分调度问题都能自己排查。后续如果集群规模上去了,再考虑调插件权重、上多调度器 profile,那是另一个层次的优化了。