containerdの設定はどう合わさるのか
一言でいうと
設定ファイルに書いたことと、その設定が読み込まれたことは、別の事象です。この2つの間には、マージの規則、再起動の有無、Pod終了時のクリーンアップのロジック、そしてすでに動いているコンテナという、4つの隙間があり、GPUの障害はほとんどすべてその隙間で起きます。
なぜ必要なのか
実際にあったことです。タイムスライシングを有効にしようとしてClusterPolicyを修正したところ、その影響でtoolkitのDaemonSetが再び動きました。そしてその日から、クラスターのGPU4枚を誰も使えなくなりました。
原因を見つけるのに何時間もかかりましたが、その時間の大半は、間違った場所を正確に確認することに使いました。cat /etc/containerd/conf.d/99-nvidia.tomlを実行すると、nvidiaランタイムが問題なく書かれています。ファイルはあります。そこで「設定はできている」と判断して、他の場所を調べます。ところが、最初にcontainerd config dumpを実行したときの答えは1行でした。読み込まれているランタイムハンドラーは、runcただ1つだけだったのです。
どう動くのか
隙間1: ドロップインが丸ごと無視されることがあります。containerdのメイン設定は、importsで他のファイルを取り込めます。
version = 2
imports = ["/etc/containerd/conf.d/*.toml"]
ここで、ほとんどの人が間違える点があります。このマージはフィールド単位ではありません。ドロップインがあるプラグインの設定に触れると、そのプラグインの設定の全体が、そのファイルの内容に置き換わります。ドロップインが書かなかった項目は、前のファイルの値に戻るのではなく、既定値になります。
ドロップインは名前順に読み込まれるので、結局、そのプラグインに言及した最後のファイルがすべてを持っていきます。
障害が起きたノードで実際に計測した値です(containerd 1.7.27)。
99-nvidia.toml 만 import → runtimes.nvidia 6개
zz-labhub-registry.toml 만 import → runtimes.nvidia 0개
둘 다 import → runtimes.nvidia 0개 ← 여기
このコードブロックの韓国語の部分は、順に、99-nvidia.tomlだけをimportするとnvidiaランタイムが6個、zz-labhub-registry.tomlだけをimportすると0個、両方をimportしても0個になる(ここが問題の箇所)、という意味です。
zz-labhub-registry.tomlは3行のファイルでした。HarborがHTTPなので、証明書のパスを指すために入れたものです。
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
ランタイムの話は一文字もありません。それなのに、CRIプラグインに言及したというだけの理由で、しかも名前が99-より後ろに来るという理由だけで、CRI設定の全体をこの3行で置き換えてしまいました。nvidiaランタイム3つが、そのとき消えました。
なぜ1週間も見えなかったのか
この故障の本当に恐ろしい点はここです。config dumpを開いてみると、こう出ます。
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
sandbox_image = "registry.k8s.io/pause:3.8"
runcがあり、sandbox_imageももっともらしく見えます。設定が生きているように見えます。実際は、すべて既定値です。自分たちが書いた値は1つも残っていないのに、既定値が私たちの書きそうな値と似ているので、気づきにくいのです。
そのため、このdumpを根拠に「ドロップインのマージが効いていないのだ」と、2回間違って診断しました。2回ともそれらしく、2回とも間違っていました。答えをくれたのは推論ではなく、ドロップインを1つずつ外しながらdumpを取り直すことでした。
読んで結論を出さないという原則は、ファイルにだけ当てはまるのではありません。dumpを読むときも、今見ている値が自分が書いた値なのか、既定値なのかを区別する必要があります。その2つを区別する唯一の方法は、1つずつ外してみて、結果が変わるかどうかを見ることです。
隙間2: SIGHUPではランタイムハンドラーが登録されません。toolkitコンテナは、設定を書いたあと、containerdにSIGHUPを送ります。ところが、containerd 1.7のリロードは、プラグインを再初期化しません。CRIプラグインは、ランタイムハンドラーの一覧を初期化時に読み込むので、新しく追加されたハンドラーは、プロセスを再起動する前には一覧に入りません。そのため、「toolkitのPodはReadyで、設定ファイルも更新されているのに、ランタイムはない」という状態が、ごく普通に作られます。
隙間3: toolkitのPodを削除すると、設定が元に戻ります。toolkitコンテナには、終了時のクリーンアップのロジックがあります。自分が入れたnvidiaの設定を取り除いて、元の状態に戻します。設計としては正しいことです。オペレーターを削除したのに、ノードに不要な設定が残ってはいけないからです。問題は、障害対応中の手癖です。「Podを削除して起動し直そう」を先にやって、そのあとでcontainerdを再起動すると、再起動の時点では、設定がすでに外れたあとです。順序が逆になると、何度繰り返しても直りません。
隙間4: すでに動いているコンテナは、何も教えてくれません。ランタイムハンドラーは、コンテナを作るときにしか参照されません。すでに実行中のGPU Podは、設定が消えても問題なく動き続けます。そのため、故障はすぐには見えず、ノードを再起動したりPodが再作成されたりした瞬間に、たいていは数日後、たいていは明け方に、一気に表面化します。この潜伏が、GPU設定の障害をとりわけ高くつくものにしています。
現場での姿
第一に、復旧の順序は決まっています。元に戻す順序は、いつもこうです。① containerd config dumpで、現在読み込まれているハンドラーを確認 → ② toolkitのDaemonSetを正常な状態に戻し、Podに設定を書き直させたあとで → ③ そのあとにcontainerdを再起動 → ④ もう一度config dumpで確認。②と③を入れ替えると、最初からやり直しです。
第二に、ノードの起動後の点検を自動化しておきます。潜伏する故障は、人の記憶では捕まえられません。ノードが起動するたびに、containerd config dumpで必要なハンドラーを確認し、なければ失敗で終わるスクリプト1つで十分です。ファイルを読む点検では、この障害を絶対に見つけられないというのが核心です。
第三に、設定の所有者を文書に書いておきます。ノードのcontainerd設定を変更する主体が複数あると(OSイメージのビルド、構成管理ツール、GPU Operator)、いつか互いに上書きし合います。誰がどのファイルを所有するかを1行で決めておくことが、この障害を根本から減らす唯一の方法です。
次のラボですること
すぐ後のラボで、これらの隙間を手で作ってみます。検証されないRuntimeClassのhandlerでもPodがスケジュールされるのを確認し、importsのグロブがずれてドロップインが丸ごと無視される状況と、設定バージョンが3に上がってプラグイン名が変わる状況をそれぞれ作ったあと、クラスターが要求するハンドラーと、読み込まれているハンドラーを照合するチェッカーを自分で作ります。ファイルではなく、読み込まれたものを見る習慣が、そのスクリプト1枚に詰まっています。