TT Lab
はじめる
学ぶ 学習パス コース

GPU Operatorとタイムスライシング

ラベルが無いと、エラーも出ないまま何も起きない

TT Labで続きを見る

目標

GPUノードを作るラベル3セット(NFD・GFD・オペレーター)を由来別に作り、ラベル値の規約をAPIサーバーに直接拒否されながら学び、In・NotIn・Exists・Gtとtermのリストで、Podの配置先を決め、検出結果を元に戻す同期ツールと、ラベル規約チェッカーを、手で作ります。

なぜ重要なのか

GPU Operatorは、ノードに接続されたカードを直接見ません。ラベルを見ます。nfd-workerがPCIベンダーID 0x10deを見つけてfeature.node.kubernetes.io/pci-10de.present=trueを付け、オペレーターは、そのラベルが付いたノードにだけオペランドを載せ、gpu-feature-discoveryがモデル・枚数・メモリ・ドライバーのバージョンをラベルとしてさらに付けると、そのときから、人とスケジューラーが、そのラベルで配置先を選びます。この連鎖の恐ろしい点は、途切れてもエラーが出ないことです。ラベルがなければオペランドが起動せず、起動しないのでリソースがアドバタイズされず、アドバタイズがないのでPodはただPendingです。どこにも「ラベルがないので」とは書かれません。そのため、GPUクラスターを扱う人の最初の習慣は、kubectl get node -o jsonでラベルから読むことになり、2つ目の習慣は、ラベルの規約を検査する小さなツールを持ち歩くことになります。このラボは、その2つを作ります。

ステップ

  1. /root/gpunfdで作業します(export KUBECONFIG=/root/.kube/config)。3台のノードにラベルを付けて、「検出が終わったクラスター」を作ってください。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に3行を書いてください。どのラベルをどのコンポーネントが付けるかです。feature.node.kubernetes.io/pci-10de.present=nfd-worker、nvidia.com/gpu.product=gpu-feature-discovery、nvidia.com/gpu.deploy.driver=gpu-operatorを、1行ずつ書きます。
  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行で出力します。規則は3つです。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に、a100-jobというPodを書いてください。コンテナ名はtrainer、イメージはnvcr.io/nvidia/pytorch:24.07-py3です。spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution1つで、nvidia.com/gpu.productがNVIDIA-A100-SXM4-40GBのノードだけを選ばせてください。nodeSelectorやnodeNameは使いません。適用したあと、Podがどのノードに行ったかを確認してください。
  4. /root/gpunfd/k8s/any-gpu-job.yamlに、any-gpu-jobというPodを書いてください(コンテナtrainer、同じイメージ)。条件は、1つのterm内にmatchExpressionsが2つです。nvidia.com/gpu.presentがExistsで、同時にnvidia.com/gpu.productがNVIDIA-A10と等しくない(NotIn)ノードです。適用したあと、Podがlab-node-0に行くことを確認してください。Existsにはvaluesを書かない点に注意してください。
  5. Podをさらに2つ作成してください。/root/gpunfd/k8s/gt-job.yamlのgt-jobは、termが1つで、条件も1つです。nvidia.com/gpu.countに対してGtと3を指定したノードです。/root/gpunfd/k8s/or-job.yamlのor-jobは、termが2つです。1つ目はgpu.count Gt 3、2つ目はgpu.product In [NVIDIA-A10]です。どちらもコンテナはtrainerで、イメージは同じです。適用したあと、gt-jobはlab-node-0にしか行けず、or-jobはGPUノード2台のどちらかに行くことを確認し、/root/gpunfd/out/placement.txtに、2行を<파드이름> <노드이름>の形で書いてください(プレースホルダーはPod名とノード名です)。
  6. /root/gpunfd/features/の下に、ノードごとに1つずつファイルlab-node-0.env・lab-node-1.envを作成してください。各行は<라벨키>=<값>で(プレースホルダーはラベルキーと値です)、ステップ1で付けたnvidia.com/ラベルをそのまま入れます(ワーカーが報告した事実です)。そして/root/gpunfd/nfd-sync.shを作成してください。各ファイルの行と実際のノードのラベルを照合して、違っていればファイル側の値に戻し、戻したものごとに<노드> <키> <값>を1行出力します(プレースホルダーはノードとキーと値です)。すべて同じなら、何も出力せずに0で終わります。作成したら、kubectl label node lab-node-1 nvidia.com/gpu.count=9 --overwriteで値を1つ壊し、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にtrain-aというPodを書き(コンテナtrainer、同じイメージ)、そのラベルがtrueのノードをrequired nodeAffinityで要求させたうえで、適用して、lab-node-1で起動することを確認してください。次に、そのラベルをlab-node-1から削除し、同じ内容で名前だけをtrain-bに変えたマニフェストを/root/gpunfd/k8s/train-b.yamlとして作成して、適用してください。最後に、/root/gpunfd/out/ignored.txtに、ちょうど2行を書いてください。train-a=Running:lab-node-1とtrain-b=Pendingです。
  8. /root/gpunfd/label-audit.shを作成してください。クラスターのすべてのノードを回り、nvidia.com/gpu.presentがtrueのノードについて、4つを確認します。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行ずつ出力し(プレースホルダーはノードとラベルキーと理由です)、1つでもあれば終了コード1で終わります。問題がなければOKの1行だけを出力して、0で終わります。理由は、missing・not-a-number・too-long・bad-formatのどれかを書きます。作成したら、今のクラスターに対して実行して、その出力を/root/gpunfd/out/audit.txtに保存してください。ノード一覧をスクリプトの中に書いておかないでください。採点ツールが、ノードをもう1台作ってから呼び出します。

