cgroupsによる計算資源の物理配分とOOM Killer
Dockerでコンテナを起動するとき、私たちは何気なく以下のようなオプションを打ち込みます。
docker run -d --cpus="1.5" --memory="1g" --name my-app nginx
「CPUは1.5コア分まで」「メモリは1GBまで」。 人間にとっては直感的なパラメータ指定です。しかし、第1話で明かした通り、コンテナとは独立した仮想マシンではなく、ホストOSの単一カーネルの上で動く単なるプロセスに過ぎません。
ハードウェア(物理CPUやRAMチップ)は、ホストマシンに1台分しか存在しない。 それなのに、なぜ「—memory=“1g”」と指定されたプロセスは、ホストの物理メモリが数十GB余っていようとも、1GBの境界を一歩踏み越えた瞬間に即座にクラッシュするのでしょうか? そして、なぜ「—cpus=“1.5”」と指定されたプロセスは、CPUを2コア使おうとした瞬間に、見えない手によって減速させられるのでしょうか?
この「リソースの首輪」を嵌めているカーネルの物理機構こそが、GoogleのエンジニアたちがLinuxカーネルに寄贈した**「cgroups(Control Groups:コントロールグループ)」**です。
1. Namespaces(視覚)と cgroups(胃袋)の決定的な違い
前回の第1話で学んだ「Linux Namespaces」と、今回の「cgroups」の役割分担を整理しましょう。
- Namespaces(視覚の制限): 「お前には、このプロセス番号と、このネットワークインターフェースしか見えない」という視野の隔離。
- cgroups(胃袋の制限): 「お前が消費してよいCPU時間、メモリ容量、ディスクI/O、ネットワーク帯域はここまでだ」という物理資源の計量と絞り込み(Quota)。
Namespacesで周囲のプロセスを見えなくしても、cgroupsがなければ、たった1つのコンテナが無限ループを起こしただけでホスト全体のCPU使用率が100%に張り付き、メモリを食い尽くしてサーバー丸ごとダウン(クラッシュ)します。 マルチテナント(同じ物理サーバーで何十個ものコンテナを同居させること)が成り立っているのは、cgroupsがプロセスたちの胃袋をミリグラム単位で縛り上げているからなのです。
2. CPUの物理配分:CFSクォータと「マイクロ秒単位の強制睡眠」
まず、「CPUを1.5コア分(あるいは50%)に制限する」とは、物理的に何が起きているのでしょうか?
CPUのクロック周波数をプロセスごとに落とすことはできません。物理CPUコアは、動くときは常にギガヘルツの最高速度で動きます。 カーネルがやっているのは、**「時間をマイクロ秒単位のコマに切り刻み、持ち時間を使い切ったプロセスを無理やり居眠り(スロットリング)させる」**という時間制御です。
完全公平スケジューラ(CFS)の計算式
Linuxの標準CPUスケジューラは「CFS(Completely Fair Scheduler)」と呼ばれます。cgroupsのCPU制御(cgroups v2の cpu.max)は、2つの数字で構成されます。
period(周期):標準では 100,000マイクロ秒(=100ミリ秒=0.1秒)quota(割当量):その周期内にプロセスが消費してよいCPU時間の合計
例えば --cpus="0.5" と指定した場合、カーネルは以下のように設定します。
$$\text{period} = 100000,\mu\text{s}, \quad \text{quota} = 50000,\mu\text{s}$$
プロセスがCPUを全力で回すと、最初の50ミリ秒で持ち時間をすべて使い果たします。 その瞬間、CFSスケジューラはプロセスからCPUの実行権を無慈悲に奪い取り、「待機キュー(Throttled Queue)」に放り込んで強制睡眠させます。 残り50ミリ秒の間、プロセスは1クロックも進むことができません。そして100ミリ秒の周期がリセットされた瞬間、クォータが満額補充され、プロセスは再び目を覚まします。
これが**「CPUスロットリング」の物理です。 人間から見れば「CPU使用率が50%でスムーズに動いている」ように見えても、マイクロ秒のミクロな世界では、プロセスは「50ms爆走して50ms気絶する」という激しい点滅(スタンガン攻撃)**を毎秒10回繰り返しているのです。
触って試す:CPU制限の体感シミュレータ
⏱️ CPU制限の体感(上限を選んで実行)
同じ計算タスクを実行します。CPU上限を選ぶと、1周期(ここでは見やすいよう1秒に拡大表示)の中で「実行できる時間」の割合が変わります。上限が低いほど強制睡眠(スロットリング)の時間が長くなり、タスクの完了が遅くなります。実行中の上限変更もできます。
上限を選んで「実行開始」を押してください。
制限なし(100%)なら約2.5秒で終わる計算です。50%では約5秒、10%では約25秒かかります。
3. メモリの物理配分:ページフォールトの計数と「1バイトの壁」
CPUは「時間を削る(待たせる)」ことで制限できました。 しかし、メモリ(RAM)は待たせることができません。すでにデータが書き込まれているメモリ領域を「ちょっと待って」と止めることは物理的に不可能です。
では、cgroupsはどうやってコンテナのメモリ消費を監視しているのでしょうか?
仮想記憶と「ページフォールト」の監視塔
CPUとOSは、メモリを1バイトずつではなく**「ページ(標準で4KB)」というブロック単位で管理しています。
C言語で malloc(1024 * 1024) を呼んでも、その瞬間に物理メモリが確保されるわけではありません。プロセスがそのメモリ番地に初めてデータを書き込もうとした瞬間、ハードウェア(MMU:メモリ管理ユニット)が例外を発生させます。これが「ページフォールト(Page Fault)」**です。
cgroupsのメモリコントローラ(memcg)は、このページフォールトの関所(フックポイント)に常駐しています。
- プロセスが4KBの新しいメモリを欲しがる。
- カーネルは、そのプロセスが属しているcgroupの累積メモリ使用量をチェックする。
- もし上限(例: 1GB)に達していなければ、カウンターを
+4KBして物理メモリを配給する。 - もし上限(1GB)を1バイトでも超えていたら、メモリの割り当てを拒否し、即座に非常事態宣言を発令する。
4. 死神の降臨:OOM Killer(Out of Memory Killer)の処刑アルゴリズム
メモリ上限に達した瞬間、カーネルはまず悪あがきをします。 使用頻度の低いファイルキャッシュ(ページキャッシュ)をディスクへ吐き出し、空きメモリを作ろうとします(メモリ回収処理:Reclaim)。
しかし、プロセスがヒープ領域(実データ)を確保し続け、キャッシュを削っても削っても1GBの枠に収まらないとき、カーネルは最後の冷酷な手段を発動します。 それが、Linuxの最終防衛装置**「OOM Killer(Out of Memory Killer)」**です。
どうやって「生贄」を選ぶのか?(oom_score の力学)
OOM Killerは、適当にプロセスを殺しているわけではありません。カーネルは以下の要素から、各プロセスの**「悪質度スコア(oom_score:0〜1000)」**を瞬時に計算します。
- 消費メモリの絶対量:多くのメモリを食い荒らしているプロセスほどスコアが高い(殺したときに解放されるメモリが大きい)。
- プロセスの重要度:ルート権限のシステムプロセスや特権プロセスは、スコアが低く補正される。
oom_score_adj:管理者が-1000(絶対に殺すな)から+1000(最優先で殺せ)まで手動で設定できる補正値。
なぜコンテナのクラッシュは「Exit Code 137」なのか?
OOM Killerの処刑は、情け容赦がありません。
通常の終了通知(SIGTERM:シグナル15)ではなく、プロセス側がハンドラで捕捉することも無視することも絶対にできない即死シグナル**「SIGKILL(シグナル 9)」**を叩き込みます。
Linuxにおいて、シグナルによって強制終了されたプロセスの終了ステータス(Exit Code)は、**「128 + シグナル番号」**で表されます。
$$128 + 9 (\text{SIGKILL}) = 137$$
Dockerコンテナが理由も言わずに突然死し、ログに**Exited (137)と刻まれているとき、その裏では「cgroupsのメモリ境界を突破したため、カーネルの死神(OOM Killer)によって射殺された」**という物理的処刑が執行されていたのです。
5. すべてはファイルである:/sys/fs/cgroup の裏側を覗く
LinuxのUNIX哲学「すべてはファイルである」は、cgroupsにおいても徹底されています。
DockerやKubernetesなどのコンテナエンジンは、特殊なシステムコールを使っているわけではありません。ホストOSの /sys/fs/cgroup ディレクトリの下にフォルダを作り、テキストファイルに数字を書き込んでいるだけです。
現在主流となっている「cgroups v2」のファイルツリーを見てみましょう。
# cgroups v2 の構造(概念)
/sys/fs/cgroup/
├── cgroup.controllers # 利用可能なリソース種別(cpu, memory, io, pids...)
├── cgroup.procs # このグループに属するプロセスID一覧
├── cpu.max # CPUクォータ上限(例: "50000 100000")
├── memory.max # メモリ上限バイト数(例: "1073741824" = 1GB)
├── memory.current # 現在の消費バイト数
└── docker/
└── [コンテナID]/ # Dockerが作ったコンテナ用ディレクトリ
├── cgroup.procs
├── cpu.max
├── memory.max
└── memory.events # OOMが発生した回数などの統計
実験:シェルから手動でコンテナの首輪を作る
root権限があれば、Dockerを一切使わずに、Bashコマンドだけでプロセスに「100MBのメモリ制限」をかけることができます。
# 1. 新しいcgroupディレクトリを作成(カーネルが自動で管理ファイルを生成する!)
sudo mkdir /sys/fs/cgroup/my-limit
# 2. メモリ上限を100MB(104,857,600バイト)に設定
echo 104857600 | sudo tee /sys/fs/cgroup/my-limit/memory.max
# 3. 現在のシェル(PID: $$)をこのcgroupに所属させる
echo $$ | sudo tee /sys/fs/cgroup/my-limit/cgroup.procs
# 4. 意図的に150MBのメモリを確保するPythonスクリプトを実行してみる
python3 -c "a = 'x' * (150 * 1024 * 1024)"
このコマンドを叩いた瞬間、Pythonは150MBの文字列を作ろうとした途端、Killed と画面に出て一瞬で消滅します。
カーネルログ(dmesg -T)を覗くと、そこには誇らしげに以下のメッセージが刻まれています。
[Fri Nov 6 10:00:00 2026] Memory cgroup out of memory: Killed process 54201 (python3) total-vm:156420kB, anon-rss:102400kB ...
Dockerという巨大なクジラの皮を剥ぎ取れば、中にあるのは**「Linuxカーネルが提供する擬似ファイルシステム(sysfs)へのテキスト書き込み」**という、極めて素朴なLinux標準の土台なのです。
結論:コンテナとは「Namespacesの幻覚」と「cgroupsの首輪」の積である
第1話と第2話を合わせることで、ついに「コンテナ」という概念の物理輪郭が完全に浮き彫りになりました。
コンテナとは、新しい仮想ハードウェアではありません。 「Namespacesによって視覚を奪われ、cgroupsによって胃袋を縛られた、ただの従順なLinuxプロセス」。 これが、クラウドネイティブの全宇宙を支えている泥臭くも頑強な物理の真実です。
しかし、まだコンテナの謎はすべて解けてはいません。 UbuntuやAlpine、Node.jsといった数百メガバイトもある巨大なファイルシステムが、なぜ「docker run」を叩いたコンマ数秒後には起動し、ディスクを消費せずに別々のコンテナから同時に読み書きできるのか?
次回第3話、レイヤーを重ね合わせてディスク容量の壁を粉砕する**「OverlayFSの物理(Copy-on-Writeの構造)」**を暴きます。
📝 3行で頭に入る『試験・実務・投資の要点まとめ』
- 【CPUスロットリング】 CFSスケジューラは100ms周期(period)の中で使用時間(quota)を監視し、上限超過時はプロセスを強制睡眠させてCPU時間を物理的に絞る。
- 【メモリ監視とOOM】 ページフォールト時にcgroupの累積メモリを検査し、上限超過時はページキャッシュを回収。それでも足りない場合は「OOM Killer」がSIGKILL(Exit Code 137)で即死処刑する。
- 【sysfsの物理実体】 cgroupsは特殊な魔法ではなく、
/sys/fs/cgroup/配下にディレクトリを作り、cpu.maxやmemory.maxというテキストファイルに数値を書き込むだけで機能する。