TT Lab
开始
学习 学习路径 课程

GPU Operator 与时间片

标签一缺,什么都不会发生,也什么都不会报错

在 TT Lab 中继续学习

目标

按来源搭建构成 GPU 节点的三套标签(NFD、GFD、Operator),在被 API 服务器亲自拒绝的过程中学习标签值规范,用 In、NotIn、Exists、Gt 和 term 列表决定 Pod 的位置,并亲手制作把发现结果恢复原样的同步器和标签规范检查器。

为什么重要

GPU Operator 并不直接去看节点上插着的显卡。它看的是标签。 nfd-worker 找到 PCI 厂商 ID 0x10de 后添加 feature.node.kubernetes.io/pci-10de.present=true,Operator 只在带有这个标签的节点上放置 operand,gpu-feature-discovery 再把型号、数量、显存和驱动版本添加为标签,从那时起,人和调度器就按这些标签来选择位置。这条链路可怕的地方在于,即使断了也不会报错。 没有标签,operand 就不会启动;operand 不启动,资源就不会上报;没有资源上报,Pod 就只是 Pending。任何地方都不会写“因为缺少标签”。所以处理 GPU 集群的人,第一个下意识的动作是用 kubectl get node -o json 先读标签,第二个动作是随身带一个检查标签规范的小工具。本实验就来制作这两样东西。

步骤

  1. 在 /root/gpunfd 中工作(export KUBECONFIG=/root/.kube/config)。给三个节点添加标签,搭建“发现已完成的集群”。给 lab-node-0 添加 feature.node.kubernetes.io/pci-10de.present=true、feature.node.kubernetes.io/kernel-version.major=5、nvidia.com/gpu.present=true、nvidia.com/gpu.product=NVIDIA-A100-SXM4-40GB、nvidia.com/gpu.count=4、nvidia.com/gpu.memory=40537、nvidia.com/cuda.driver.major=550。给 lab-node-1 用相同的键添加 pci-10de.present=true、kernel-version.major=5、gpu.present=true、gpu.product=NVIDIA-A10、gpu.count=2、gpu.memory=22731、cuda.driver.major=535。给 lab-node-2 只添加 feature.node.kubernetes.io/kernel-version.major=5 一个,不要添加 pci-10de.present,也不要添加任何以 nvidia.com/ 开头的标签(这是没有 GPU 的节点)。然后在 /root/gpunfd/out/sources.txt 中写三行——即哪个标签由哪个组件添加。每行写一条:feature.node.kubernetes.io/pci-10de.present=nfd-worker、nvidia.com/gpu.product=gpu-feature-discovery、nvidia.com/gpu.deploy.driver=gpu-operator。
  2. 请尝试把设备名称 NVIDIA A100-SXM4-40GB 原样放进标签值——运行 kubectl patch node lab-node-1 -p '{"metadata":{"labels":{"nvidia.com/gpu.product":"NVIDIA A100-SXM4-40GB"}}}',把它的输出连同标准错误一起保存到 /root/gpunfd/out/reject.txt(应当被拒绝才正常,标签不会改变)。然后创建 /root/gpunfd/sanitize.sh——把作为参数收到的字符串整理成可用作标签值的形式,并输出一行。规则有三条。(1)把不是字母数字及 - _ . 的字符全部替换为 -;(2)超过 63 个字符就截成 63 个字符;(3)如果两端不是字母数字,就一直去掉,直到出现字母数字为止。创建之后,请确认 bash /root/gpunfd/sanitize.sh "NVIDIA A100-SXM4-40GB" 输出的是 NVIDIA-A100-SXM4-40GB。
  3. 创建命名空间 nfd-lab,并在 /root/gpunfd/k8s/a100-job.yaml 中写出 Pod a100-job。容器名称为 trainer,镜像为 nvcr.io/nvidia/pytorch:24.07-py3。只用一个 spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution,让它只选出 nvidia.com/gpu.product 为 NVIDIA-A100-SXM4-40GB 的节点。不要使用 nodeSelector 或 nodeName。 应用之后,请确认 Pod 去了哪个节点。
  4. 在 /root/gpunfd/k8s/any-gpu-job.yaml 中写出 Pod any-gpu-job(容器为 trainer,镜像相同)。条件是在一个 term 中放两个 matchExpressions——nvidia.com/gpu.present 为 Exists,同时 nvidia.com/gpu.product 不是(NotIn)NVIDIA-A10 的节点。应用之后,请确认 Pod 去了 lab-node-0。请注意,Exists 不要写 values。
  5. 再创建两个 Pod。/root/gpunfd/k8s/gt-job.yaml 中的 gt-job 是一个 term 加一个条件——nvidia.com/gpu.count 为 Gt 3 的节点。/root/gpunfd/k8s/or-job.yaml 中的 or-job 有两个 term——第一个是 gpu.count Gt 3,第二个是 gpu.product In [NVIDIA-A10]。两者的容器都是 trainer,镜像相同。应用之后,请确认 gt-job 只能去 lab-node-0,而 or-job 会去两个 GPU 节点中的某一个,并在 /root/gpunfd/out/placement.txt 中按 <파드이름> <노드이름>(占位符依次为 Pod 名称与节点名称)的形式写两行。
  6. 在 /root/gpunfd/features/ 下为每个节点创建一个文件:lab-node-0.env 和 lab-node-1.env。每行是 <라벨키>=<값>(占位符依次为标签键与值),原样包含第 1 步中添加的 nvidia.com/ 标签(这是 worker 上报的事实)。然后创建 /root/gpunfd/nfd-sync.sh——把每个文件中的行与节点的实际标签作对比,如果不同就恢复成文件中的值,每恢复一个就输出一行 <노드> <키> <값>(占位符依次为节点、键与值)。全部相同时什么也不输出,以 0 结束。创建之后,用 kubectl label node lab-node-1 nvidia.com/gpu.count=9 --overwrite 破坏一个值,再运行 bash /root/gpunfd/nfd-sync.sh,把它的输出保存到 /root/gpunfd/out/sync.txt。结束后,标签必须恢复为原来的值。
  7. 给 lab-node-1 添加 feature.node.kubernetes.io/custom-gpu-training=true(当作是 NFD 的自定义规则添加的)。在 /root/gpunfd/k8s/train-a.yaml 中写出 Pod train-a(容器为 trainer,镜像相同),用 required nodeAffinity 要求该标签为 true 的节点,应用后确认它在 lab-node-1 上启动。接着把这个标签从 lab-node-1 上删除,把内容相同、只把名称改为 train-b 的清单创建为 /root/gpunfd/k8s/train-b.yaml 并应用。最后在 /root/gpunfd/out/ignored.txt 中准确写两行——train-a=Running:lab-node-1 和 train-b=Pending。
  8. 创建 /root/gpunfd/label-audit.sh。遍历集群中的所有节点,对 nvidia.com/gpu.present 为 true 的节点检查四件事——(1)nvidia.com/gpu.product、nvidia.com/gpu.count、nvidia.com/gpu.memory 是否都存在;(2)gpu.count 和 gpu.memory 是否只由数字构成;(3)gpu.product 是否不超过 63 个字符;(4)gpu.product 是否以字母或数字开头,并且只由字母、数字以及 -、_、. 构成。每发现一处不符,就输出一行 <노드> <라벨키> <이유>(占位符依次为节点、标签键与原因),只要有一处就以退出码 1 结束。没有任何问题时,只输出一行 OK,以 0 结束。原因使用 missing、not-a-number、too-long、bad-format 中的一个。创建之后,对当前集群运行它,并把输出保存到 /root/gpunfd/out/audit.txt。不要把节点列表写在脚本里——评分器会再多创建一个节点后再调用它。