参考

検出結果を作る: 3セットのラベルは由来が異なる

/root/gpunfdで作業します(export KUBECONFIG=/root/.kube/config)。3台のノードにラベルを付けて、「検出が終わったクラスター」を作ってください。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に3行を書いてください。どのラベルをどのコンポーネントが付けるかです。feature.node.kubernetes.io/pci-10de.present=nfd-worker、nvidia.com/gpu.product=gpu-feature-discovery、nvidia.com/gpu.deploy.driver=gpu-operatorを、1行ずつ書きます。

kubectl label node <이름> <키>=<값> --overwriteで、複数を一度に付けられます(プレースホルダーはノード名とキーと値です)。ラベルキーのプレフィックスが、そのまま由来です。feature.node.kubernetes.io/はNode Feature Discoveryが、nvidia.com/gpu.で始まる検出ラベルはGPU Feature Discoveryが、nvidia.com/gpu.deploy.はGPU Operatorが、自分のオペランドをゲーティングするために付けます。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行で出力します。規則は3つです。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]+//'のように2回行えばよいです。切り詰めを先に行い、両端の整理をあとに行うと、63文字目がハイフンの場合でも、規約を守れます。

モデル名で配置先を選ぶ

ネームスペースnfd-labを作成し、/root/gpunfd/k8s/a100-job.yamlに、a100-jobというPodを書いてください。コンテナ名はtrainer、イメージはnvcr.io/nvidia/pytorch:24.07-py3です。spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution1つで、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で見ます。

1つのterm内の条件は、すべて真である必要がある

/root/gpunfd/k8s/any-gpu-job.yamlに、any-gpu-jobというPodを書いてください(コンテナtrainer、同じイメージ)。条件は、1つのterm内にmatchExpressionsが2つです。nvidia.com/gpu.presentがExistsで、同時にnvidia.com/gpu.productがNVIDIA-A10と等しくない(NotIn)ノードです。適用したあと、Podがlab-node-0に行くことを確認してください。Existsにはvaluesを書かない点に注意してください。

1つのnodeSelectorTermsの項目(term)の中のmatchExpressionsは、すべて満たす必要があります(AND)。そのため、「GPUがあるノードの中から、このモデルだけを除く」のような条件が、1つのtermで書かれます。ExistsとDoesNotExistは値を見ないので、valuesを書くとAPIサーバーが拒否します。NotInは、そのキーがそもそもないノードも通すという点が落とし穴です。そのため、Existsを一緒にかけて、GPUノードに絞ります。

termのリストはORで、数字のラベルは大きさで比べられる

