引言 — Pod 卡在 Pending 已经 10 分钟了
发布显示成功,但 Pod 一动不动。
kubectl get pod -n analytics
NAME READY STATUS RESTARTS AGE
clickhouse-0 0/1 Pending 0 11m
report-worker-7f4c8d9b6-hs2p 0/1 Pending 0 11m
report-worker-7f4c8d9b6-k9vt 1/1 Running 0 11m
Pending 的意思是 Pod 对象已经创建,但还没有确定落到哪个节点上。这不是容器的问题,而是放置问题,所以看日志什么也看不到。因为容器压根就没启动。
kubectl logs clickhouse-0 -n analytics
Error from server (BadRequest): container "clickhouse" in pod "clickhouse-0" is waiting to start: ContainerCreating
好在调度器每次都会把无法放置的原因记进事件里。那一句话就是诊断的全部。
调度器拒绝消息的解读方法
kubectl describe pod clickhouse-0 -n analytics | tail -6
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 11m default-scheduler 0/5 nodes are available: 2 Insufficient cpu, 1 node(s) had untolerated taint {dedicated: batch}, 2 node(s) didn't match Pod's node affinity/selector. preemption: 0/5 nodes are available: 5 Preemption is not helpful for scheduling.
必须先弄清这句话的结构。
- 0/5 — 全部 5 个节点中有 0 个可用。前面那个数字是通过筛选的节点数
- 冒号后面是按节点统计的淘汰理由。2 + 1 + 2 = 5,与节点数正好对得上
- 最后的
preemption:子句表示,即便用基于优先级的抢占也腾不出位置
第二项蕴含着关键信息。调度器的过滤链对每个节点都停在第一个失败的插件上。所以一个节点只会被统计进一个理由里。既缺 CPU 又带污点的节点,只会显示排在前面的那一个理由。落到实操上,这意味着:改掉报出来的理由后紧接着冒出下一个理由,是正常现象。必须一次剥一层。
事件的默认保留时长是一小时,所以在停留已久的 Pending Pod 上可能已经消失了。这时就重建 Pod,重新取出事件。
kubectl get events -n analytics --field-selector reason=FailedScheduling --sort-by=.lastTimestamp | tail -3
LAST SEEN TYPE REASON OBJECT MESSAGE
3m41s Warning FailedScheduling pod/clickhouse-0 0/5 nodes are available: 2 Insufficient cpu, ...
原因分支与诊断
| 原因 | 调度器消息 | 诊断命令 | 解决 |
|---|---|---|---|
| CPU、内存请求量超出 | Insufficient cpu, Insufficient memory | kubectl describe node 的 Allocated resources | 调低 requests 或扩容节点 |
| 没有容忍度 | had untolerated taint | kubectl describe node 的 Taints | 添加 tolerations 或移除污点 |
| 选择器、亲和性不匹配 | didn't match Pod's node affinity/selector | kubectl get nodes --show-labels | 给节点打标签或放宽条件 |
| PVC 未绑定 | pod has unbound immediate PersistentVolumeClaims | kubectl describe pvc | 确认 StorageClass 存在且制备器正常 |
| 卷可用区不匹配 | volume node affinity conflict | 查看 PV 的 nodeAffinity | 在同一可用区准备节点 |
| Pod 反亲和性自我排除 | didn't match pod anti-affinity rules | 比较副本数与拓扑数 | 放宽为 preferred 或增加节点 |
| 违反分布约束 | didn't match pod topology spread constraints | 用 kubectl get pods -o wide 查看当前分布 | 放宽为 ScheduleAnyway,调整 maxSkew |
| 单节点 Pod 数量上限 | Too many pods | 用 kubectl get node -o jsonpath 查询 allocatable.pods | 修改 CNI 配置或扩容节点 |
| 节点 NotReady、cordon | node(s) were unschedulable | kubectl get nodes | 恢复节点或 uncordon |
资源不足 — 判定基准是 requests 而不是用量
这是最常见,同时也最经常被误诊的一支。
kubectl top node
NAME CPU(cores) CPU% MEMORY(bytes) MEMORY%
ip-10-0-1-14 842m 21% 4118Mi 31%
ip-10-0-2-77 1105m 27% 5203Mi 39%
ip-10-0-3-91 667m 16% 3877Mi 29%
每个节点的 CPU 都在 20% 上下,内存也在 30% 上下。如果从这里得出「资源明明有富余,为什么调度不上去」的结论,就会白白浪费好几个小时。调度器不看实际用量。它只看节点上已放置的那些 Pod 的 requests 总和。
kubectl describe node ip-10-0-1-14 | grep -A10 "Allocated resources"
Allocated resources:
(Total limits may be over 100 percent, i.e., overcommitted.)
Resource Requests Limits
-------- -------- ------
cpu 3730m (93%) 6200m (155%)
memory 11544Mi (89%) 15Gi (118%)
ephemeral-storage 0 (0%) 0 (0%)
pods 24 24
实际用量 21%,预留量 93%。站在调度器的角度,这个节点已经几乎塞满了。原因通常是几个把 requests 开得过大的 Pod。
kubectl get pods -A --field-selector spec.nodeName=ip-10-0-1-14 \
-o custom-columns='NS:.metadata.namespace,POD:.metadata.name,CPU:.spec.containers[*].resources.requests.cpu,MEM:.spec.containers[*].resources.requests.memory' \
| sort -k3 -hr | head -6
NS POD CPU MEM
analytics spark-executor-3 1500m 4Gi
analytics spark-executor-1 1500m 4Gi
search indexer-6c4d9f7b8-h2klp 500m 2Gi
payments ledger-5f8b7c6d9-nm3xt 200m 1Gi
两个 Spark 执行器预留了 3 个 CPU 核,实际上几乎没怎么用。
这里必须把 requests 与 limits 的角色区分清楚。
- requests — 调度的唯一基准。用节点的 allocatable 减去这些值来计算剩余空位。到了容器运行阶段,对 CPU 而言体现为 cgroup 权重,对内存而言则不带任何强制力
- limits — 完全不参与调度。它只是运行期的上限。CPU limits 会引发限流,内存 limits 会引发 OOM 杀进程
所以 limits 开得再大也不会导致调度不上去,反过来,requests 开得大就算实际不用也会占住节点。
常见的错误答案:干脆把 requests 删掉让调度通过。Pod 是能起来,但 QoS 类别会变成 BestEffort,节点一旦承压就第一个被驱逐。而且该 Pod 在调度器的计算里被当作 0,会让节点过度密集,连邻居 Pod 都跟着有风险。正确的解法是以实际用量为依据重新核算 requests。
kubectl top pod -A --containers --sort-by=cpu | head -6
NAMESPACE POD NAME CPU(cores) MEMORY(bytes)
analytics spark-executor-3 executor 180m 1204Mi
analytics spark-executor-1 executor 166m 1189Mi
search indexer-6c4... indexer 410m 1802Mi
预留了 1500m,却只用 180m。降到 400m 左右,就能整整空出一个节点。
节点不肯接收的情况
污点与容忍度
kubectl get nodes -o custom-columns='NAME:.metadata.name,TAINTS:.spec.taints[*].key,EFFECT:.spec.taints[*].effect'
NAME TAINTS EFFECT
ip-10-0-1-14 dedicated NoSchedule
ip-10-0-2-77 <none> <none>
ip-10-0-3-91 node.kubernetes.io/disk-pressure NoSchedule
ip-10-0-4-22 nvidia.com/gpu NoSchedule
只有第二个节点没有污点。第三个节点上的污点不是人打上去的,而是 kubelet 自动打上去的。看到 disk-pressure、memory-pressure、pid-pressure、unreachable、not-ready 这一类污点,那就是节点故障信号,而不是放置策略。不该靠加容忍度绕过去,而应该把节点修好。
如果是有意为之的污点,就加上容忍度。
spec:
tolerations:
- key: dedicated
operator: Equal
value: batch
effect: NoSchedule
节点选择器与亲和性不匹配
消息里出现 affinity/selector 时,先确认符合条件的节点是否真的存在。
kubectl get pod clickhouse-0 -n analytics -o jsonpath='{.spec.nodeSelector}'
{"disktype":"nvme","topology.kubernetes.io/zone":"ap-northeast-2c"}
kubectl get nodes -l disktype=nvme,topology.kubernetes.io/zone=ap-northeast-2c
No resources found
把条件一个一个去掉试试,问题出在哪一边就显现出来了。
kubectl get nodes -l disktype=nvme
NAME STATUS ROLES AGE VERSION
ip-10-0-1-14 Ready <none> 9d v1.31.4
kubectl get nodes -l topology.kubernetes.io/zone=ap-northeast-2c
No resources found
nvme 节点是有的,但那个可用区里一个节点都没有。要么是可用区条件写错了,要么是该可用区的节点组被缩到了 0。
单节点 Pod 数量上限与 NotReady
如果消息是 Too many pods,那问题在于数量而不是资源。
kubectl get nodes -o custom-columns='NAME:.metadata.name,MAXPODS:.status.allocatable.pods'
NAME MAXPODS
ip-10-0-1-14 29
ip-10-0-2-77 29
ip-10-0-3-91 29
在 EKS 上使用 VPC CNI 时,上限由节点能挂载的 ENI 与 IP 数量决定,所以实例类型偏小的话,即便 CPU 和内存还有富余也接不下更多 Pod。需要开启前缀委派,或者把实例类型调大。
节点本身掉线的情况也很常见。
kubectl get nodes
NAME STATUS ROLES AGE VERSION
ip-10-0-1-14 Ready <none> 9d v1.31.4
ip-10-0-2-77 Ready,SchedulingDisabled <none> 9d v1.31.4
ip-10-0-3-91 NotReady <none> 9d v1.31.4
SchedulingDisabled 表示有人执行过 cordon。绝大多数情况是维护完之后忘了 uncordon。
存储与放置约束
PVC 未绑定,以及正常的 Pending
Pod 是 Pending、PVC 也是 Pending 时,大多数人会怀疑存储,但这里有两种必须区分开的情形。
先看真正出问题的那种。
kubectl describe pvc clickhouse-data -n analytics | tail -5
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning ProvisioningFailed 9m (x14 over 12m) persistentvolume-controller storageclass.storage.k8s.io "fast-ssd" not found
StorageClass 的名字写错了。这是迁移集群时名字发生变化的典型情况。
kubectl get storageclass
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
gp3 (default) ebs.csi.aws.com Delete WaitForFirstConsumer true 61d
io2 ebs.csi.aws.com Delete WaitForFirstConsumer true 61d
再看正常的那种。
kubectl describe pvc report-cache -n analytics | tail -4
Events:
Type Reason Age From Message
---- ---- ---- ---- -------
Normal WaitForFirstConsumer 12s (x8 over 2m) persistentvolume-controller waiting for first consumer to be created before binding
这不是错误。VOLUMEBINDINGMODE 为 WaitForFirstConsumer 的 StorageClass,会在 Pod 被放置到节点之后,才在与该节点相同的可用区里创建卷。必须先放置 Pod 才会有卷,所以 PVC 处于 Pending 完全是按设计运作。
问题就出在这里。如果 Pod 因为别的原因放不上去,PVC 也会永远停在 Pending。于是人们盯着 PVC 去修存储,而真正要修的其实是 Pod 的调度失败。如果 PVC 是因为 WaitForFirstConsumer 在等待,就请忽略 PVC,只看 Pod 的 FailedScheduling 消息。
卷已经创建之后再更换节点,可用区就可能对不上。
kubectl describe pod clickhouse-0 -n analytics | grep FailedScheduling
Warning FailedScheduling 4m default-scheduler 0/5 nodes are available: 5 node(s) had volume node affinity conflict.
意思是卷在 ap-northeast-2a,而那个可用区里没有可调度的节点。
kubectl get pv -o custom-columns='NAME:.metadata.name,ZONE:.spec.nodeAffinity.required.nodeSelectorTerms[*].matchExpressions[*].values' | head -3
NAME ZONE
pvc-8f3c1d5b-2e7a-94c0-f13d-68b5a2e9c47f [ap-northeast-2a]
反亲和性导致的自我排除
如果给 3 个副本加上主机粒度的必需反亲和性,而节点只有 2 个,那么第三个 Pod 会永远 Pending。
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: report-worker
topologyKey: kubernetes.io/hostname
kubectl describe pod report-worker-7f4c8d9b6-hs2p -n analytics | grep FailedScheduling
Warning FailedScheduling 9m default-scheduler 0/2 nodes are available: 2 node(s) didn't match pod anti-affinity rules.
这条约束确实有必须使用的场合,但副本数一旦超过拓扑数,它就会悄无声息地卡住发布。如果目的是可用性,通常用 preferred 就够了。
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: report-worker
topologyKey: kubernetes.io/hostname
topologySpreadConstraints
分布约束比反亲和性温和,但只要把 whenUnsatisfiable 留成 DoNotSchedule,一样会被卡住。
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: report-worker
有 3 个可用区,但其中一个可用区的节点全部塞满时,即使别的可用区还有位置,也会因为守不住 1 的偏斜而被拒绝放置。
kubectl get pods -n analytics -l app=report-worker -o wide
NAME READY STATUS NODE ZONE
report-worker-7f4c8d9b6-k9vt 1/1 Running ip-10-0-1-14 2a
report-worker-7f4c8d9b6-m2xd 1/1 Running ip-10-0-2-77 2b
report-worker-7f4c8d9b6-hs2p 0/1 Pending <none> <none>
如果可用性不是绝对条件,改成 ScheduleAnyway 更实用。此时这条约束只参与打分计算,不会阻止放置。
Cluster Autoscaler 不扩节点的原因
节点不够时,人们期待自动扩缩容会把节点加上去,但不扩的情况其实很常见。这时理由同样会留在事件里。
kubectl describe pod clickhouse-0 -n analytics | grep -A2 NotTriggerScaleUp
Normal NotTriggerScaleUp 2m17s (x21 over 9m) cluster-autoscaler pod didn't trigger scale-up: 1 max node group size reached, 2 node(s) had untolerated taint {dedicated: batch}, 1 node(s) had volume node affinity conflict
它同时在说三件事。一个节点组已经达到最大规模,两个组带着这个 Pod 无法容忍的污点,还有一个组的卷可用区对不上。自动扩缩容器会预先模拟假设启动一个新节点之后这个 Pod 能否被放置,一旦判定放不上去,就不会去扩节点。
控制器侧的日志和状态 ConfigMap 也要一起看。
kubectl -n kube-system logs deployment/cluster-autoscaler --tail=200 | grep -i "no expansion\|max size\|scale_up"
I0726 09:52:14.118 scale_up.go:300] Pod analytics/clickhouse-0 can't be scheduled on eks-general-2c. Predicate checking error: node(s) had volume node affinity conflict
I0726 09:52:14.119 scale_up.go:472] No expansion options
kubectl -n kube-system get configmap cluster-autoscaler-status \
-o jsonpath='{.data.status}' | head -12
Cluster-autoscaler status at 2026-07-26 09:52:14:
Cluster-wide:
Health: Healthy
ScaleUp: NoActivity (ready=5 registered=5)
ScaleDown: NoCandidates
NodeGroups:
Name: eks-general-2a
Health: Healthy (ready=3 cloudProviderTarget=3 minSize=1 maxSize=3)
ScaleUp: NoActivity
maxSize 与 cloudProviderTarget 相同,就说明撞上了上限。
常见的错误答案:一看到 Pending 就无脑加节点。反亲和性、分布约束、卷可用区不匹配这几类,要么加节点也解不开,要么节点加到了不相干的可用区,只是把成本推高。应该先读 NotTriggerScaleUp 消息,把它指出的理由消除掉。
防止复发
Pending 大多是 requests 的估算缺乏依据所导致的结果。可以用三道装置把它压下去。
第一,在命名空间上强制默认值与上限。挡住那些漏写 requests 的 Pod。
apiVersion: v1
kind: LimitRange
metadata:
name: default-requests
namespace: analytics
spec:
limits:
- type: Container
default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 100m
memory: 256Mi
max:
cpu: 4
memory: 16Gi
第二,把 VPA 跑在推荐模式下,拿到基于实际用量的建议值。即便不做到自动应用,光是能看到这些数字就已经有价值。
kubectl get vpa spark-executor -n analytics -o jsonpath='{.status.recommendation.containerRecommendations[0]}'
{"containerName":"executor","lowerBound":{"cpu":"180m","memory":"1000Mi"},"target":{"cpu":"320m","memory":"1400Mi"},"upperBound":{"cpu":"1200m","memory":"3Gi"}}
第三,用告警抓住 Pending 的持续时长。放置失败在应用指标上完全不会体现,所以需要单独的规则。
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: scheduling-alerts
namespace: monitoring
spec:
groups:
- name: scheduling
rules:
- alert: PodPendingTooLong
expr: kube_pod_status_phase{phase="Pending"} == 1
for: 15m
labels:
severity: warning
annotations:
summary: "{{ $labels.namespace }}/{{ $labels.pod }} has been Pending for 15m"
- alert: NodeRequestsNearlyFull
expr: sum by (node) (kube_pod_container_resource_requests{resource="cpu"}) / on(node) kube_node_status_allocatable{resource="cpu"} > 0.9
for: 30m
labels:
severity: warning
结语 — 调度器早就把答案说出来了
在 Pending 的诊断里,几乎没有需要靠猜的时刻。FailedScheduling 事件的那一句话里,节点数和淘汰理由都已经统计好了,而每条理由都对应上面表格中的某一行。只要记住过滤链在每个节点的第一次失败处就停下,所以理由是按顺序逐个浮现的,这件事就变成了一次剥一层的工作。
值得记住的一句话是这样的。调度是由 requests 的算术决定的,而不是实际用量;kubectl top 看起来清闲,并不代表节点上还有位置。
현재 단락 (1/244)
发布显示成功,但 Pod 一动不动。