一次让人抓狂的调度失败
假设你正负责一个核心服务的发布。Pod 创建之后一直停留在 Pending 状态,kubectl describe pod 看到这样一条事件:0/6 nodes available: insufficient cpu。第一反应是资源不够,于是你给集群加了一台节点。结果新节点 join 之后,Pod 依然 Pending,原因是 node(s) didn’t match pod anti-affinity rules。这时候你会发现,调度失败并不是唯一的原因,甚至你看到的事件提示只是当前调度器认为最严重的那个。

这篇文章想深入讨论 Kubernetes 调度器 的工作原理,以及为什么 Pod 调度失败 并不总是因为资源不够。如果你已经踩过几次坑,你会认同:看清调度器的判定逻辑,比临时扩容重要得多。
调度器到底是怎么选节点的?
Kubernetes 默认调度器 kube-scheduler 并不负责运行 Pod,它只负责为 Pod 选择一台最合适的节点。整体流程分两步:过滤和打分。过滤阶段把不满足硬性条件的节点全部排除,打分阶段在剩余节点里按权重选出一个最合适的。
如果剩余节点为零,调度器就认为调度失败,Pod 进入 Pending,并且在事件中写出每个节点被过滤的原因。所以你会看到类似“0/6 nodes available”的计数。
真正的难点在于,过滤条件远比我们直觉想象得多。除了 CPU 和内存,还有端口冲突、节点亲和性、Pod 亲和性、污点容忍、卷的拓扑约束,以及自定义调度器插件。任何一项不过,节点都会被剔除。
资源不够,往往只是表象
“insufficient cpu” 或 “insufficient memory” 是最常见的调度失败提示。但很多情况下,节点本身还有大量空闲资源,为什么还说不够?因为调度器看的是节点上 已分配 requests 的总和,而不是实时使用量。
这时很多人会犯第一个错:只去看节点的 Capacity,却不看 Allocatable。节点除了要运行你的业务容器,还要运行 kubelet、容器运行时、systemd、监控进程等等。因此 Kubernetes 会从总容量中扣掉系统预留,这部分叫 Allocatable。也就是说调度器的可用空间上限不是 capacity,而是 allocatable。
另一个容易被忽略的是 requests 的设置。很多开发者习惯只给 Pod 设置 limits,不设置 requests。在不设置 requests 的情况下,调度器默认按 0 处理。结果就是所有 Pod 都容易塞进同一台节点,运行时互相抢资源,产生 CPU throttling 或 OOMKill。反过来,如果你把 requests 设置得过高,比如给了 8 核,但节点实际可分配 16 核,可能只放得下两个 Pod,其余 Pod 同样失败。
# 查看节点能力很容易,但注意 allocatable
kubectl describe node node-1 | grep -A8 "Allocatable"
# 输出中 capacity 和 allocatable 的差异就代表系统预留
Capacity:
cpu: 8
memory: 32100Mi
Allocatable:
cpu: 7600m
memory: 27500Mi
在调度的语义里,7600m 才是节点能分配出去的 CPU。如果集群里已经有很多 Pod 把 requests 占满,即使节点 CPU 利用率很低,新 Pod 也一样进不来。
节点选择规则,比你想的更严
nodeSelector 和节点亲和性
如果你的应用需要跑在特定机器上,比如有 GPU 或者 SSD,通常会用 nodeSelector 或 节点亲和性。nodeSelector 实现的是精确匹配,要求节点必须包含指定标签且值一致。一旦节点标签缺失或值变了,Pod 就会失败,事件里一般会写 node(s) didn't match node selector。
节点亲和性提供了更丰富的表达能力,其中 requiredDuringSchedulingIgnoredDuringExecution 相当于硬性条件,preferredDuringSchedulingIgnoredDuringExecution 只是软性偏好,后者不会因为不满足就直接失败,只会降低该节点的评分。很多新手把 preferred 当成 required 来用,然后看到事件里没有任何提示,但 Pod 就是长期 Pending,这可能是因为打分之后没有节点能被选中。
一个非常典型的坑是:在反亲和性里写 podAntiAffinity 要求 Pod 尽量分散在不同节点,但集群节点数小于副本数,这时无论怎么打分,都不可能找到一台满足“不与其他副本同节点”要求的机器。调度器不会创造节点,只会告诉你无可用节点。
污点、容忍与 NoExecute 的真实副作用
污点(Taints)和容忍(Tolerations)是另一道硬性过滤。节点被打上污点之后,只有携带对应容忍的 Pod 才会被调度到该节点。常见错误是给节点加了 NoSchedule 污点,但业务 Pod 设置的容忍 key 或 value 跟节点不一致。这时事件会有 untolerated taint 提示。
需要区分的是,NoSchedule 只会阻止新 Pod 调度,NoExecute 不仅阻止调度,还会驱逐该节点上所有没有对应容忍的已有 Pod。某些团队用 NoExecute 来隔离故障节点,结果导致大量 Pod 被重新调度到其他节点,反而把集群压垮。
这里有一个工程场景:你给 GPU 节点打了 nvidia.com/gpu=true:NoSchedule,给 GPU 任务加了完全一致的容忍,调度没有任何问题。但后续运维人员改节点标签时拼错了一个字符,或者使用脚本批量重新标签导致 key 不匹配,所有 GPU 任务马上回落到 Pending。污点和容忍在配置上是静默的,校验工具也往往发现不了这种问题。
卷的拓扑约束:隐蔽的调度黑手
如果你的 Pod 使用了存储卷,调度器还必须考虑卷是否可被目标节点访问。尤其是云环境里的持久化卷,往往绑定到特定可用区。动态供给的 StorageClass 可以设置 allowedTopologies,静态创建的 PV 会有 nodeAffinity,这些都会成为硬过滤条件。
某次我们要部署一个跨可用区的有状态服务,PVC 一直在 Binding,但创建的 Pod 一直 Pending。describe 事件只看到 node(s) had volume node affinity conflict。检查 PV 后发现,它绑定在 zone A,而 Pod 的节点亲和性却要求调度到 zone B。调度器无法同时满足存储和节点的位置约束,于是只能失败。这类问题非常隐蔽,因为资源、节点标签都没有异常。
自定义调度器和集群规模带来的干扰
如果你没有使用默认调度器,而是给 Pod 指定了 schedulerName: my-scheduler,那么默认调度器不会做任何处理。如果自定义调度器没有正常运行或者没有注册,Pod 会一直 Pending,并且事件里可能根本没有调度记录。这种情况很难通过 describe pod 看出来,反而更像“什么都没发生”。
另一个容易被忽略的问题是集群规模。当集群节点数量超过几百上千时,调度器的过滤和打分循环会带来一定开销。虽然官方调度器做了大量性能优化,甚至支持调度器缓存,但如果某些节点一直处于 Unknown 状态,或者某些节点事件频繁变动,调度器反复重试也会让 Pod 长时间得不到 bind 决策。这种延迟并不是永久失败,但会让人误判为调度失败。
用一条排查链路快速定位问题
面对一个 Pending 的 Pod,我的习惯是按下述顺序来做,而不是一上来就改资源或加节点。
kubectl describe pod查看 Events,收集调度器给出的失败原因。- 用
kubectl get node -o wide检查节点的运行状态和标签。 - 查看节点 Allocatable 和已分配 requests,判断是否为资源问题。
- 检查 PVC 和 PV 的状态,确认卷拓扑是否满足要求。
- 最后再查 kube-scheduler 的日志,定位自定义插件或调度周期中的异常。
# 查看 Pod 相关的调度事件
kubectl describe pod my-pod -n my-namespace | grep -A10 "Events"
# 查看调度器日志,推荐在日志平台按调度周期检索
kubectl logs -n kube-system -l component=kube-scheduler -f --tail=50
在多数情况下,describe 里的事件已经告诉你足够多的信息。真正的挑战是,调度器只会把当前最严重的不匹配条件列出来,而不会一次告诉你全部。所以当你修复了一个问题,可能会看到下一个问题浮出来,这是正常现象。
我常看到的几个调度误区
- 只看节点 capacity,不看 allocatable 和已分配 requests。
- 把 preferred 亲和性和污点容忍等级搞混,以为软约束不满足也应该硬失败。
- 使用反亲和性时不计算可行节点数,导致约束自相矛盾。
- 忽略云盘、PVC 这种隐含的节点拓扑约束。
这些误区的共同根源是没有把调度看作一个“整体约束求解”过程。每个条件单独看都不苛刻,但合在一起可能没有解。
一份调度失败排查对照表
为了减少排查时反复试错,我整理了一张不同失败类型对应的典型现象和定位方式。这张表不是万能钥匙,但大多数团队遇到的问题都能落到表里的某一项。
| 失败现象 | 常见原因 | 优先排查方向 |
|---|---|---|
| 0/N nodes available: insufficient cpu/memory | 节点可分配资源不足,或 requests 设置过高 | 检查 Allocatable 与已分配 requests |
| 0/N nodes available: node(s) didn’t match node selector | 节点缺少标签,或 nodeSelector 写错 | 检查节点 labels 与 yaml 中的 label |
| 0/N nodes available: untolerated taint | 节点存在污点,Pod 没有对应容忍 | 查看节点 taints,核对容忍配置 |
| 0/N nodes available: node(s) had volume node affinity conflict | PV 的拓扑约束与节点亲和性冲突 | 检查 PVC/PV 的可用区、nodeAffinity |
| 0/N nodes available: pod affinity/antiaffinity rules not satisfied | 反亲和性约束过强,解空间为空 | 计算可放置节点数,调整约束或副本数 |
想减少调度失败,这些习惯值得建立
最后聊一点落地建议。调度失败是可以被规模化管理的,关键在于事前控制。
第一,认真对待 requests。把 requests 看成 Pod 对节点资源的“占位承诺”,它应该偏低但足够保证运行时稳定,而不是取一个平均值。你可以通过监控历史使用量来校准。
第二,把节点选择规则集中管理。不要让每个团队随意写 nodeSelector 和亲和性。建议平台统一提供一组标准的节点标签和污点语义,比如 node-role.kubernetes.io/gpu、disk=ssd,并在模板中约束可选值。
第三,为关键服务配置调度失败告警。利用事件中的 FailedScheduling 触发告警,把调度失败率作为集群健康度指标之一。
第四,在测试环境做一次“调度演练”。把要发布的 Pod YAML 放到一个与生产相近但规模更小的集群上,观察它是否能落到预期节点。趁验证阶段发现问题,比上线到生产再修复便宜得多。
最后说几句
调度失败看起来是一件烦心事,但反过来想,它反映了调度器正在严格保护集群资源的边界。如果你能读懂调度器抛出的那些事件,你就能更清楚每个 Pod 的约束和集群的实际容量。
排查调度失败没有银弹,大多数时候需要把节点状态、Pod 配置、存储和调度器日志放在一起看。真正理解它的运行逻辑之后,你会发现自己再遇到 Pending,不是盲目扩容,而是先问一句:过滤器到底把谁拦下了?
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/588/