Dev Study
← 仮想化とクラウドの土台
レッスン 5 / 10·約 9 分·第8期 仮想化とクラウドの土台

cgroup で資源を配る — 絞られるものと殺されるもの

アプリと DB の下に何が積まれているか(左が下、右が上)
  1. 物理機リージョン/AZ
  2. ハイパーバイザVM exit で捕まえる
  3. VMゲストOS
  4. コンテナnamespace / cgroup今ここ
  5. プロセスアプリ / DB

namespace は見える範囲、cgroup は使える量。前回が「見えない」を作る機構、今回は「足りない」を作る機構です。

まず結論

namespace が「何が見えるか」を分けるのに対し、cgroup は「どれだけ使えるか」を配ります。担当する機構が別です。

そして上限に当たったときの挙動が資源で違います。CPU の上限は帯域の制限で、当たると待たされて遅くなります。メモリの上限は hard limit で、減らせなければ OOM killer が呼ばれます。どちらもアプリのログには出ません。

? 読む前に予測

メモリ上限 256MiB のコンテナで、Node のアプリが「たまに落ちる」。ログには例外もエラーも残っていません。何が起きているでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

見える範囲を分ける機構と、量を配る機構

前回、コンテナの隔離は namespace が作っていることを見ました。ただし namespace には、量を制限する力がありません。自分専用のプロセス番号空間を持っていても、CPU を全部使うことは止められません。見える範囲と使える量は別の問題です。

後者を担当するのが cgroup です。cgroups(7) の定義はこうです。

Control groups, usually referred to as cgroups, are a Linux kernel feature which allow processes to be organized into hierarchical groups whose usage of various types of resources can then be limited and monitored.

プロセスを階層的なグループに整理し、資源使用量を制限し監視できるようにする機能。インタフェースは cgroupfs という疑似ファイルシステムです。すべてのプロセスがちょうど1つの cgroup に属すので、上限が無い状態も「上限が max の cgroup にいる」だけです。

具体実物

上限はファイルに書かれた数字でしかない

コンテナに上限を付け、中から cgroup のファイルを読みます。

$ docker run --rm --memory=64m alpine cat /sys/fs/cgroup/memory.max ほか(macOS / Docker Desktop 4.47.0 / Engine 28.4.0 / kernel 6.10.14-linuxkit、cgroup v2)コマンド出力
### memory.max with --memory=64m
67108864
### memory.max with no limit
max
### cpu.max with --cpus=0.5
50000 100000
### cpu.max no limit
max 100000

--memory=64m は memory.max に 67108864、つまり 64×1024×1024 を書き込んだだけです。--cpus=0.5 は cpu.max に 50000 100000 を書きます。「100000 マイクロ秒の各区間で最大 50000 マイクロ秒まで使える」という帯域の上限で、カーネルの文書が $MAX $PERIOD として定義している形です。無指定なら既定値 max と max 100000。ランタイムがしているのは、この擬似ファイルに数を書くことです。

なおこの実機では --memory=64m のとき memory.swap.max も 67108864 でした。上限の指定が何を設定するかはランタイムの裁量です。 言い切る前にファイルを読みます。

✓ ここまでの確認

`cpu.max` に `50000 100000` と書かれています。これは何を意味しますか。

抽象原理

上限に当たったときの振る舞いは資源ごとに違う

同じ「上限」でも、当たると起きることは資源で違います。

カーネルの文書は cpu.max を "The maximum bandwidth limit"(帯域の上限)と定義し、cpu.stat に nr_throttled(絞られた周期の数)と throttled_usec(止められた時間)を用意しています。**CPU 側の語彙は「絞る」**です。

一方 memory.max の定義はこうです。

Memory usage hard limit. This is the main mechanism to limit memory usage of a cgroup. If a cgroup's memory usage reaches this limit and can't be reduced, the OOM killer is invoked in the cgroup.

hard limit であり、到達して減らせなければ、その cgroup 内で OOM killer が呼ばれる。ここだけ明示的に「殺す」と書かれています。ただしメモリ側にも「絞る」型の上限は別にあり、memory.high は「この境界を超えても OOM killer は決して呼ばれない」と定義されています。メモリだから殺されるのではなく、hard limit だから殺される。

具体実物

実測: 片方は遅くなって完走し、片方は 137 で消える

まず CPU。上限なしと --cpus=0.5 で同じループを回します。

$ docker run --rm [--cpus=0.5] alpine time sh -c '200万回の加算ループ'(同環境、1回の計測)コマンド出力
### CPU busy loop, no limit
real 0m 1.71s
user 0m 1.71s
exit=0
### CPU busy loop, --cpus=0.5
real 0m 3.72s
user 0m 1.88s
exit=0
--cpu.stat--
nr_periods 38
nr_throttled 37
throttled_usec 1845569

壁時計は 1.71 秒から 3.72 秒へ、およそ2倍に。しかし user(実際に CPU を使った時間)はほぼ変わりません。増えたぶんは待たされていた時間です。cpu.stat では 38 周期のうち 37 周期で絞られ、合計 1.845569 秒止められています。real と user の差 1.84 秒と一致。終了コードは 0、仕事は最後まで終わりました。

次にメモリ。32MiB の上限で、約1億バイトを変数に読み込ませます。

