标签一缺,什么都不会发生,也什么都不会报错
目标
按来源搭建构成 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 先读标签,第二个动作是随身带一个检查标签规范的小工具。本实验就来制作这两样东西。
步骤
- 在
/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。 - 请尝试把设备名称
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。 - 创建命名空间
nfd-lab,并在/root/gpunfd/k8s/a100-job.yaml中写出 Poda100-job。容器名称为trainer,镜像为nvcr.io/nvidia/pytorch:24.07-py3。只用一个spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution,让它只选出nvidia.com/gpu.product为NVIDIA-A100-SXM4-40GB的节点。不要使用nodeSelector或nodeName。 应用之后,请确认 Pod 去了哪个节点。 - 在
/root/gpunfd/k8s/any-gpu-job.yaml中写出 Podany-gpu-job(容器为trainer,镜像相同)。条件是在一个 term 中放两个 matchExpressions——nvidia.com/gpu.present为Exists,同时nvidia.com/gpu.product不是(NotIn)NVIDIA-A10的节点。应用之后,请确认 Pod 去了 lab-node-0。请注意,Exists不要写values。 - 再创建两个 Pod。
/root/gpunfd/k8s/gt-job.yaml中的gt-job是一个 term 加一个条件——nvidia.com/gpu.count为Gt3的节点。/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 名称与节点名称)的形式写两行。 - 在
/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。结束后,标签必须恢复为原来的值。 - 给 lab-node-1 添加
feature.node.kubernetes.io/custom-gpu-training=true(当作是 NFD 的自定义规则添加的)。在/root/gpunfd/k8s/train-a.yaml中写出 Podtrain-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。 - 创建
/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。不要把节点列表写在脚本里——评分器会再多创建一个节点后再调用它。
参考
- 从
export KUBECONFIG=/root/.kube/config开始。这是 kwok 启动的真实 kube-apiserver,节点是 lab-node-0/1/2 三个。所有产出物都放在/root/gpunfd下。 - 本环境中没有 GPU,也没有 NFD 和 GFD。所以“发现”由你在第 1 步中搭建。不过标签值的校验、nodeAffinity、调度器的判定和节点添加,全部由真实的 API 服务器完成。
- Pod 并不会真正运行,而是被伪造为 Ready。本实验要查看的不是容器内部,而是
.spec.nodeName、.status.phase、.status.conditions这样的 API 对象。 - 常见错误:在
Exists、DoesNotExist中写了values。这是不看值的运算符,所以 API 服务器会拒绝。 - 常见错误:只用
NotIn来选择 GPU 节点。根本没有该键的节点也会通过NotIn。 - 常见错误:把节点名称写在检查器里。节点一增加,这个工具从当天起就开始说谎。
- Assigning Pods to Nodes · Labels and Selectors · Node Feature Discovery: Feature labels
搭建发现结果——三套标签的来源各不相同
在 /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。