Blog

2025.01.10

Engineering

Kubernetes における cgroup v2 での Out-Of-Memory 問題の解決

Toru Komatsu

はじめに

PFN のエンジニアの小松です。Cluster Services チームという機械学習基盤を開発・運用するチームに所属し、日々基盤の改善や新機能の開発を進めています。また、最近では社内基盤に限らず Preferred Computing Platform の開発・運用も行っています。

PFN での機械学習基盤ではコンテナを実行するオーケストレータとして Kubernetes を採用し、日々運用をしています。本記事ではKubernetes の機械学習基盤の日々の運用で社内からもらったフィードバックを 実装し、Kubernetes のアップストリームへ貢献した話題を紹介します。

PFN のクラスタチーム

PFN の機械学習基盤を運用/保守しているクラスタチームでは、Kubernetes のバージョンアップの追従にも力をいれています。Kubernetes クラスタを継続的に更新し、バグ修正や安定性、パフォーマンス向上の変更を取り込み、また新しい機能をいち早くユーザに届けるように取り組んでいます。具体的には最新のバージョンから1つ前のバージョンになるように定期的にアップグレードしています。また、社内向けの機械学習基盤ではユーザーである社内のエンジニア/リサーチャとクラスタチームの距離が近く、ユーザーからフィードバックをいただくことが頻繁にあります。

クラスタチームの業務内容については以下の記事に詳しく書かれています。もしよければご一読ください。

Kubernetes v.1.28 へのアップグレード後の Out-Of-Memory の発生

ある日、OOM Killer がやってきました。Kubernetes v1.28 にアップグレードして数日後に、これまで成功していたジョブが OOM で失敗するようになったというフィードバックがありました。詳しくユーザーの話を聞いてみると「そのジョブは実際に走らせてみるまで各エントリがメモリをどれだけ消費するかがわからないワークロードのため、とりあえず走らせて子プロセスが OOM になったら別のエントリを計算するように実装している」とのことでした。

また、別のチームからも同様のフィードバックがあり、そのチームのワークロードはインタラクティブ用途のワークロードで make の -k, –keep-going オプションを使用し、「ビルド処理の一部が失敗しても継続するようにしていて OOM でプロセスが失敗した場合にもビルドが成功するようにする」としているものでした(例えば make -j 20 -k || make -j 10)

