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 集群分别托管在三家不同的云/平台上,下文一律用 ABC 指代。三套 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。

要看真实算力,三种来源:

  1. DCGM exporter(标准方案,对接 Prometheus)
  2. 节点上 nvidia-smi 直接看(要 exec 进去或起 DaemonSet)
  3. 云厂商自家 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:9400backing 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-xxxPending 状态但 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: 同上

注意 loongcollectorpromtail 都是 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 / podgroupkubectl 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 卡空)的节点(...ujx4guefbhnode_env=prod 标签,业务 pod 看不见它
    • 仅剩的 1 卡是 node_env=test 节点(业务 pod nodeSelector 是 prod,不会调过去)

陷阱三验证完毕: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 历史
posted @ 2026-06-01 14:40  Hello_worlds  阅读(13)  评论(0)    收藏  举报