Skip to content
Published on

3行の設定ファイルが GPU 4枚を1週間殺した — containerd ドロップイン統合の真実

シェア
Authors

はじめに — ファイルはあるのに設定がない

前回の記事で GPU Operator を立ち上げ、設定が config.toml ではなく conf.d/99-nvidia.toml というドロップインファイルに書き込まれることを確認しました。その記事をこう締めくくりました。

この設計は意図的です。ホストがもともと持っていた設定と混ざらないので、Operator を削除するときはドロップインファイルを消すだけでよく、ホストは元の状態に戻ります。

正しい説明です。ただ、条件が一つ抜けていました。ドロップインがそれ一つだけのとき に限ります。

1週間後、GPU ノード4台が全部 GPU を広告しなくなりました。

$ kubectl get nodes -o custom-columns="NODE:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu"
NODE     GPU
nuc1     0
nuc2     0
omen     0
omen2    0

実習用のポッドが起動できませんでした。ところが、ファイルを見ると何の問題もありません。

$ ssh omen 'grep -c "runtimes.nvidia" /etc/containerd/conf.d/99-nvidia.toml'
6

nvidia、nvidia-cdi、nvidia-legacy の3つのランタイムが BinaryName まで正確に書かれています。ファイルはあります。

一つ目の誤診 — 「メイン設定がドロップインを上書きする」

ロードされた設定を見ました。