参考

搭建发现结果——三套标签的来源各不相同

在 /root/gpunfd 中工作(export KUBECONFIG=/root/.kube/config)。给三个节点添加标签,搭建“发现已完成的集群”。给 lab-node-0 添加 feature.node.kubernetes.io/pci-10de.present=true、feature.node.kubernetes.io/kernel-version.major=5、nvidia.com/gpu.present=true、nvidia.com/gpu.product=NVIDIA-A100-SXM4-40GB、nvidia.com/gpu.count=4、nvidia.com/gpu.memory=40537、nvidia.com/cuda.driver.major=550。给 lab-node-1 用相同的键添加 pci-10de.present=true、kernel-version.major=5、gpu.present=true、gpu.product=NVIDIA-A10、gpu.count=2、gpu.memory=22731、cuda.driver.major=535。给 lab-node-2 只添加 feature.node.kubernetes.io/kernel-version.major=5 一个,不要添加 pci-10de.present,也不要添加任何以 nvidia.com/ 开头的标签(这是没有 GPU 的节点)。然后在 /root/gpunfd/out/sources.txt 中写三行——即哪个标签由哪个组件添加。每行写一条:feature.node.kubernetes.io/pci-10de.present=nfd-worker、nvidia.com/gpu.product=gpu-feature-discovery、nvidia.com/gpu.deploy.driver=gpu-operator。