$ docker run --rm --memory=32m alpine sh -c '約1億バイトを変数に読み、done と表示'; echo "exit=$?"(同環境。`### docker inspect の State` は `--rm` を外し `--name` を付けて再実行したもの)コマンド出力
exit=137
### docker inspect の State
OOMKilled=true ExitCode=137 Status=exited

done は表示されず、終了コードは 137。状態にも OOMKilled=true が記録されています。137 は 128+9、9 は SIGKILL です。この 128+N はシェルの規約で、Bash のマニュアルが「致命的なシグナル N で終了したコマンドには 128+N を使う」と定めています。カーネルが返す数ではありません。

そして SIGKILL は、signal(7) が明記するとおり捕捉もブロックも無視もできません。終了処理を書いても実行されません。「エラーが出ていないから正常」という読み方が、ここで崩れます。

✓ ここまでの確認

コンテナが終了コード 137 で終わりました。この 137 という数の根拠はどれですか。

⇄ 2つを見比べる
具体例 A

cpu.max — 帯域の上限に当たる

周期ごとに使える CPU 時間を決め、使い切ったら次の周期まで待たせます。実測では同じ仕事が約2倍かかり、終了コード 0。

--cpus=0.5 で変わったもの(1回の計測)表
項目 上限なし --cpus=0.5
壁時計 (real) 1.71s 3.72s
CPU時間 (user) 1.71s 1.88s
終了コード 0 0
絞られた周期 - 38中37
具体例 B

memory.max — hard limit に当たる

上限に達し、回収しても減らせないとき、cgroup 内で OOM killer が呼ばれます。実測では最後の出力が消え、終了コード 137。

--memory=32m で変わったもの表
項目 上限なし --memory=32m
最後の出力 done出る 出ない
終了コード 0 137
記録される場所 - memory.events
アプリのログ - 何も無い

どちらも「cgroup のファイルに書いた1つの数」が上限です。同じ上限なのに、片方は遅くなるだけで、片方は死ぬ。この違いはどこから来るのでしょうか。

「上限を超えそうだ」となった瞬間にカーネルに何ができるかを、資源ごとに考えてみてください。

抽象原理

上限に当たった先の分岐

プロセスが資源を要求してから、上限に当たるまで
プロセスが資源を要求するCPU 時間の割り当て、またはメモリの確保○ カーネルが処理する
cgroup の上限と比べるcpu.max / memory.max に書かれた数○ 上限内なら通る
CPU が上限を超えたら、待たせるその周期の残りは走らせない○ 遅くなるが完走。終了コード 0
メモリが上限を超え、減らせないならcgroup 内で OOM killer が呼ばれる× SIGKILL。終了コード 137、ログ無し
↓
どちらの分岐もアプリからは見えない。観測点は cpu.stat と memory.events、そして終了コード

もう一段の注意です。上限の設定と、中のランタイムがその値を前提に動くことは別です。JVM は、割り当てられたメモリ量と CPU 数を自動検出する機能が JDK 10 で入り(JDK-8146115)、公式文書に「既定で有効で、-XX:-UseContainerSupport で無効化できる」とあります。他のランタイムが同じことをするかは、その文書を見るまで分かりません。 本教材が確認した範囲では、Node.js の公式 CLI 文書に cgroup への言及は見つかりませんでした。見つからないことは「見ていない」の証明ではないので、どちらとも書けません。上限の数字は二か所にあると考えます。

この章のまとめ

  • namespace が見える範囲を分けるのに対し、cgroup は資源の使用量を制限・監視する。上限の実体は cgroupfs のファイルの数で、--memory=64m は memory.max に 67108864、--cpus=0.5 は cpu.max に 50000 100000 を書くだけ。
  • cpu.max は帯域の上限で、当たると待たされる。実測では同じ仕事が約2倍になり、終了コード 0 で完走した(1回の計測)。
  • memory.max は hard limit で、減らせなければその cgroup 内で OOM killer が呼ばれる。実測では終了コード 137。捕捉できない SIGKILL なのでログには何も残らない。137 は 128+9 で、シェルの規約。
  • 観測点は cpu.stat の nr_throttled / throttled_usec、memory.events の oom_kill、終了コード。
  • 上限の設定と、中のランタイムがその値を前提に動くことは別。 JVM は自動検出が既定で有効と文書にあるが、他は各文書で確かめる。

この先の学びにどう繋がるか

次は「イメージはレイヤ」の回です。演習は 仮想化とコンテナ隔離の原理 の「仮想化IV(cgroupによる資源制御)」が対応し、観測の点で システム性能分析 も重なります。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 4正解: 0

namespace と cgroup の役割分担として正しいものはどれですか。

用語集(6語)
cgroup
プロセスを階層的にグループ化し、資源の使用量を制限・監視できるようにする Linux カーネルの機能。インタフェースは cgroupfs。
cpu.max
`$MAX $PERIOD` の2値で書く CPU の帯域上限。各区間で最大 $MAX まで使える。既定は `max 100000`。
throttle(絞る)
上限に当たったとき実行を止めて待たせること。`cpu.stat` の `nr_throttled` と `throttled_usec` に出る。
memory.max
メモリ使用量の hard limit。達して減らせないとき、その cgroup 内で OOM killer が呼ばれる。
OOM killer
メモリが確保できない状況でプロセスを選んで強制終了させるカーネルの仕組み。SIGKILL は捕捉できない。
終了コード 137
致命的なシグナル N に 128+N を返すシェルの規約による値。SIGKILL は 9 なので 128+9 = 137。