Podをさらに2つ作成してください。/root/gpunfd/k8s/gt-job.yamlのgt-jobは、termが1つで、条件も1つです。nvidia.com/gpu.countに対してGtと3を指定したノードです。/root/gpunfd/k8s/or-job.yamlのor-jobは、termが2つです。1つ目はgpu.count Gt 3、2つ目はgpu.product In [NVIDIA-A10]です。どちらもコンテナはtrainerで、イメージは同じです。適用したあと、gt-jobはlab-node-0にしか行けず、or-jobはGPUノード2台のどちらかに行くことを確認し、/root/gpunfd/out/placement.txtに、2行を<파드이름> <노드이름>の形で書いてください(プレースホルダーはPod名とノード名です)。

nodeSelectorTermsはリストで、項目同士はORです。1つだけ合っても、そのノードは候補になります。GtとLtは、値が整数として読めるときだけ使え、valuesには値を1つだけ書きます。ラベルの値は文字列なのに、この2つの演算子だけは整数として解釈するという点が、よく忘れられる部分です。ORで候補が2つになったとき、どちらに行くかはスケジューラーのスコアが決めます。そのため、「どちらか一方」が正常です。

手で変えたラベルは、なぜ元に戻るのか

/root/gpunfd/features/の下に、ノードごとに1つずつファイルlab-node-0.env・lab-node-1.envを作成してください。各行は<라벨키>=<값>で(プレースホルダーはラベルキーと値です)、ステップ1で付けたnvidia.com/ラベルをそのまま入れます(ワーカーが報告した事実です)。そして/root/gpunfd/nfd-sync.shを作成してください。各ファイルの行と実際のノードのラベルを照合して、違っていればファイル側の値に戻し、戻したものごとに<노드> <키> <값>を1行出力します(プレースホルダーはノードとキーと値です)。すべて同じなら、何も出力せずに0で終わります。作成したら、kubectl label node lab-node-1 nvidia.com/gpu.count=9 --overwriteで値を1つ壊し、bash /root/gpunfd/nfd-sync.shを実行して、その出力を/root/gpunfd/out/sync.txtに保存してください。終わったあと、ラベルは元の値に戻っている必要があります。

NFDは、自分が付けたラベルの持ち主なので、ワーカーが定期的に報告し、マスターがその報告とノードを合わせます。そのため、人が手で直した値は、次の周期で静かに消えます。原因を知らないと、「ラベルが何度も戻ってくる」という幽霊を追いかけることになります。ラベルを読むとき、jqの//は、値がfalseのときも既定値に置き換えてしまうので、if . == nullで分けるのが安全です。元に戻すときは、kubectl label --overwriteです。2回目の実行で何も出力されなければ、冪等です。

ラベルを消しても、すでに起動したPodは追い出されない

lab-node-1にfeature.node.kubernetes.io/custom-gpu-training=trueを付けてください(NFDのユーザー定義ルールが付けたことにします)。/root/gpunfd/k8s/train-a.yamlにtrain-aというPodを書き(コンテナtrainer、同じイメージ)、そのラベルがtrueのノードをrequired nodeAffinityで要求させたうえで、適用して、lab-node-1で起動することを確認してください。次に、そのラベルをlab-node-1から削除し、同じ内容で名前だけをtrain-bに変えたマニフェストを/root/gpunfd/k8s/train-b.yamlとして作成して、適用してください。最後に、/root/gpunfd/out/ignored.txtに、ちょうど2行を書いてください。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のノードについて、4つを確認します。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行ずつ出力し(プレースホルダーはノードとラベルキーと理由です)、1つでもあれば終了コード1で終わります。問題がなければOKの1行だけを出力して、0で終わります。理由は、missing・not-a-number・too-long・bad-formatのどれかを書きます。作成したら、今のクラスターに対して実行して、その出力を/root/gpunfd/out/audit.txtに保存してください。ノード一覧をスクリプトの中に書いておかないでください。採点ツールが、ノードをもう1台作ってから呼び出します。

ノード一覧は、kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'でその都度取得します。ノード1台ごとに、kubectl get node <이름> -o jsonを1回だけ取得しておいて(プレースホルダーはノード名です)、jqで何度も取り出せば、60秒のバジェット内に十分収まります。「ラベルがない」と「ラベルの値が空文字列である」は別の事象なので、jqの//でひとまとめにしてはいけません。文字列の長さは、シェルで${#변수}を使って数えます(プレースホルダーは変数名です)。終了コードは、最後のechoではなく、exitで確定させてください。