ドライバー版を合わせる — なぜタグの中にカーネル版が入っているのか
一言でいうと
コンテナとしてデプロイされるGPUドライバーが実際に行うことは、ホストカーネルにモジュールを読み込むことなので、カーネルが変わればドライバーも再ビルドが必要になり(そのため、事前コンパイル済みイメージのタグにカーネルバージョンが入っています)、ドライバーを載せ替えるには、そのノードのGPUワークロードを先に空にする必要があります(そのため、PodDisruptionBudgetがアップグレードを止めます)。
なぜ問題になるのか
通常のコンテナは、分離されたユーザー空間で動きます。ホストに何がインストールされていても、イメージの中のライブラリを使うので、「コンテナにすればホストと無関係になる」という直感が成り立ちます。
ドライバーはそうではありません。NVIDIAのドキュメントは、ドライバーのアップグレードに特別な配慮が必要な理由を、こう書いています。ドライバーのカーネルモジュールは、ドライバーコンテナが再起動されるたびに、いったん下ろされてから、再び読み込まれる必要があります。そしてその手順は、5つあります。ドライバーを使っているすべてのクライアントを止め、現在のカーネルモジュールを下ろし、新しいドライバーのPodを起動し、新しいモジュールを読み込み、クライアントを再び起動します。
ここから、2つの性質が出てきます。1つ目は、ドライバーはホストカーネルに依存することです。カーネルモジュールは、カーネルのバージョンに合わせてコンパイルされる必要があるので、カーネルが1つ上がれば、そのカーネル用のドライバーが別に必要になります。2つ目は、ドライバーの入れ替えは、そのノードのGPUワークロードに必ず影響することです。モジュールを下ろすには、それを使っているプロセスがない必要があるからです。
どう動くのか: イメージタグが契約です
既定の方式のドライバーコンテナは、ノードでカーネルヘッダーとコンパイラーを取得して、起動時にモジュールをビルドします。インターネットが必要で、時間がかかり、CPUを消費します。そのためNVIDIAは、事前コンパイル済みのドライバーコンテナを別に提供しています。ドキュメントは、その利点を、インターネットアクセスが制限された場所と、リソースに余裕のない場所で、特に価値があると書いています。
事前コンパイル済みイメージのタグは、このような形です。
<driver-branch>-<linux-kernel-version>-<os-tag>
예: 525-5.15.0-69-generic-ubuntu22.04
3つの部品が1つのタグに埋め込まれていることが、この方式の契約です。ドライバーブランチが同じでもカーネルバージョンが違えば別のイメージで、カーネルバージョンが同じでもOSが違えばまた別のイメージです。そのため、運用では、次のことがすべて「イメージの作業」になります。
- ノードを1台増やしたら、新しいイメージでインストールされてカーネルが先に進んでいる → その組み合わせのイメージが必要です
- セキュリティパッチでカーネルが1つ上がった → その組み合わせのイメージが必要です
- ノードプールを22.04から24.04に移す → その組み合わせのイメージが必要です
なければ、どう表面化するでしょうか。そのノードでだけ、ドライバーのPodがイメージを取得できずに落ちます。他のノードは問題ないので、クラスターレベルのアラートは鳴らず、そのノードのGPUだけが静かに外れます。ドキュメントは、サポートされる組み合わせが表で決まっていることと、一覧にないカーネルの派生版を使う場合は、自分でイメージをビルドして自分のレジストリに載せる必要があることを、はっきり書いています。
そのため、成熟したチームは、「自分たちが持っているドライバーイメージの一覧」を台帳として持ち、ノードの現在の組み合わせと定期的に照合します。カーネルアップグレードの計画が決まったら、その照合を先に実行して、欠けている組み合わせを事前にビルドします。この順序を守らないと、ノードを空にしたまま、イメージを待つことになります。
CUDAの互換は一方向だけ
ドライバーとCUDAランタイムの間にも、バージョンの問題があり、向きが片方にだけ開いています。古いCUDAコンテナは、新しいドライバーの上でうまく動きます。逆はだめです。ドライバーは、自分より後に出たCUDAランタイムを知らないからです。
実務でこれが意味することは単純です。新しいフレームワークのイメージを使い始めるときは、クラスターで最も低いドライバーバージョンが、そのイメージを支えられるかをまず見ます。そして、その要件を人の記憶ではなくPodのスペックに書いておきます。gpu-feature-discoveryが付けてくれるnvidia.com/cuda.driver.majorラベルにnodeAffinityの条件をかければ、合わないノードに行って実行中に失敗する代わりに、スケジュール段階で止まります。失敗を前倒しすることが、この条件の価値です。
アップグレードが止まる場所
ドライバーを載せ替えるには、そのノードのGPUワークロードを下ろす必要があり、下ろす操作はeviction APIを通ります。そしてeviction APIの前には、PodDisruptionBudgetがあります。現在生きている数がバジェットを下回ると、APIはリクエストを拒否します。
error when evicting pods/"trainer-..." (will retry after 5s):
Cannot evict pod as it would violate the pod's disruption budget.
kubectl drainは、ノードを先にcordonしてからPodを1つずつevictするので、止まってもノードはすでにスケジュール不可の状態です。ここで気づかないと、そのノードは新しいPodを受け付けないまま、ドライバーも載せ替えられない中途半端な状態で残ります。--timeoutなしで実行したdrainは、永遠に再試行を続けるので、画面を見るだけだと「遅い」と見えます。
GPU Operatorのアップグレードコントローラーは、この手順を自動化しながら、進行状況をノードのラベルnvidia.com/gpu-driver-upgrade-stateに書きます。ドキュメントが定義した状態は、次のとおりです。
| 状態 | 意味 |
|---|---|
upgrade-required |
ドライバーのPodが最新ではありません |
cordon-required |
ノードをスケジュール不可にする番です |
wait-for-jobs-required |
指定したジョブが終わるのを待ちます |
pod-deletion-required |
GPUを割り当てられたPodを削除します |
drain-required |
Podの削除だけでは足りず、ノードをドレインします |
pod-restart-required |
ドライバーのPodを再起動して、新しいバージョンを載せます |
validation-required |
新しいドライバーを検証します |
uncordon-required |
ノードをスケジュール可能な状態に戻します |
upgrade-done |
完了です |
このラベルの価値は、状態の名前そのものではなく、どこで止まったのかが1行でわかることにあります。cordon-requiredで止まったノードと、pod-deletion-requiredで止まったノードは、原因が違います。ノード全体を一度に取り出して見ると、どの段階にどれだけ溜まっているかが、すぐに見えます。
ドキュメントが特に強く警告しているのがdrain.enableです。既定値がオフになっているのには、理由があります。ドレインは、GPUと無関係なワークロードまで、そのノードからすべて追い出します。まずGPU Podの削除の設定を調整し、それでも足りないときだけドレインを有効にして、podSelectorで範囲を絞るようにと書かれています。
現場での姿
第一に、「1台のノードだけGPUが見えない」。カーネルがそのノードだけ上がったか、そのノードだけがあとから入ってきてイメージが違うか。確認は、そのノードのカーネルラベルとドライバーのPodのイメージタグを、並べて見ることで終わります。
第二に、「アップグレードが3時間も1台のノードで止まっている」。ドライバーのログをどれだけ読んでも出てこないのは、原因がドライバーではないからです。PDBがevictionを拒否しているか、コントローラーが待つように指定したジョブが終わっていないか。アップグレード状態のラベルを先に見れば、3秒で終わります。
第三に、事前にインストールされたドライバーです。イメージにドライバーを焼き込んでおくチームでは、オペレーターがドライバーを管理しません。ドキュメントも、ホストに事前インストールされたドライバーのライフサイクルは、オペレーターが管理しないと書いています。楽に見えますが、その代わりバージョン管理がすべて人の担当になるので、どちらがよいかは、チームのイメージパイプラインの成熟度によります。
第四に、この環境の正直な限界です。ラボでは、ドライバーを実際にはインストールしません。GPUもカーネルモジュールもnvidia-smiもありません。そのため、カーネルバージョンとドライバーバージョンはノードのラベルで表現し、アップグレードは、そのラベルを変更することで表します。実際のクラスターでも、スケジューラーとオペレーターが見るのは、結局そのラベルです。一方、ノードの追加、PodDisruptionBudgetの判定、evictionの拒否、drainとuncordonは、本物のコントロールプレーンがそのまま実行します。
参考ドキュメント
- GPUドライバーのアップグレード(アップグレードのステートマシンとポリシー): https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/gpu-driver-upgrades.html
- 事前コンパイル済みドライバーコンテナ(タグの規則と制約): https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/precompiled-drivers.html
- プラットフォームのサポート表: https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/platform-support.html
- ノードを安全にドレインする: https://kubernetes.io/docs/tasks/administer-cluster/safely-drain-node/
- PodDisruptionBudgetを指定する: https://kubernetes.io/docs/tasks/run-application/configure-pdb/
次のラボですること
ノードのラベルを事実の置き場として、ノードが必要とするドライバーイメージのタグを計算するツールと、それを社内のイメージ一覧と照合するチェッカーを作ります。ノードを1台増やして、そのノードだけ組み合わせがないことを、チェッカーが先に教えてくれることを確認し、CUDAの要件をnodeAffinityで書いて、合わない要求がスケジュール段階で止まるのを見ます。そして、PodDisruptionBudgetがドレインを実際に拒否する場面を通過したあと、イメージの確保からuncordonまで、アップグレードの手順を順番に最後まで進めます。