Kubernetes 节点 GPU 显示 13 卡可用实际 0 卡可调度:vcluster fake-node 标签丢失定位
Kubernetes 节点 GPU 显示 13 卡可用实际 0 卡可调度:vcluster fake-node 标签丢失定位
结论先放上:当
kubectl top或自己写 jq 算出来的"GPU 节点利用率"显示某个集群还有 10+ 张卡可用、但业务 pod 就是调不上去时,不要急着扩容、不要急着切流量——大概率是节点缺标签、Pending pod 幽灵占用、或者节点已被 cordon 三个陷阱里的一个。本文用一次真实排障过程把这三个陷阱讲透,最后落到一个非显然的根因:托管 vcluster 平台在物理机维修后会让 fake-node 的标签静默丢失。
我们有三套 GPU k8s 集群分别托管在三家不同的云/平台上,下文一律用 A、B、C 指代。三套 kubeconfig 我在跳板机上分别 alias 成 k1/k2/k3(正文里的命令都写标准 kubectl,你按自己环境换前缀即可)。
某天看监控报 GPU 集群有调度异常,我从 events 入手。
第一步:events 反复出现 FailedScheduling
按时间倒序看最近事件:
kubectl get events --sort-by=.lastTimestamp -A | tail -50
prod 112s Warning FailedScheduling pod/task-monitor-xxx
pod group is not ready, 1 Pending, 1 minAvailable, 108 Running, 4 Releasing;
Pending: 1 InsufficientQuota
prod 102s Warning FailedScheduling pod/edit-client-xxx
pod group is not ready, 1 minAvailable, 106 Running, 2 Pending, 5 Releasing;
Pending: 2 InsufficientQuota
InsufficientQuota 反复出现,但 kubectl get resourcequota -n prod 又是 No resources found——所以这不是 k8s 原生 ResourceQuota 在卡,而是某个调度器自家的 quota CRD 在卡。
那真正的容量怎么算?得分集群看。
第二步:三集群一行命令对比基线
写一个 for 循环把三集群的节点数、GPU 余量、Pod 数串起来。这套模板我现在固定用:
# 三个 kubeconfig 分别 alias 成 k1/k2/k3
for k in k1 k2 k3; do
echo "=== $k ===";
$k get nodes --no-headers | wc -l;
$k top node --no-headers 2>&1 | head -5;
done
跑完是这样:
| 集群 | 节点数 | CPU 利用率 | 内存利用率 | top 是否可用 |
|---|---|---|---|---|
| k1 (A) | 18 | < 4% | 7~22% | ✅ |
| k2 (B) | 36 | 1~5% | 10~20% | ✅ |
| k3 (C) | 15 | — | — | ❌ metrics not available yet |
第一个陷阱已经露头了:kubectl top 看的是 CPU / 内存,不是 GPU。GPU 集群的 CPU 闲不代表卡空,得另算。
第三步:自己写 jq 算每节点 GPU 余量
按 pod 的 requests."nvidia.com/gpu" 求和、按节点聚合,再 join 上节点 allocatable,得出每节点利用率:
join -a1 -e0 -o 1.1 1.2 2.2 \
<(kubectl get nodes -o json | jq -r '.items[] |
"\(.metadata.name) \(.status.allocatable["nvidia.com/gpu"] // "0")"' \
| sort) \
<(kubectl get pods -A -o json | jq -r '.items[] |
select(.spec.nodeName) |
.spec.nodeName as $n |
(.spec.containers[]?.resources.requests."nvidia.com/gpu" // "0" | tonumber) as $g |
"\($n) \($g)"' \
| awk '{a[$1]+=$2} END {for(k in a) printf "%s %d\n", k, a[k]}' \
| sort) \
| awk '{free=$2-$3; pct=($2>0?$3/$2*100:0);
printf "%-32s %d/%d free=%d %3d%%\n", $1, $3, $2, free, pct}'
跑 k3 集群(即下文的 C 平台)得到:
node-xxx......w6m3besu 8/8 free=0 100%
node-xxx......53kgjtadmb 8/8 free=0 100%
node-xxx......52rjchz3vu 8/8 free=0 100%
node-xxx......525rgh6geb 8/8 free=0 100%
node-xxx......uljewrj4ah 8/8 free=0 100%
... (8 个节点 100%)
node-xxx......uihaekjigl 8/8 free=0 100%
node-xxx......ulvq4g3iqi 7/8 free=1 87%
node-xxx......ujx4guefbh 3/8 free=5 37%
node-xxx......52ew76fke2 1/8 free=7 12%
合计:total=120 used=107 free=13 util=89.2% max_single_node_free=7
看上去还剩 13 张卡可用、最大单节点空 7 卡。我以为可以接一波切流,实际不行——后面三个陷阱挨个揭穿。
陷阱一:调度占用率 ≠ 真实 GPU 利用率
我刚算的「利用率」严格说叫调度占用率:
利用率 = sum(pod.spec.containers[].resources.requests."nvidia.com/gpu")
÷ node.status.allocatable["nvidia.com/gpu"]
它代表 scheduler 视角"这台机有几张卡被锁了"——和真实 GPU 算力 / 显存使用率是两回事。一个 pod 申请 8 卡,可能只用一张做推理、剩下 7 张挂着;fp16 模型也可能只吃 30% SM。
要看真实算力,三种来源:
- DCGM exporter(标准方案,对接 Prometheus)
- 节点上 nvidia-smi 直接看(要 exec 进去或起 DaemonSet)
- 云厂商自家 GPU exporter(如某些云的 ARMS-style GPU 监控)
我检查三集群 DCGM 状态:
for k in k1 k2 k3; do
echo "=== $k ===";
$k get ds -A | grep -iE 'dcgm|nvidia|gpu';
$k get svc -A | grep -E '9400|dcgm';
done
| 集群 | DCGM 状态 |
|---|---|
| k1 (A) | 无标准 DCGM,装了云厂商自家 GPU exporter(指标走云厂商自家监控产品) |
| k2 (B) | ✅ 完整:标准 dcgm-exporter 36/36 副本 + Svc :9400 |
| k3 (C) | ⚠️ 只有 headless Svc kube-system/dcgm-exporter:9400,backing pod 不存在 |
所以接下来还是只能用调度占用率作为代理指标——但要意识到这是充分上界,不是真实算力。
陷阱二:Pending pod 的"幽灵占用"
回头看那个 C 平台节点 node-xxx......52ew76fke2,jq 算出来是 1/8(已用 1 张),free=7。我去 describe 这个节点看真实 Allocated:
kubectl describe node node-xxx......52ew76fke2 | grep -A10 'Allocated resources'
Allocated resources:
Resource Requests Limits
-------- -------- ------
cpu 100m (0%) 2 (1%)
memory 256Mi (0%) 2Gi (0%)
nvidia.com/gpu 0 0 ← scheduler 视角是 0
scheduler 视角 GPU 已用是 0,jq 却算成 1——差在哪?
把节点上所有 pod 列出来:
kubectl get pods -A --field-selector spec.nodeName=node-xxx......52ew76fke2
NAMESPACE NAME STATUS GPU 申请
kube-system coredns-xxx Running 0
kube-system loongcoll-xxx Pending 0
prod inference-xxx Pending 1 ← 这个!
那个 inference-xxx 是 Pending 状态但 spec.nodeName 已被绑到这台节点的 pod。它启不来(被另一个 quota 卡着),但 scheduler 在 binding 阶段就 reserve 了这张 GPU。我的 jq 把它算成"已用",scheduler 的 Allocated resources 也认这张占用——直到 pod 被 GC,资源才会释放。
我管这种叫幽灵占用:调度看似占着、其实物理 GPU 完全空闲、pod 永远启不来。做容量决策时如果只看 jq 求和会被它误导:表面 free=7,实际节点真空 8 卡;又或者表面 free=0,其实有一堆永远启不来的 Pending pod 挂着不放。
更可靠的口径:
# 用 describe node 的 Allocated 段作为真实 scheduler 视角
kubectl describe node <name> | grep -A10 'Allocated resources'
# 同时看 pod 状态分布,把 Pending 的单独列
kubectl get pods -A --field-selector spec.nodeName=<name>
两个口径有差异,差值就是幽灵占用,要去查这些 Pending pod 是什么、为啥启不来。
陷阱三:8 个 Pending pod 的两套调度器
C 平台当前有 8 个 Pending pod,把每个的 schedulerName 和报错都列出来:
kubectl get pods -A --field-selector=status.phase=Pending -o json \
| jq -r '.items[] |
"\(.metadata.namespace)/\(.metadata.name) sched=\(.spec.schedulerName) msg=\((.status.conditions[]? | select(.type=="PodScheduled") | .message) // "")"'
输出分成两类:
A 类(14 小时前 Pending,5 个)——包括 DaemonSet:
kube-system/loongcollector-xxx sched=default-scheduler
msg: pod group is not ready, ... InsufficientQuota / QuotaNotSelected
observability/promtail-xxx sched=default-scheduler
msg: 同上
prod/inference-xxx (老 pod) sched=default-scheduler
msg: 同上
注意 loongcollector 和 promtail 都是 GPU=0 的系统组件,也被 quota 卡着 14 小时调不上去。describe 完整事件源:
From: x-scheduler ← 关键,平台自家调度器
Warning FailedScheduling 3m35s (x23191 over 14h)
pod group is not ready, ... Pending: 1 InsufficientQuota
x-scheduler(C 平台自家调度器,名字脱敏)通过 admission webhook 在 pod 创建时强制接管(即使 yaml 里写的是 default-scheduler)。它有自家的 quota CRD(不暴露 volcano 标准的 queue / podgroup,kubectl get queue / podgroup -A 都返回 the server doesn't have a resource type)。
反直觉点:DaemonSet 也被它强制接管了——通常 DaemonSet 走的是 default-scheduler 的 daemonset controller 直接 binding,不会被 quota 卡。但平台 webhook 一视同仁,结果一个 quota 配错把 coredns / loongcollector / promtail 全停在那儿 14 小时没人发现。
B 类(6 分钟前 Pending,3 个)——业务 pod:
prod/inference-yyy sched=default-scheduler
msg: 0/15 nodes are available:
1 node(s) were unschedulable,
11 Insufficient nvidia.com/gpu,
3 Insufficient cpu,
3 node(s) didn't match Pod's node affinity/selector.
15 个节点全被排除:1 个被 cordon、11 个 GPU 不足、3 个 CPU 不足、3 个 affinity 不匹配。等等——刚才不是说有 13 张卡空吗?怎么 11 个节点都 GPU 不足?
线索藏在 3 node(s) didn't match Pod's node affinity/selector。看下 pod 自己的 nodeSelector:
kubectl get pod prod/inference-yyy -o yaml | grep -A5 nodeSelector
nodeSelector:
node_env: prod
业务 pod 强制要求节点带 node_env=prod 标签。那两个看着 GPU 空的节点(最大空 7 卡的、空 5 卡的),可能根本没贴这个标签——下一节验证。
第四步:用 -L 看节点标签
kubectl get nodes 加 -L <label> 可以把某个 label 作为单独列显示,加 -l '!<label>' 可以反向过滤"没有这个 label"的节点:
# 把 node_env 作为列显示
kubectl get nodes -L node_env
# 直接列出"没有 node_env 标签"的节点
kubectl get nodes -l '!node_env'
跑出来的 C 平台全表:
| 节点 | Ready | node_env | GPU | 备注 |
|---|---|---|---|---|
| ......ui7rqebujn | ✅ | test | 8/8 | 不是 prod 节点 |
| ......uihaekjigl | ✅ | prod | 8/8 | |
| ......uitkiiydfe | ✅ | prod | 8/8 | |
| ......ujlwuw7exp | ✅ | prod | 8/8 | |
| ......ujx4guefbh | ✅ | (空) | 0/8 | ❗ 8 卡空但缺标签 |
| ......uk4xh2k2ji | ✅ | prod | 8/8 | |
| ......ukd7tcmmdk | ✅ | prod | 8/8 | |
| ......ukqaky3d6f | ✅ | prod | 8/8 | |
| ......uljewrj4ah | ✅ | prod | 8/8 | |
| ......ulvq4g3iqi | ✅ | test | 7/8 | 不是 prod 节点 |
| ......525rgh6geb | ✅ | prod | 8/8 | |
| ......52ew76fke2 | ❌ NotReady + Cordoned | prod | 0/8 | ❗ 节点死了 155 天 |
| ......52rjchz3vu | ✅ | prod | 8/8 | |
| ......53kgjtadmb | ✅ | prod | 8/8 | |
| ......53w6m3besu | ✅ | prod | 8/8 |
汇总:
- 看似 free=13(jq 算出)→ 真实可调度业务 pod ≈ 0 张:
- 那 7 卡空的节点(
...ew76fke2)状态是 NotReady + SchedulingDisabled,已经死了 155 天没人 cordon-uncordon - 那 5 卡空(后来变成 8 卡空)的节点(
...ujx4guefbh)缺node_env=prod标签,业务 pod 看不见它 - 仅剩的 1 卡是
node_env=test节点(业务 pod nodeSelector 是 prod,不会调过去)
- 那 7 卡空的节点(
陷阱三验证完毕:13 卡余量是统计噪声,真实业务可调度容量为 0。
第五步:想查 label 修改历史 → 死胡同
业务节点为啥缺 node_env=prod 标签?想查 label 是从来没贴过、还是贴过又被删了。
k8s 本身不存对象修改历史,但 Server-Side Apply 留下的 metadata.managedFields 可以告诉你最后一次哪个 manager 在什么时间改了哪些字段。试一下:
kubectl get node ...ujx4guefbh -o json \
| jq '.metadata.managedFields[] | {time, manager, operation}'
jq: error (at <stdin>:125): Cannot iterate over null (null)
managedFields = null。对比满节点也是 null——C 平台的 vcluster 实现把 managedFields 整个剥离了(猜测是为了节省 etcd 空间)。
kubectl get events --field-selector involvedObject.name=<node> 也是 No resources found(label 变更本来就不产生 event)。
到这里 k8s 角度能做的全做完了,完全查不到 label 修改历史。剩下只能找平台方。
真相:物理机维修触发的 fake-node 脱钩
把上面的疑点列给 C 平台方:
- 节点
...ujx4guefbh创建时间 ~14 小时前 - 缺
node_env=prod标签 - 节点上 8 张 GPU 全闲
- 同集群其他 11 个业务节点都有标签
他们的回复一句话讲清楚了根因(脱敏复述):
「该节点之前 pod 数量降到 0,触发了 vcluster fake-node 与物理集群脱钩的 bug。底层物理机送去维修了,回来后 fake-node 是重新创建的,标签没有自动补上。这个 bug 我们已经修了,等稳定再做一次全量更新。」
把碎片拼上:
某时刻 GPU 物理机硬件故障 → 送修
↓
任意时刻 对应 fake-node 上 pod 数变 0
↓
触发 bug:fake-node 与物理集群"脱钩"
↓
~14h 前 物理机修好回来 → fake-node 重新创建
↓
平台 controller 漏了补 node_env=prod 标签 ← 标签缺失暴露
↓
14h+ 节点 8 卡白闲,业务 pod 永远进不来
这事最难的地方在于:vcluster fake-node 模型下,节点对象的生命周期由平台自己的 controller 管,不是 kubelet 注册的真实节点。物理硬件的维修 → 上层节点对象的销毁与重建之间,标签元数据完全可能丢失。而 managedFields 被剥离让你连"谁动过 label"都查不到,只能依赖平台方提供解释。
快速参考
多集群一行命令骨架
# 多 kubeconfig alias 成 k1/k2/k3 后
for k in k1 k2 k3; do echo "=== $k ==="; $k get nodes --no-headers | wc -l; done
节点 GPU 调度占用率(含"幽灵占用"陷阱说明)
join -a1 -e0 -o 1.1 1.2 2.2 \
<(kubectl get nodes -o json | jq -r '.items[] |
"\(.metadata.name) \(.status.allocatable["nvidia.com/gpu"] // "0")"' | sort) \
<(kubectl get pods -A -o json | jq -r '.items[] | select(.spec.nodeName) |
.spec.nodeName as $n |
(.spec.containers[]?.resources.requests."nvidia.com/gpu" // "0" | tonumber) as $g |
"\($n) \($g)"' | awk '{a[$1]+=$2} END {for(k in a) printf "%s %d\n", k, a[k]}' | sort) \
| awk '{free=$2-$3; printf "%-32s %d/%d free=%d\n", $1, $3, $2, free}'
注意:这个口径会把 Pending 但 spec.nodeName 已被绑的 pod 也算成已占用。要看 scheduler 真实视角用 kubectl describe node <name> | grep -A10 'Allocated resources'。
节点标签速查
# 看某个特定 label 值
kubectl get node <name> -o jsonpath='{.metadata.labels.node_env}{"\n"}'
# 看所有标签
kubectl get node <name> --show-labels
# 把 label 作为列展示(批量看)
kubectl get nodes -L node_env -L kubernetes.io/arch
# 按 label 过滤
kubectl get nodes -l node_env=prod
kubectl get nodes -l '!node_env' # 反向:没有 node_env 标签的
Pending pod 根因速查
# 所有 Pending pod 的 schedulerName + 失败原因
kubectl get pods -A --field-selector=status.phase=Pending -o json \
| jq -r '.items[] |
"\(.metadata.namespace)/\(.metadata.name) sched=\(.spec.schedulerName)
msg=\((.status.conditions[]? | select(.type=="PodScheduled") | .message) // "")"'
# 完整调度事件(含 23191 次重试这种历史 retry 计数)
kubectl describe pod -n <ns> <pod>
"节点 GPU 全空但业务 pod 进不去" 排查 checklist
□ 节点 Ready 吗?是不是 NotReady 或 SchedulingDisabled (cordoned)?
kubectl get nodes | grep -E 'NotReady|SchedulingDisabled'
□ 节点的 nodeSelector 相关标签全吗?对比满节点缺什么?
kubectl get nodes -L node_env # 或你们 deploy 用的任何 nodeSelector 键
□ Pending pod 的事件是 default-scheduler 还是其他自定义 scheduler 报的?
kubectl get pods ... -o yaml | grep -E 'schedulerName|FailedScheduling'
□ 是不是 spec.nodeName 已被绑定的 Pending pod 在"幽灵占用"?
对比 jq sum vs describe node 的 Allocated 段
□ 调度器报 InsufficientQuota / QuotaNotSelected:
- kubectl get resourcequota -n <ns> → 看是不是原生 quota
- kubectl api-resources | grep -iE 'quota|podgroup|queue' → 看自定义 quota CRD
□ 托管 vcluster 集群上节点缺标签:
联系平台方查是不是物理机刚维修回来 / 节点重新创建过
几条铁律
kubectl top显示的不是 GPU 利用率,只是 CPU/内存——GPU 算力得靠 DCGM exporter- 调度占用率(request/allocatable)≠ 真实 GPU 算力利用率,前者是充分上界
spec.nodeName已被绑的 Pending pod 真实占着资源,scheduler 把它当已分配- GPU 余量必须按"业务 pod 实际能调度的节点"算,不能直接按集群所有节点的总和算
- 托管 vcluster 集群的节点对象不是 kubelet 注册的真节点,生命周期由平台 controller 管,物理变更可能让标签静默丢失
managedFields是节点对象 label 修改的唯一审计来源,被平台剥离意味着完全无法自查 label 历史

浙公网安备 33010602011771号