GPU事故の再現 — 設定のマージからロールアウト停止まで
目標
GPUクラスターを一度に使えなくした障害を、8つのステップで、もう一度たどります。containerdの設定が合成される場所、読み込まれたものを確認する習慣、リソースのアドバタイズとランタイムハンドラーという2つのゲート、タイムスライシングの算術、そしてロールアウトが止まったクラスターの様子までを、手で作ります。
なぜ重要なのか
GPU運用で人を捕まえるのは、ドライバーではなく、設定が反映されたと信じた瞬間です。ドロップインファイルにnvidiaランタイムが問題なく書かれていても、読み込まれたランタイムはrunc1つだけかもしれず、toolkitのPodがReadyでも、containerdを再起動していなければ、ハンドラーは登録されておらず、すでに動いているPodは、これらすべてが壊れても何も言いません。故障が潜伏して、ノードの再起動のときに一気に表面化する理由が、ここにあります。そのため、このラボは「何を書いたか」ではなく、「何が実際に読み込まれ、何が実際にスケジュールされたか」だけを見るようにします。その習慣1つで、何時間もかかる原因の特定が、3秒に縮まります。
この環境には、本物のGPUもcontainerdもGPU Operatorもありません。ステップ1–4は設定ファイルとTOMLパーサーで扱い、ステップ5–8は、kwokが起動した本物のコントロールプレーンと、偽のノード3台(lab-node-0/1/2)の上で扱います。スケジューリング・リソースのアドバタイズ・DaemonSetのロールアウトは、本物のコントローラーが処理するのでそのまま再現されますが、コンテナは実際には実行されず、ノードに接続されたGPUは、ノードオブジェクトの数字にすぎません。
ステップ
/root/gpuop/etc/containerd/config.tomlを作成してください。最上位にversion = 2とimports配列があり、importsの項目の1つはconf.d/*.tomlで終わっている必要があります。plugins."io.containerd.grpc.v1.cri".containerdの下にdefault_runtime_name = "runc"を置き、plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runcテーブルに、runtime_type = "io.containerd.runc.v2"とoptions.SystemdCgroup = trueを入れてください。/root/gpuop/etc/containerd/conf.d/99-nvidia.tomlを作成してください。plugins."io.containerd.grpc.v1.cri".containerd.runtimes.nvidiaテーブルにruntime_type = "io.containerd.runc.v2"を、その下のoptionsにBinaryName = "/usr/bin/nvidia-container-runtime"とSystemdCgroup = trueを入れてください。- 2つのファイルを
tomllibで読んで判定した結果を、/root/gpuop/out/merge.txtに5行で書いてください。MAIN_RUNTIMES=はメイン設定が直接定義したランタイム名、DROPIN_RUNTIMES=はドロップインが定義した名前、CONFLICT=は両方が同じテーブルを定義していればyes、CONFLICT_TABLE=はぶつかるテーブルのパス(plugins."io.containerd.grpc.v1.cri".containerd.runtimes)、そして最後がLOADED_RUNTIMES=です。最後の行だけは、計算値ではなく観測値です。障害当日にそのノードでcontainerd config dumpで確認した、読み込まれたランタイムハンドラーは、runc1つだけでした。 /root/gpuop/bin/check-runtime.shを作成してください。containerd config dumpを絶対パスなしで呼び出して、その出力からcontainerd.runtimes.nvidiaを探し、あれば0、なければ1で終わる必要があります。/etc/containerd/config.tomlのパスを読んではいけません。採点ツールがそれを不正解として検出します。/root/gpuop/k8s/runtimeclass.yamlに、名前とhandlerがどちらもnvidiaのRuntimeClassを書き、/root/gpuop/k8s/gpu-pod.yamlに、ネームスペースgpu-lab、名前cuda-probe、runtimeClassName: nvidia、コンテナのリソースlimitsにnvidia.com/gpu: 1を持つPodを書いてください。ネームスペースを先に作成して、両方を適用してください。Podは起動しません。PodScheduled条件のreasonとmessageを、2行で/root/gpuop/out/unscheduled.txtに保存してください。kubectl patch node lab-node-0 --subresource=statusで、capacityとallocatableの両方にnvidia.com/gpuを"4"として入れてください。PodがRunningになったら、3台のノードの名前とアドバタイズ量を、/root/gpuop/out/node-gpu.txtに2列の表として保存してください。/root/gpuop/k8s/time-slicing.yamlに、ネームスペースgpu-operator、名前time-slicing-configのConfigMapを書いてください。data.anyはYAMLの文字列で、その中にsharing.timeSlicing.failRequestsGreaterThanOne: trueと、resources配列の最初の項目がname: nvidia.com/gpu、replicas: 5である必要があります。適用したあと、lab-node-1のアドバタイズ量を"20"に上げて、/root/gpuop/out/slots.txtに7行を書いてください。PHYSICAL_GPUS、REPLICAS、ADVERTISED、VRAM_PER_GPU_GIB、VRAM_GUARANTEED_PER_SLOT_GIB、VRAM_SHARED_PER_GPU_GIB、ISOLATIONです。機材は40GiBのA100が4枚という前提です。/root/gpuop/k8s/toolkit-ds.yamlに、ネームスペースgpu-operator、名前nvidia-container-toolkit、updateStrategy.rollingUpdate.maxUnavailable: 1、イメージnvcr.io/nvidia/k8s/container-toolkit:v1.16.2、メモリ要求128MiのDaemonSetを書いて、適用してください。3つのPodがすべてReadyになったら、イメージをv1.17.0に上げると同時に、メモリ要求を64Giに変更してください。ロールアウトが止まったら、/root/gpuop/out/rollout.txtに6行を書いてください。DESIRED、UPDATED、READY、OLD_IMAGE_PODS、MAX_UNAVAILABLE、そしてBLOCKER=insufficient-memoryです。
参考
- ステップ4の採点は、静的な検査では終わりません。偽の
containerdをPATHの先頭に立てておいて、あなたのスクリプトを実際に2回実行します。nvidiaがないdumpを渡したときに失敗で、あるdumpを渡したときに成功で終わっていれば、通過です。 kubectl patch --subresource=statusは、kubectl 1.24以上で動作します。ノードの容量は、3台ともCPU 8、メモリ32Giです。- よくある間違い1: TOMLのテーブル名を、
[plugins.io.containerd.grpc.v1.cri...]のように引用符なしで書くことです。ドットが入ったキーは、二重引用符で囲まないと1つの塊として読まれず、パーサーはまったく別の階層を作ります。 - よくある間違い2: ステップ7で
data.anyをYAMLのマッピングで書くことです。ConfigMapのdataの値は文字列でなければならないので、|-のブロックスカラーで入れる必要があります。 - よくある間違い3: ステップ8で、3つのPodがReadyになる前に、新しいスペックを反映することです。最初から準備ができていない状態で反映すると、何が原因で止まったのかを区別できません。
障害が起きたノードのメインのcontainerd設定を再現する
/root/gpuop/etc/containerd/config.tomlを作成してください。最上位にversion = 2とimports配列があり、importsの項目の1つはconf.d/*.tomlで終わっている必要があります。plugins."io.containerd.grpc.v1.cri".containerdの下にdefault_runtime_name = "runc"を置き、plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runcテーブルに、runtime_type = "io.containerd.runc.v2"とoptions.SystemdCgroup = trueを入れてください。
TOMLは、インデントではなくテーブル名で階層を作ります。ドットが入ったキーは、二重引用符で囲まないと1つの塊として読まれません。書き終えたあとは、目で見るのではなく、パーサーで一度読んで確認してください。
toolkitが落とすドロップインを再現する
/root/gpuop/etc/containerd/conf.d/99-nvidia.tomlを作成してください。plugins."io.containerd.grpc.v1.cri".containerd.runtimes.nvidiaテーブルにruntime_type = "io.containerd.runc.v2"を、その下のoptionsにBinaryName = "/usr/bin/nvidia-container-runtime"とSystemdCgroup = trueを入れてください。
ドロップインは、メイン設定と同じテーブルパスの下に、自分のランタイムをもう1つ宣言します。核心は、オプションのバイナリ名です。その外側のラッパーが、コンテナを作る直前に、デバイスとライブラリを差し込みます。cgroupドライバーは、メイン設定側とずれてはいけません。
2つのファイルが同じテーブルでぶつかるかを判定する
2つのファイルをtomllibで読んで判定した結果を、/root/gpuop/out/merge.txtに5行で書いてください。MAIN_RUNTIMES=はメイン設定が直接定義したランタイム名、DROPIN_RUNTIMES=はドロップインが定義した名前、CONFLICT=は両方が同じテーブルを定義していればyes、CONFLICT_TABLE=はぶつかるテーブルのパス(plugins."io.containerd.grpc.v1.cri".containerd.runtimes)、そして最後がLOADED_RUNTIMES=です。最後の行だけは、計算値ではなく観測値です。障害当日にそのノードでcontainerd config dumpで確認した、読み込まれたランタイムハンドラーは、runc1つだけでした。
判定は、手ではなくパーサーにやらせます。メイン設定のインポート一覧を読んでグロブを展開し、両方から、ランタイムのテーブルに定義された名前をそれぞれ集めてください。最後の行だけは、計算ではなく障害当日の観測値で、その値は案内文に書かれています。
読み込まれた設定を見る点検スクリプトを作る
/root/gpuop/bin/check-runtime.shを作成してください。containerd config dumpを絶対パスなしで呼び出して、その出力からcontainerd.runtimes.nvidiaを探し、あれば0、なければ1で終わる必要があります。/etc/containerd/config.tomlのパスを読んではいけません。採点ツールがそれを不正解として検出します。
判定の基準は、ファイルではなく、実行中のデーモンが持っている設定です。コマンドは、絶対パスなしで呼んでください。採点ツールが、偽のデーモンを先頭に立てておいて、あなたのスクリプトを実際に2回実行します。探しているものがないときに、0以外の値で終わって初めて、点検が点検の役目を果たします。
RuntimeClassとGPU Pod、そしてPendingを確認する
/root/gpuop/k8s/runtimeclass.yamlに、名前とhandlerがどちらもnvidiaのRuntimeClassを書き、/root/gpuop/k8s/gpu-pod.yamlに、ネームスペースgpu-lab、名前cuda-probe、runtimeClassName: nvidia、コンテナのリソースlimitsにnvidia.com/gpu: 1を持つPodを書いてください。ネームスペースを先に作成して、両方を適用してください。Podは起動しません。PodScheduled条件のreasonとmessageを、2行で/root/gpuop/out/unscheduled.txtに保存してください。
RuntimeClassはクラスタースコープなのでネームスペースがなく、名前とハンドラーは、それぞれ別のフィールドです。Podは起動しないのが正常です。スケジュール条件に書かれた理由を、ファイルに残しておいてください。条件が埋まるまでに数秒かかるので、決まった時間だけ眠るのではなく、条件を確認しながら待ってください。
device pluginの役割を、ノードのstatusで再現する
kubectl patch node lab-node-0 --subresource=statusで、capacityとallocatableの両方にnvidia.com/gpuを"4"として入れてください。PodがRunningになったら、3台のノードの名前とアドバタイズ量を、/root/gpuop/out/node-gpu.txtに2列の表として保存してください。
拡張リソースはノードオブジェクトのstatusの下にあり、statusは別のサブリソースなので、普通にpatchしても反映されません。capacityとallocatableの2か所を一緒に埋める必要があり、値は文字列で書きます。枠ができると、待機していたPodを、スケジューラーがもう一度拾います。
タイムスライシングの設定を読み、スロットとメモリを計算する
/root/gpuop/k8s/time-slicing.yamlに、ネームスペースgpu-operator、名前time-slicing-configのConfigMapを書いてください。data.anyはYAMLの文字列で、その中にsharing.timeSlicing.failRequestsGreaterThanOne: trueと、resources配列の最初の項目がname: nvidia.com/gpu、replicas: 5である必要があります。適用したあと、lab-node-1のアドバタイズ量を"20"に上げて、/root/gpuop/out/slots.txtに7行を書いてください。PHYSICAL_GPUS、REPLICAS、ADVERTISED、VRAM_PER_GPU_GIB、VRAM_GUARANTEED_PER_SLOT_GIB、VRAM_SHARED_PER_GPU_GIB、ISOLATIONです。機材は40GiBのA100が4枚という前提です。
ConfigMapのdataの値は必ず文字列なので、設定をマッピングで書くと、適用が拒否されます。ブロックスカラーで入れてください。計算での落とし穴は、メモリの行です。枠が5倍になったからといって、メモリが5つに分かれるわけではありません。
ロールアウトを止めて、分かれた状態を報告する
/root/gpuop/k8s/toolkit-ds.yamlに、ネームスペースgpu-operator、名前nvidia-container-toolkit、updateStrategy.rollingUpdate.maxUnavailable: 1、イメージnvcr.io/nvidia/k8s/container-toolkit:v1.16.2、メモリ要求128MiのDaemonSetを書いて、適用してください。3つのPodがすべてReadyになったら、イメージをv1.17.0に上げると同時に、メモリ要求を64Giに変更してください。ロールアウトが止まったら、/root/gpuop/out/rollout.txtに6行を書いてください。DESIRED、UPDATED、READY、OLD_IMAGE_PODS、MAX_UNAVAILABLE、そしてBLOCKER=insufficient-memoryです。
まず、3台のノードがすべて準備できるまで待ってから、新しいスペックを反映してください。そうしないと、何が止まったのかを区別できません。ノードの容量は32Giなので、それより大きなメモリを要求すると、どのノードもそのPodを受け取れません。レポートの数字は、DaemonSetのstatusからそのまま読み取って書いてください。