社内での調査の結果、v1.28 から入ったPR(https://github.com/kubernetes/kubernetes/pull/117793) が原因であることがわかりました。この PR では cgroup v2 から利用可能な  memory.oom.group を用いてコンテナ内のあるプロセスが OOM になった場合にコンテナ内のすべての プロセス を OOM として強制終了するようにしています。memory.oom.group については次の章で詳しく説明しています。

これまで Pod が OOM になった場合に子プロセスなど1つのプロセスだけが殺されてコンテナ全体としては健全なまま実行され続けていました。成熟したマルチプロセスなソフトウェアではこのような子プロセスの OOM を正しく処理できますが、そうではないその他多くのソフトウェアでは OOM になった場合に一部のプロセスが殺されて不安定になるよりはコンテナ全体を OOM として強制終了したほうが安定すると考えられています。

cgroup v2 のサポートは Kubernetes の v1.25 から Generally Available (GA) となっています。PFN では2022年12月に Kubernetes バージョンを v1.25 にアップグレードするのと同じタイミングで cgroup v2 に切り替えています。そのため、この PR の変更は cgroup v2 へ移行済みの PFN の機械学習プラットフォームにとって破壊的変更となってしまっていました。

memory.oom.group とは

cgroup v2 のメモリコントローラのインタフェースファイルの1つに memory.oom.group があります。このファイルに 1 を書き込むと cgroup 内とその子孫のタスクをまとめて OOM Kill するようにします。逆にデフォルト値である 0 の場合は一部のタスクのみ OOM Kill されます。

ここではさらに理解を深めるために実際の実行例を使って、memory.oom.group の振る舞いを紹介します。まずは oom-test という cgroup を用意します。メモリの上限を 128MB にします。OOM が発生するようにスワップを利用しないようにします。

$ sudo mkdir /sys/fs/cgroup/oom-test
$ echo 0 | sudo tee /sys/fs/cgroup/oom-test/memory.swap.max
0
$ echo 128M | sudo tee /sys/fs/cgroup/oom-test/memory.max
128M

memory.oom.group が無効の場合

現状の設定値を確認すると、memory.oom.group が 0 、つまり無効となっていることがわかります。また、該当の cgroup にOOM が起きたかどうかは memory.events ファイルで確認できます。

$ cat /sys/fs/cgroup/oom-test/memory.oom.group
0
$ cat /sys/fs/cgroup/oom-test/memory.events
low 0
high 0
max 0
oom 0
oom_kill 0
oom_group_kill 0

stress-ng コマンドで 2 つのプロセスを立ち上げて、200MB のメモリの使用を試みます。このとき当然 OOM が発生しますが、立ち上げた 2プロセスの片方ずつが OOM Kill されているのがログからわかります。stress-ng 全体が OOM されることはないので Ctrl+C で強制終了させます。

$ bash -c 'echo $$ | sudo tee /sys/fs/cgroup/oom-test/cgroup.procs && stress-ng --vm 2 --vm-bytes 200M --vm-hang 0 -v | grep OOM'
2909100
stress-ng: debug: [2909108] vm: assuming killed by OOM killer, restarting again (instance 1)
stress-ng: debug: [2909107] vm: assuming killed by OOM killer, restarting again (instance 0)
...
stress-ng: debug: [2909107] vm: assuming killed by OOM killer, restarting again (instance 0)
stress-ng: debug: [2909108] vm: assuming killed by OOM killer, restarting again (instance 1)
^C%

memory.events を確認すると何回か OOM と OOM Kill が起きていることがわかります。

$ cat /sys/fs/cgroup/oom-test/memory.events
low 0
high 0
max 8916
oom 470
oom_kill 30
oom_group_kill 0

memory.oom.group が有効の場合

次に memory.oom.group を有効にして、同様の実験をしてみましょう。

$ echo 1 | sudo tee /sys/fs/cgroup/oom-test/memory.oom.group
1

それでは先ほどと同様に stress-ng を使って負荷をかけます。すると前回とは異なり 2つの stress-ng プロセス全体が OOM で終了させられます。つまり、先ほどの一部の2プロセスのうち片方が OOM されたのとは異なり stress-ng 全体がOOM されています。

$ bash -c 'echo $$ | sudo tee /sys/fs/cgroup/oom-test/cgroup.procs && stress-ng --vm 2 --vm-bytes 200M --vm-hang 0 -v'
2909320
stress-ng: debug: [2909320] invoked with 'stress-ng --vm 2 --vm-bytes 200M --vm-hang 0 -v' by user 1000 'utam0k'
stress-ng: debug: [2909320] stress-ng 0.17.06
...
stress-ng: info:  [2909320] dispatching hogs: 2 vm
stress-ng: debug: [2909320] starting stressors
stress-ng: debug: [2909320] 2 stressors started
stress-ng: debug: [2909325] vm: [2909325] started (instance 0 on CPU 14)
stress-ng: debug: [2909326] vm: [2909326] started (instance 1 on CPU 10)
stress-ng: debug: [2909325] vm: using method 'all'
zsh: killed     bash -c

memory.events を確認すると oom_group_kill という項目のカウントが増えているのがわかります。また、dmesg からも OOM されているのが確認できます。

$ cat /sys/fs/cgroup/oom-test/memory.events
low 0
high 0
max 8952
oom 472
oom_kill 36
oom_group_kill 1

$ sudo dmesg | grep 2908280
[4745446.656277] [2908280]  1000 2908280    15858      799      351      448         0    81920        0             0 stress-ng-vm
[4745446.659536] Memory cgroup out of memory: Killed process 2908280 (stress-ng-vm) total-vm:63432kB, anon-rss:1404kB, file-rss:1792kB, shmem-rss:0kB, UID:1000 pgtables:80kB oom_score_adj:0

$ sudo rmdir /sys/fs/cgroup/oom-test

上記の例からわかるように memory.oom.group の設定値によってコンテナ、Pod の挙動が変わります。Kubernetes v1.28 からは cgroup v2 を利用すると memory.oom.group は強制的に 1 となってしまっていました。

Kubernetes へ memory.oom.group の設定を追加

前述のとおり  memory.oom.group が有効かどうかで OOM Killer の挙動が異なります。そのため、コミュニティ、PFN としても memory.oom.group を有効にするかどうかの選択をユーザーにさせるべきだと考え、アップストリームへのフィードバックを行い、Pull Request のマージまで行いました。

社内ではこの問題を早急に解決したかったため kubelet へ独自のパッチを当てて運用を行っていました。しかし、パッチの運用は将来への負債を考えると一時的であるべきであり、Kubernetes のアップストリームでの修正が理想的です。そのため、これらの変更をアップストリームへ追加するという取り組みを行いました。そのためには Kubernetes のコミュニティとのやり取りは必須でした。

Pull Request の引継ぎ

まず最初にこの問題に対処するために memory.oom.group を無効にするオプション、フラグを追加してほしい旨をコメントしました。これについて SIG-Node で議論を行い、本機能の無効化を行うオプションの追加が決定しました。

その後に実際にするフラグを追加する Pull Request が出され、`\lgtm`までつきました。しかし、この機能を追加するのであればコンテナレベルで制御すべきという意見があり硬直状態になりました。この Pull Request は最終的には作者の時間の都合で Close されてしまいました。

また、同時期には cgroup v1 のサポートをメンテナンスモードにするという話題も Kubernetes にはありました。そのため、「cgroup v2 に移行するユーザーは直近で増加し、この問題に直面するユーザーは増えるであろう」「将来的にコンテナレベルでの制御は理想的だが、ユーザーに機能を提供するまでに時間がかかる」という指摘を行い、PFN で Close された Pull Request を引継ぎこの singleProcessOOMKill をアップストリームに入れるところまで持っていくことにしました。引き継いだ Pull Request はこちらです。

前の Pull Request は \lgtm が既に出ていたので、当初は実装自体に手を入れることは大きくなく、主な作業は議論だと考えていました。しかし、実際には各環境、他の設定との関係を考慮し、どのようなバリデーションにするべきかというフラグの設計についての議論が SIG API Machinery から入り、その部分についての実装を数回やり直すこととなりました。

singleProcessOOMKill フラグの追加

本機能は linux の cgroup v2 に関連する機能ですが、kubelet がサポートしているその他の環境、例えばcgroup v1 での環境で設定されている場合にどうするか、また Windows 環境ではどういうバリデーションにするべきかという議論がありました。ヘテロジニアスな環境のデプロイを考慮し、Warning ログのみとして無視をして起動をするというパターンなど様々な議論がありました。singleProcessOOMKill は議論の末に以下のような挙動としました。

kubelet の実行環境 singleProcessOOMKill kubelet の起動の挙動 Pod の振る舞い Note
cgroup v2 true 起動に成功する OOM Killer が OOM したタスクを停止させる None
cgroup v2 false とデフォルト値 起動に成功する OOM Killer が OOM したタスクが存在する cgroup 全体を停止させる None
cgroup v1 false Invalid として起動に失敗 v1 では memory.oom.group が存在しないため
cgroup v1 true とデフォルト値 起動に成功する OOM Killer が OOM したタスクを停止させる v1 では memory.oom.group が存在しないが、v1のデフォルトの挙動で何もする必要がないので Invalid としない
Windows true / false Invalid として起動に失敗 kubelet では Windows サポートがあるが、該当する機能が Windows ではないため
Other OS true / false Invalid として起動に失敗 サポートしていないため

singleProcessOOMKill の指定がなかった場合にどのタイミングでデフォルトの値を決定するかというのも議論の余地があります。上記の表からわかるようにデフォルト値は、実行環境の cgroup のバージョンをもとに cgroup v1 の場合は true、 cgroup v2 の場合は false となっています。つまり、このデフォルト値の決定はホストの環境に依存します。そのため、「どのタイミングでデフォルト値を決定するか」というのが問題となります。Pull Request の初期の実装では設定の読み込み時にデフォルト値を決めていました。ただしこのやり方だと cgroup のバージョンが混合されていたり、異なる OS が混ざっているヘテロジニアスなクラスタでは問題がおきます。実際に linux 上で kubelet の設定ファイルを生成し、それを Windows ノードで動かした場合に問題が発生したことがあります。 

このような問題を回避するために、kubelet の起動時にデフォルト値の決定をするようにしています。これにより kubelet が実際に動く環境でデフォルト値の決定ができます。一方で kubelet の起動するまではデフォルト値がわからないということになります。

memory.oom.group を有効化するかどうかの実装は単純です。そもそもとしてmemory.oom.group を cgroup に適用することは kubelet で行いません。実際に値を適用するのは OCI Runtime の役割です。そのため、kubelet では OCI Runtime Specification の形に従い、memory.oom.group1 に設定しておくだけです。これらの条件を踏まえると以下のような実装になります。以下が v.1.32 で実際に実装したコードになります。

if isCgroup2UnifiedMode() && !ptr.Deref(m.singleProcessOOMKill, true) {
	resources.Unified = map[string]string{
		// Ask the kernel to kill all processes in the container cgroup in case of OOM.
		// See memory.oom.group in https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html for
		// more info.
		"memory.oom.group": "1",
	}
}

https://github.com/kubernetes/kubernetes/blob/v1.32.0/pkg/kubelet/kuberuntime/kuberuntime_container_linux.go#L283-L290

v1.31 までの挙動では cgroup v2 であれば memory.oom.group を有効に設定するという挙動になっていました。上記との差分は singleProcessOOMKill のフラグの条件分岐のみです。

if isCgroup2UnifiedMode() {
	resources.Unified = map[string]string{
		// Ask the kernel to kill all processes in the container cgroup in case of OOM.
		// See memory.oom.group in https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html for
		// more info.
		"memory.oom.group": "1",
	}
}

https://github.com/kubernetes/kubernetes/blob/v1.31.4/pkg/kubelet/kuberuntime/kuberuntime_container_linux.go#L249-L257

マージ

最終的には Kubernetes v1.32 のコードフリーズの1日前にマージされました。実際にはフラグの設計以外にも前の Pull Reuqest の e2e テストが不十分であったため、それらの修正もありました。

また、他のバージョンへのバックポートも検討されましたが、Kubernetes の cherry-pick のガイドラインの要件を満たさないということで今回は見送りとなりました。そのため、このようなケースが問題になりそう場合は Kubernetes v1.32 を待ってから cgroup v2 を有効にするとよいでしょう。

取り組み始めた最初の頃はそこまで注目度の高い Pull Request ではなかったのですが、Pull Request のリアクションもついたことから当初の予想通り、cgroup v2 へ移行したユーザーがこの挙動の変更に苦労する結果となったようです。最終的には Pull Request に対してリアクションもつき、参照もされるようになりました。azuredisk でも memory.oom.group で同様に問題を抱えているようでしたが、Kubernetes v1.32 に更新することで解決できるはずです。

おわりに

PFN の Cluster Services では今回の様に OSS の最先端な機能を利用し、積極的に OSS へのフィードバックの還元や、パッチの提供も必要に応じて行っています。

Cluster Services チームでは、一緒に OSS に貢献したり、大規模な機械学習基盤の開発・運用をしたりする仲間を募集しています!カジュアル面談はこちらから申し込みができます。また、他にも以下の ポジションの採用を行っています。ご興味のある方は、ぜひお気軽にご連絡ください!

 

  • Twitter
  • Facebook