GPU 故障分类 — 从同一条消息里分出不同的原因
目标
在真实的调度器上造出让 GPU Pod 受阻的七种状态,并制作只看对象就能用一个词回答原因的分类器。
为什么重要
一份“GPU Pod 起不来”的报告,实际原因有六七种,需要调查的地方也各不相同。而且,资源未上报、allocatable 为 0、空位耗尽这三种情况,调度器给出的是完全相同的语句。所以光读消息是不够的,必须按 nodeSelector → 污点 → capacity → allocatable → 剩余空位 的顺序对照对象,才能把原因收敛到一个。顺序之所以重要,是因为前一步为真时,后一步根本没有可检查的对象——违反顺序,就会像删除好好的污点那样,只把集群弄坏。如果每次都由人来做这个判定,标准就会动摇,所以最后把它固化成工具。
步骤
- 创建工作目录
/root/gputri/cases、/root/gputri/out、/root/gputri/bin,并创建命名空间gpu-triage。在lab-node-0的status.capacity和status.allocatable两处,都把nvidia.com/gpu设为"2",并添加污点nvidia.com/gpu=present:NoSchedule。在/root/gputri/cases/case-ok.yaml中写出 Podcase-ok——命名空间为gpu-triage,nodeSelector为kubernetes.io/hostname: lab-node-0,带有能容忍该污点的容忍度,容器名称为cuda,镜像为nvcr.io/nvidia/cuda:12.4.1-base-ubuntu22.04,limits中为nvidia.com/gpu: 1。应用后,等它变为Running,就在/root/gputri/out/01-ok.txt中写两行——NODE=和PHASE=。 lab-node-1不上报任何东西(这是 device plugin 挂掉的节点)。在/root/gputri/cases/case-nogpunode.yaml中写出 Podcase-nogpunode——用nodeSelector固定到lab-node-1,容忍度与第 1 步相同,并要求nvidia.com/gpu: 1。应用后,确认它处于Pending,并把PodScheduled条件的reason和message,以REASON=和MESSAGE=两行的形式保存到/root/gputri/out/02-nogpunode.txt。- 在
lab-node-2的status.capacity中把nvidia.com/gpu设为"4",在status.allocatable中设为"0"(驱动验证失败,节点无法提供 GPU 的状态)。在/root/gputri/cases/case-alloc0.yaml中写出 Podcase-alloc0——固定到lab-node-2,其余与第 2 步相同。应用后,在/root/gputri/out/03-alloc0.txt中写三行——CAPACITY=、ALLOCATABLE=、MESSAGE=。请与前一步的消息作比较。 - 在
/root/gputri/cases/case-taint.yaml中写出 Podcase-taint——固定到lab-node-0,要求nvidia.com/gpu: 1,但不加入容忍度。 其余与第 1 步的 Pod 相同。应用后,在/root/gputri/out/04-taint.txt中写两行——TAINT=nvidia.com/gpu=present:NoSchedule和MESSAGE=。请通过消息确认,资源明明还有,为什么却被拦住。 - 在
/root/gputri/cases/case-label.yaml中写出 Podcase-label——把nodeSelector设为nvidia.com/gpu.product: NVIDIA-A100-SXM4-40GB(没有节点带有这个标签),容忍度和nvidia.com/gpu: 1请求保持不变。应用后,在/root/gputri/out/05-label.txt中写两行——MATCHING_NODES=中写实际带有该标签的节点数,MESSAGE=中写条件消息。 - 在
/root/gputri/cases/case-exhaust.yaml中写出 Podcase-exhaust——固定到lab-node-0,加入容忍度,并要求nvidia.com/gpu: 2。该节点上报了 2 块,但第 1 步的case-ok已经占住了一块。应用后,在/root/gputri/out/06-exhaust.txt中写三行——ALLOCATABLE=(该节点上报的数量)、ALLOCATED=(放置在该节点上的 Pod 的 GPU 请求之和)、REQUESTED=2。 - 先在
/root/gputri/cases/case-norequest.yaml中写出 Podcase-norequest——固定到lab-node-0,加入容忍度,但完全不写资源请求。 应用后它会启动。接着在/root/gputri/cases/case-rtc.yaml中写出带有runtimeClassName: nvidia的 Podcase-rtc并应用,把包含标准错误的输出保存到/root/gputri/out/07-runtimeclass.txt(不要创建 RuntimeClass)。最后创建命名空间gpu-quota,在/root/gputri/cases/quota.yaml中写出 ResourceQuotagpu-quota,把requests.nvidia.com/gpu限制为"1",然后应用/root/gputri/cases/case-quota.yaml中的 Podcase-quota(要求 3 块 GPU),把输出保存到/root/gputri/out/07-quota.txt。最后在/root/gputri/out/07-symptoms.txt中写三行——NOREQUEST=、RUNTIMECLASS=、QUOTA=。后两行中,用created或absent来写 Pod 对象是否已经生成。 - 创建
/root/gputri/bin/triage.sh。用bash triage.sh <네임스페이스> <파드>(占位符依次为命名空间与 Pod)调用时,只在标准输出中输出一个词就结束。答案有七种——no-requestoklabel-mismatchtaintno-gpu-nodeallocatable-zeroexhausted。如果 Pod 不存在,就在标准错误中输出提示,并以1结束。判定只依据kubectl get -o json给出的对象,顺序是 没有请求 → 已经放置 → nodeSelector → 污点 → capacity → allocatable → 剩余空位。创建之后,把它接到七个 Pod(case-okcase-nogpunodecase-alloc0case-taintcase-labelcase-exhaustcase-norequest)上全部运行一遍,并在/root/gputri/out/triage.txt中按<파드이름> <원인>(占位符依次为 Pod 名称与原因)的形式写七行。
参考
- 这个集群里既没有 GPU,也没有 device plugin。扩展资源是通过直接修补节点 status 来搭建的——调度器读取的反正只有 status,所以对判定对象而言是同一种状态。
- Pod 并不会运行,而是被伪造为 Ready。所以不会根据容器里的 nvidia-smi 看到了什么来判定任何东西,全部只依据 API 对象来判定。
- 用
kubectl patch node <이름> --subresource=status --type=merge -p '{"status":{...}}'(占位符为节点名称)来修改节点 status。 - 条件消息用
kubectl get pod <이름> -n <ns> -o jsonpath='{.status.conditions[0].message}'取出。 - 常见错误:只写 capacity 而漏掉 allocatable,调度器就没有可用的空位。
- 常见错误:计算空位时,如果不去掉
Succeeded、Failed的 Pod,就会把空着的节点误判为耗尽。
搭建一个正常的 GPU 节点
创建工作目录 /root/gputri/cases、/root/gputri/out、/root/gputri/bin,并创建命名空间 gpu-triage。在 lab-node-0 的 status.capacity 和 status.allocatable 两处,都把 nvidia.com/gpu 设为 "2",并添加污点 nvidia.com/gpu=present:NoSchedule。在 /root/gputri/cases/case-ok.yaml 中写出 Pod case-ok——命名空间为 gpu-triage,nodeSelector 为 kubernetes.io/hostname: lab-node-0,带有能容忍该污点的容忍度,容器名称为 cuda,镜像为 nvcr.io/nvidia/cuda:12.4.1-base-ubuntu22.04,limits 中为 nvidia.com/gpu: 1。应用后,等它变为 Running,就在 /root/gputri/out/01-ok.txt 中写两行——NODE= 和 PHASE=。
扩展资源是 kubelet 自己无法统计的。在真实集群中,由 device plugin 写入节点 status,而这里用 kubectl patch node <이름> --subresource=status --type=merge(占位符为节点名称)在同一个位置直接写入同样的值。只写 capacity,调度器就没有可用的空位——两处都要填。生产环境的 GPU 节点几乎总是带有污点,以免普通工作负载流入。所以 GPU Pod 会带上容忍度。
根本没有资源名称的节点
lab-node-1 不上报任何东西(这是 device plugin 挂掉的节点)。在 /root/gputri/cases/case-nogpunode.yaml 中写出 Pod case-nogpunode——用 nodeSelector 固定到 lab-node-1,容忍度与第 1 步相同,并要求 nvidia.com/gpu: 1。应用后,确认它处于 Pending,并把 PodScheduled 条件的 reason 和 message,以 REASON= 和 MESSAGE= 两行的形式保存到 /root/gputri/out/02-nogpunode.txt。
条件可以用 kubectl get pod <이름> -n <ns> -o jsonpath='{.status.conditions[0].reason}'(占位符依次为 Pod 名称与命名空间)取出。请留意消息中写了什么——在接下来的两步中,原因完全不同的 Pod 会给出同一条语句。没有在节点上写 nvidia.com/gpu,并不意味着“没有卡”,而是“在调度器看来没有”。
有 capacity 却无法提供的节点
在 lab-node-2 的 status.capacity 中把 nvidia.com/gpu 设为 "4",在 status.allocatable 中设为 "0"(驱动验证失败,节点无法提供 GPU 的状态)。在 /root/gputri/cases/case-alloc0.yaml 中写出 Pod case-alloc0——固定到 lab-node-2,其余与第 2 步相同。应用后,在 /root/gputri/out/03-alloc0.txt 中写三行——CAPACITY=、ALLOCATABLE=、MESSAGE=。请与前一步的消息作比较。
capacity 是“这个节点拥有的量”,allocatable 是“可以提供给调度器的量”。两者出现差别是正常行为(系统预留量就是这个差别),而在 GPU 上,这个差别确实会拉开到 0。两个值位于同一个 kubectl get node -o json 输出的不同列。把消息与第 2 步的文件并排放在一起看,就能一眼看出为什么需要这个实验。
只缺容忍度的 Pod
在 /root/gputri/cases/case-taint.yaml 中写出 Pod case-taint——固定到 lab-node-0,要求 nvidia.com/gpu: 1,但不加入容忍度。 其余与第 1 步的 Pod 相同。应用后,在 /root/gputri/out/04-taint.txt 中写两行——TAINT=nvidia.com/gpu=present:NoSchedule 和 MESSAGE=。请通过消息确认,资源明明还有,为什么却被拦住。
第 1 步中 case-ok 启动在同一个节点上,而这个 Pod 去不了。区别只有一个容忍度。这一步的消息与前两步不同——污点是调度器能准确说出原因的少数几个地方之一。节点上的污点可以用 kubectl get node lab-node-0 -o jsonpath='{.spec.taints}' 查看。
根本没有候选节点的 Pod
在 /root/gputri/cases/case-label.yaml 中写出 Pod case-label——把 nodeSelector 设为 nvidia.com/gpu.product: NVIDIA-A100-SXM4-40GB(没有节点带有这个标签),容忍度和 nvidia.com/gpu: 1 请求保持不变。应用后,在 /root/gputri/out/05-label.txt 中写两行——MATCHING_NODES= 中写实际带有该标签的节点数,MESSAGE= 中写条件消息。
把 GPU Feature Discovery 添加的标签原样挑出来用的清单,如果集群里没有这个标签,就会变成这样。这个 Pod 的污点和资源都没有可检查的对象——因为候选节点是 0 个。所以在判定顺序中,nodeSelector 排在最前面。带有该标签的节点数可以用 kubectl get nodes -l <키> --no-headers | wc -l(占位符为标签键)来数。
上报完好,却没有空位
在 /root/gputri/cases/case-exhaust.yaml 中写出 Pod case-exhaust——固定到 lab-node-0,加入容忍度,并要求 nvidia.com/gpu: 2。该节点上报了 2 块,但第 1 步的 case-ok 已经占住了一块。应用后,在 /root/gputri/out/06-exhaust.txt 中写三行——ALLOCATABLE=(该节点上报的数量)、ALLOCATED=(放置在该节点上的 Pod 的 GPU 请求之和)、REQUESTED=2。
唯独这个判定,光靠一个节点对象是完不成的。必须把放置在该节点上的 Pod 收集起来,把请求加在一起——kubectl get pods -A --field-selector spec.nodeName=lab-node-0 -o json 是出发点。已结束的 Pod(Succeeded、Failed)并不占着空位,所以必须从合计中去掉。消息与第 2、3 步又相同——这是第三次看到同一条语句。
完全不会创建 Pod 的两种情况
先在 /root/gputri/cases/case-norequest.yaml 中写出 Pod case-norequest——固定到 lab-node-0,加入容忍度,但完全不写资源请求。 应用后它会启动。接着在 /root/gputri/cases/case-rtc.yaml 中写出带有 runtimeClassName: nvidia 的 Pod case-rtc 并应用,把包含标准错误的输出保存到 /root/gputri/out/07-runtimeclass.txt(不要创建 RuntimeClass)。最后创建命名空间 gpu-quota,在 /root/gputri/cases/quota.yaml 中写出 ResourceQuota gpu-quota,把 requests.nvidia.com/gpu 限制为 "1",然后应用 /root/gputri/cases/case-quota.yaml 中的 Pod case-quota(要求 3 块 GPU),把输出保存到 /root/gputri/out/07-quota.txt。最后在 /root/gputri/out/07-symptoms.txt 中写三行——NOREQUEST=、RUNTIMECLASS=、QUOTA=。后两行中,用 created 或 absent 来写 Pod 对象是否已经生成。
这一步的三种情况,与前五种的症状分支不同。第一种是 Pod 好好地启动了,但设备挂不上;后两种则是 Pod 对象根本不会生成。在准入时被拒绝的话,集群里没有任何痕迹,错误只会留在应用者的终端上——所以用 2>&1 把输出存进文件的习惯,对调查具有决定性作用。Pod 不存在,可以用 kubectl get pod <이름> -n <ns>(占位符依次为 Pod 名称与命名空间)的退出码来确认。
只看对象就能用一个词回答原因的分类器
创建 /root/gputri/bin/triage.sh。用 bash triage.sh <네임스페이스> <파드>(占位符依次为命名空间与 Pod)调用时,只在标准输出中输出一个词就结束。答案有七种——no-request ok label-mismatch taint no-gpu-node allocatable-zero exhausted。如果 Pod 不存在,就在标准错误中输出提示,并以 1 结束。判定只依据 kubectl get -o json 给出的对象,顺序是 没有请求 → 已经放置 → nodeSelector → 污点 → capacity → allocatable → 剩余空位。创建之后,把它接到七个 Pod(case-ok case-nogpunode case-alloc0 case-taint case-label case-exhaust case-norequest)上全部运行一遍,并在 /root/gputri/out/triage.txt 中按 <파드이름> <원인>(占位符依次为 Pod 名称与原因)的形式写七行。
不能按 Pod 名称来决定答案——被接到第一次见到的 Pod 上时也能答对,才有用处。需要的输入有三种:这个 Pod、全部节点,以及所有命名空间的 Pod(用于计算空位)。判定容忍度时,必须同时处理 operator: Exists 和 Equal 两种情况,而且 effect 为空时,会容忍所有 effect。扩展资源只看 limits 就可以——这是因为有“requests 与 limits 必须相同”这条规则。不要费力用 shell 去解析 JSON,交给 python3 会短得多。