用 kubectl label node <이름> <키>=<값> --overwrite(占位符依次为节点名称、键与值)可以一次添加多个标签。标签键的前缀就是它的来源——feature.node.kubernetes.io/ 由 Node Feature Discovery 添加,以 nvidia.com/gpu. 开头的发现类标签由 GPU Feature Discovery 添加,nvidia.com/gpu.deploy. 则由 GPU Operator 为了对自己的 operand 做门控而添加。0x10de 是分配给 NVIDIA 的 PCI 厂商 ID。

厂商给出的名称不能直接变成标签

请尝试把设备名称 NVIDIA A100-SXM4-40GB 原样放进标签值——运行 kubectl patch node lab-node-1 -p '{"metadata":{"labels":{"nvidia.com/gpu.product":"NVIDIA A100-SXM4-40GB"}}}',把它的输出连同标准错误一起保存到 /root/gpunfd/out/reject.txt(应当被拒绝才正常,标签不会改变)。然后创建 /root/gpunfd/sanitize.sh——把作为参数收到的字符串整理成可用作标签值的形式,并输出一行。规则有三条。(1)把不是字母数字及 - _ . 的字符全部替换为 -;(2)超过 63 个字符就截成 63 个字符;(3)如果两端不是字母数字,就一直去掉,直到出现字母数字为止。创建之后,请确认 bash /root/gpunfd/sanitize.sh "NVIDIA A100-SXM4-40GB" 输出的是 NVIDIA-A100-SXM4-40GB。

标签值的规范由 Kubernetes 规定——不超过 63 个字符,以字母或数字开头和结尾,中间只能出现 - _ .。所以 gpu-feature-discovery 不会直接使用驱动给出的设备名称,而是整理之后再添加。可以用 sed 's/[^A-Za-z0-9_.-]/-/g' 一次性完成替换,首尾清理则像 sed -E 's/^[^A-Za-z0-9]+//' 那样做两次。要先截断、后清理首尾,这样即使第 63 个字符是连字符,也能遵守规范。

按型号名称选择位置

创建命名空间 nfd-lab,并在 /root/gpunfd/k8s/a100-job.yaml 中写出 Pod a100-job。容器名称为 trainer,镜像为 nvcr.io/nvidia/pytorch:24.07-py3。只用一个 spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution,让它只选出 nvidia.com/gpu.product 为 NVIDIA-A100-SXM4-40GB 的节点。不要使用 nodeSelector 或 nodeName。 应用之后,请确认 Pod 去了哪个节点。

nodeSelector 只看键和值是否完全相同。nodeAffinity 在做同样的事情的同时,还可以使用 In、NotIn、Exists、DoesNotExist、Gt、Lt,所以可以写出“这几个型号之一”这样的条件。requiredDuringScheduling 是调度时刻的条件,后面的 IgnoredDuringExecution 表示不会再对已经启动的 Pod 重新应用。Pod 去了哪里,可以用 -o wide 或 .spec.nodeName 查看。

同一个 term 中的条件必须全部为真

在 /root/gpunfd/k8s/any-gpu-job.yaml 中写出 Pod any-gpu-job(容器为 trainer,镜像相同)。条件是在一个 term 中放两个 matchExpressions——nvidia.com/gpu.present 为 Exists,同时 nvidia.com/gpu.product 不是(NotIn)NVIDIA-A10 的节点。应用之后,请确认 Pod 去了 lab-node-0。请注意,Exists 不要写 values。

一个 nodeSelectorTerms 项(term)中的 matchExpressions 必须全部满足(AND)。所以像“在有 GPU 的节点中,只排除这个型号”这样的条件,会写成一个 term。Exists 和 DoesNotExist 不看值,所以如果写了 values,API 服务器会拒绝。NotIn 的陷阱在于,根本没有该键的节点也会放行——所以要配合 Exists,把范围缩小到 GPU 节点。

term 列表是 OR,数字标签可以比较大小

再创建两个 Pod。/root/gpunfd/k8s/gt-job.yaml 中的 gt-job 是一个 term 加一个条件——nvidia.com/gpu.count 为 Gt 3 的节点。/root/gpunfd/k8s/or-job.yaml 中的 or-job 有两个 term——第一个是 gpu.count Gt 3,第二个是 gpu.product In [NVIDIA-A10]。两者的容器都是 trainer,镜像相同。应用之后,请确认 gt-job 只能去 lab-node-0,而 or-job 会去两个 GPU 节点中的某一个,并在 /root/gpunfd/out/placement.txt 中按 <파드이름> <노드이름>(占位符依次为 Pod 名称与节点名称)的形式写两行。