$ ssh omen 'containerd config dump | grep -n "runtimes"'
111:      [plugins."io.containerd.grpc.v1.cri".containerd.runtimes]
113:        [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
128:          [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]

runc 一つだけです。そして111行目で目に入ったものがありました。メインの config.tomlruntimes テーブルを直接定義していました

もっともらしい仮説がすぐ立ちました。TOML の統合はテーブル単位で、メインファイルがそのテーブルをすでに持っているので、ドロップインの同じテーブルは捨てられるのだ。そこで、メインファイルに nvidia ブロックを直接入れてみるよう案内しました。

まちがっていました。しかも、この案内はノードを1台落としました。

containerd: failed to load TOML: /etc/containerd/config.toml: (300, 2): duplicated tables
Job for containerd.service failed because the control process exited with error code.

>> で追記するコマンドをお渡ししたのですが、貼り付けを2回すると同じテーブルが2回宣言されます。TOML はそれを文法エラーとみなし、containerd は起動を拒否します。ノードが丸ごと落ちました。

一つ目の教訓は診断より前に出ました。本番ノードに案内するコマンドは、2回実行されても同じ結果にならなければいけません。人は貼り付けを2回します。

二つ目の誤診 — 「version = 2 が抜けている」

重複を取り除いた後も nvidia は認識されませんでした。conf.d をもう一度見ていたら、二つ目のファイルが目に入りました。

$ ssh omen 'ls /etc/containerd/conf.d/'
99-nvidia.toml
zz-labhub-registry.toml

zz-labhub-registry.toml は3行のファイルです。プライベートレジストリが平文 HTTP なので、証明書ディレクトリを指すために私が1週間前に入れたものです。

# LabHub: Harbor(HTTP) レジストリを信頼する
[plugins."io.containerd.grpc.v1.cri".registry]
  config_path = "/etc/containerd/certs.d"

version = 2 の行がないことに気づきました。containerd の設定でバージョン表記が抜けると v1 として扱われ、プラグインキーの解釈が変わるという話があります。二つ目の仮説が立ち、今度は 適用する前に測ってみました

$ # zz ファイルに version = 2 を入れた複製で dump
$ containerd --config /tmp/t2.toml config dump 2>/dev/null | grep -c "runtimes.nvidia"
0

またまちがえました

二度ともそれらしく、二度ともまちがいでした。ここでやり方を変えました。設定ファイルを読んで推論するのをやめて、一つずつ外しながら結果が変わるかを見ることにしました。

実験 — ドロップインを一つずつ外す

containerd --config <ファイル> config dump は、生きているプロセスに触れずに任意の設定ファイルを読ませてみることができます。本番ノードで何も変えずに測定できるということです。

$ for f in 99-nvidia zz-labhub-registry; do
    printf 'version = 2\nimports = ["/etc/containerd/conf.d/%s.toml"]\n' $f > /tmp/t.toml
    echo -n "$f のみ import → "
    containerd --config /tmp/t.toml config dump 2>/dev/null | grep -c "runtimes.nvidia"
  done
99-nvidia のみ import6
zz-labhub-registry のみ import0

$ # 両方
$ printf 'version = 2\nimports = ["/etc/containerd/conf.d/*.toml"]\n' > /tmp/t3.toml
$ containerd --config /tmp/t3.toml config dump 2>/dev/null | grep -c "runtimes.nvidia"
0

3行目が答えです。

99-nvidia.toml だけ読ませると nvidia ランタイムが6回出てきます。ところが、レジストリの3行を 一緒に 読ませると0になります。ランタイムの話が一文字も書かれていないファイルが、ランタイム3つを消しました。

ルール — 統合はフィールド単位ではない

containerd の imports 統合はプラグイン単位の丸ごと置き換えです

ドロップインがあるプラグインの設定に触れると、そのプラグインの設定 全体 がそのファイルの内容に置き換わります。ドロップインが書かなかった項目は、前のファイルの値に戻るのではなく デフォルト値 になります。

ドロップインは名前順に読まれます。だから結局、そのプラグインに言及した 最後のファイルが全部を持っていきます

zz-99- より後ろです。だから、この3行が CRI プラグインの設定全体を置き換えてしまいました。

読まれる順序       CRI プラグイン設定
─────────────────  ────────────────────────────────────
config.toml        runc, sandbox_image, cgroup 設定 …
99-nvidia.toml     runc + nvidia ×3, certs.d …          ← 前のものを丸ごと置き換え
zz-registry.toml   config_path 一つ                      ← また丸ごと置き換え
─────────────────  ────────────────────────────────────
最終               config_path + 残りは全部デフォルト値

ファイル二つを並べて人が頭の中で合わせると、ランタイムは4つです。実際にロードされていたのは1つでした。

なぜ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 もそれらしく見えます。設定が生きているように見えます。

全部デフォルト値です。私たちが書いた値は一つも残っていないのに、デフォルト値が私たちの書きそうな値と似ているので気づけません。pause:3.8 は kubeadm が使う値と同じで、runc はどのみちデフォルトのランタイムです。

そしてランタイムハンドラは コンテナを作るときだけ 参照されます。すでに動いていた GPU ポッドは、設定が消えても平気で動き続けます。だから故障がすぐには見えず、ノードを再起動したりポッドが再生成されたりする瞬間に一気に噴き出します。

この二つが重なって1週間が過ぎました。

修復 — 何度回しても同じ結果になるように

原因がわかったので、直すのは簡単です。zz-labhub-registry.tomlconf.d の外に出せばいいだけです。そのファイルが持っていた config_path の値は 99-nvidia.toml がすでに持っています。

ただし今回は、コマンド1行で案内しませんでした。さきほどノードを1台落としたからです。スクリプトを作り、二つの性質を入れました。

一つ目、追記しません。あるものを全部取り除いてから、正確に一つだけ作り直します。0回貼っても1回貼っても2回貼っても結果が同じです。

def strip_nvidia(lines):
    """nvidia テーブルとその配下のテーブルを全部取り除く。"""
    out, cur, dropped = [], "", 0
    for line in lines:
        h = header_of(line)
        if h is not None:
            cur = h
        if cur == NV or cur.startswith(NV + "."):
            dropped += 1
            continue
        out.append(line)
    return out, dropped

二つ目、生きているファイルは最後に触ります。候補の設定を一時ファイルとして作り、containerd にそのファイルを読ませて nvidia と certs.d が実際に認識されるかを確認してから置き換えます。検証に失敗したら、元のファイルは一文字も変わりません。

rc, out, err = dump(candidate)          # containerd --config <候補> config dump
if rc != 0 or out.count("runtimes.nvidia") == 0:
    print("候補の設定で nvidia ランタイムが認識されませんでした。元のファイルはそのままにします。")
    return 1
if "/etc/containerd/certs.d" not in out:
    print("レジストリ設定が消えます。元のファイルはそのままにします。")
    return 1
# ここから実際に変更する

二つ目の確認が重要です。消そうとしているファイルが持っていた設定を、別のファイルが代わりに持っているかを 確認したうえでだけ 消します。確認せずに消していたら、GPU を生き返らせてイメージの取得を切っていたはずです。

余震 — containerd は生きているのにノードが死ぬ

4ノードに適用して再起動しました。3台はすぐ復活したのに、1台が NotReady のまま残りました。

$ ssh omen2 'systemctl is-active containerd'
active

$ kubectl describe node omen2 | grep -A1 "Ready "
  Ready   False   KubeletNotReady   container runtime is down

containerd は active なのに、kubelet はランタイムが死んだと言います。ログを見ると答えがあります。

level=warning msg="failed to load plugin io.containerd.grpc.v1.cri"
  error="failed to create CRI service: failed to create cni conf monitor for default:
         failed to create fsnotify watcher: too many open files"
level=info msg="containerd successfully booted in 0.417476s"

プロセスは起動したのに CRI プラグインだけロードに失敗しましたfs.inotify.max_user_instances のデフォルト値128が使い切られていたのです。systemctl status だけ見ると正常に見える状態です。

$ sudo sh -c 'printf "fs.inotify.max_user_instances = 8192\nfs.inotify.max_user_watches = 1048576\n" \
    > /etc/sysctl.d/99-inotify.conf && sysctl -p /etc/sysctl.d/99-inotify.conf'
$ sudo systemctl restart containerd

5ノード全部がデフォルト値128で、設定ファイルもありませんでした。omen2 が先に爆発しただけで、残りも運がよかっただけです。Kubernetes のノードなら、先に上げておいたほうがいいです。

検証 — 数字ではなくポッドで

$ kubectl get nodes -o custom-columns="NODE:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu"
NODE     GPU
nuc1     1
nuc2     1
omen     1
omen2    1

ここで止まってはいけません。広告はされているのにポッドが起動しない状態があります。実際に動かしてみます。

$ kubectl -n gpu-operator logs gputest
GPU 0: NVIDIA GeForce RTX 4070 Laptop GPU (UUID: GPU-53be3556-a942-531c-a9a5-20e92af45279)

ポッドがスケジュールされ、nvidia ランタイムでサンドボックスが作られ、コンテナの中で nvidia-smi がカードを見る。ここまで来て終わりです。

では何をすればいいのか

一つ。ファイルを読んで結論を出しませんcat が見せてくれるのは誰かが書いた意図で、config dump が見せてくれるのは実際にロードされたものです。GPU 設定の事故のほとんどは、この二つが違うのに前者しか見なかったところから来ます。

二つ。dump を読むときも、自分が書いた値かデフォルト値かを区別します。今回の事故が1週間隠れていた理由がこれです。区別する唯一の方法は、一つずつ外してみて結果が変わるかを見ることです。

三つ。conf.d にファイルをもう一つ入れる前に名前を見ます。そのファイルが名前順で最後で、あるプラグインに言及しているなら、そのプラグインの設定全体に責任を持つことになります。3行だけ書いておけば、残りは全部デフォルト値になります。

四つ。本番ノードに案内するコマンドは、2回回しても同じでなければいけません。人は貼り付けを2回します。私はそれに耐えられないコマンドをお渡しして、ノードを1台落としました。

五つ。取り消せない変更は、生きている対象を最後に触ります。候補を作り、検証して、検証を通ったときだけ置き換えます。この順序はスクリプトを数行長くするだけですが、まちがえたときのコストを0にします。

まとめ

項目
containerd1.7.27
症状ノード4台が nvidia.com/gpu を広告しない
ファイルの状態正常(99-nvidia.toml にランタイム3種)
実際の原因zz-labhub-registry.toml の3行が CRI 設定全体を置き換え
統合ルールプラグイン単位の丸ごと置き換え、名前順で最後のファイルが勝つ
隠れていた理由残っているものが全部デフォルト値なのに、デフォルト値がそれらしい
余震inotify 上限の枯渇で CRI プラグインだけロード失敗
誤診の回数2

いちばん長く残る教訓は診断のやり方のほうです。二度の誤診はどちらも 設定ファイルを読んで立てた仮説 で、答えをくれたのは 一つずつ外しながら結果を測った実験 でした。読んで得た確信は検証ではありません。

🧠 理解度チェッククイズ

1. ドロップインファイルに nvidia ランタイムがはっきり書かれているのに config dump にはありません。何を疑うべきですか。

同じプラグインに言及していて、名前順でより後ろに来るドロップインがないかを見ます。containerd の imports 統合はフィールド単位ではなくプラグイン単位の丸ごと置き換えなので、そのプラグインに言及した最後のファイルが設定全体を持っていきます。そのファイルがランタイムを書いていなければ、ランタイムはデフォルト値に戻ります。

2. config dump に runc と sandbox_image が正常に見えるのに、設定が消えている可能性があるのはなぜですか。

残っている値が私たちの書いた値ではなく、デフォルト値だからです。containerd のデフォルトランタイムは runc で、デフォルトの sandbox_image は kubeadm が使うものと同じなので、設定が丸ごと消えた状態と正常な状態の dump が似て見えます。区別するには、ドロップインを一つずつ外しながら dump が変わるかを見なければいけません。

3. ランタイム設定が消えたのに、GPU ポッドがしばらく平気で動き続けるのはなぜですか。

ランタイムハンドラは、コンテナを 作るときだけ 参照されます。すでに実行中のコンテナは、設定が変わっても影響を受けません。だから故障がすぐには表に出ず、ノードの再起動やポッドの再生成の時点で一気に噴き出します。この潜伏が、GPU 設定の事故をとりわけ高くつくものにします。

4. 本番ノードの設定を直すスクリプトを「追記」ではなく「取り除いて書き直す」形にするのはなぜですか。

2回実行されても同じ結果になるようにするためです。追記するコマンドを2回実行すると、同じ TOML テーブルが2回宣言され、containerd はこれを duplicated tables の文法エラーとみなして起動を拒否します。ノードが丸ごと落ちます。人は貼り付けを2回するので、コマンドの側がそれに耐えなければいけません。

5. containerd が active なのにノードが NotReady で、kubelet が "container runtime is down" と言っています。何を見るべきですか。

containerd のログでプラグインのロード失敗を探します。プロセスは起動したのに CRI プラグインだけ失敗している状態かもしれません。今回の場合は fs.inotify.max_user_instances のデフォルト値128が使い切られ、CNI 設定の監視役を作れなかったのが原因でした。systemctl status は正常に見えるので、必ずログを見なければいけません。

参考資料