nodeSelectorTerms 是列表,各项之间是 OR——只要有一项满足,这个节点就成为候选。Gt 和 Lt 只有在值能被读成整数时才能使用,并且 values 中只写一个值。标签值是字符串,但只有这两个运算符会把它解释成整数,这一点常常被忘记。通过 OR 出现两个候选节点时,去哪一个由调度器的评分决定——所以“二者之一”才是正常的。

手工修改的标签为什么会恢复原样

在 /root/gpunfd/features/ 下为每个节点创建一个文件:lab-node-0.env 和 lab-node-1.env。每行是 <라벨키>=<값>(占位符依次为标签键与值),原样包含第 1 步中添加的 nvidia.com/ 标签(这是 worker 上报的事实)。然后创建 /root/gpunfd/nfd-sync.sh——把每个文件中的行与节点的实际标签作对比,如果不同就恢复成文件中的值,每恢复一个就输出一行 <노드> <키> <값>(占位符依次为节点、键与值)。全部相同时什么也不输出,以 0 结束。创建之后,用 kubectl label node lab-node-1 nvidia.com/gpu.count=9 --overwrite 破坏一个值,再运行 bash /root/gpunfd/nfd-sync.sh,把它的输出保存到 /root/gpunfd/out/sync.txt。结束后,标签必须恢复为原来的值。

NFD 是它自己所添加标签的主人,worker 会定期上报,master 会让节点与这份上报保持一致。所以人手工修改的值会在下一个周期悄悄消失——不了解原因的话,就会去追查“标签总是恢复”这个幽灵。读取标签时,jq 的 // 在值为 false 时也会换成默认值,所以要用 if . == null 来区分才安全。恢复时用 kubectl label --overwrite。第二次运行时没有任何输出,才算幂等。

即使删除标签,已经启动的 Pod 也不会被赶走

给 lab-node-1 添加 feature.node.kubernetes.io/custom-gpu-training=true(当作是 NFD 的自定义规则添加的)。在 /root/gpunfd/k8s/train-a.yaml 中写出 Pod train-a(容器为 trainer,镜像相同),用 required nodeAffinity 要求该标签为 true 的节点,应用后确认它在 lab-node-1 上启动。接着把这个标签从 lab-node-1 上删除,把内容相同、只把名称改为 train-b 的清单创建为 /root/gpunfd/k8s/train-b.yaml 并应用。最后在 /root/gpunfd/out/ignored.txt 中准确写两行——train-a=Running:lab-node-1 和 train-b=Pending。

条件的名称已经说明了答案。requiredDuringSchedulingIgnoredDuringExecution 只在调度时提出要求,运行期间即使条件不再成立也什么都不做。所以即使误删了标签,已有的 Pod 依然完好,从下一次新启动的 Pod 开始,才会悄悄变成 Pending——这就是事故要过几个小时才暴露的原因。删除标签用 kubectl label node <이름> <키>-(占位符依次为节点名称与键)。Pending 的原因写在 .status.conditions 的 PodScheduled 消息中。

制作检查是否遵守规范的工具

创建 /root/gpunfd/label-audit.sh。遍历集群中的所有节点,对 nvidia.com/gpu.present 为 true 的节点检查四件事——(1)nvidia.com/gpu.product、nvidia.com/gpu.count、nvidia.com/gpu.memory 是否都存在;(2)gpu.count 和 gpu.memory 是否只由数字构成;(3)gpu.product 是否不超过 63 个字符;(4)gpu.product 是否以字母或数字开头,并且只由字母、数字以及 -、_、. 构成。每发现一处不符,就输出一行 <노드> <라벨키> <이유>(占位符依次为节点、标签键与原因),只要有一处就以退出码 1 结束。没有任何问题时,只输出一行 OK,以 0 结束。原因使用 missing、not-a-number、too-long、bad-format 中的一个。创建之后,对当前集群运行它,并把输出保存到 /root/gpunfd/out/audit.txt。不要把节点列表写在脚本里——评分器会再多创建一个节点后再调用它。

节点列表用 kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 随时获取。每个节点只用 kubectl get node <이름> -o json(占位符为节点名称)获取一次,再用 jq 多次取值,就能轻松在 60 秒的预算内完成。“没有标签”和“标签值是空字符串”是两件不同的事,所以不能用 jq 的 // 把它们混为一谈。字符串长度在 shell 中用 ${#변수}(占位符为变量名)来数。退出码要用 exit 明确指定,而不是依赖最后一个 echo。