DIVE

Part 3: カーネルと仮想化

第 9 章

コンテナの正体 — それは「隔離されたプロセス」に過ぎない

前章で、カーネルは各プログラムに「あなただけのメモリ世界」という幻想を見せられるようになった。だが、分離できるのはメモリだけだろうか。

もし、プロセスID・ネットワーク・ファイルシステム・ホスト名——あらゆる資源を丸ごと分離できたらどうなる? そのプロセスは、自分がマシンを独占していると信じ込み、まるで独立した一台のサーバーの中にいるように振る舞うだろう。

これこそがコンテナの正体だ。そして、多くの人が誤解している点をはっきり言おう。コンテナは仮想マシンではない。ただの、隔離されたプロセスだ。


仮想マシンとの決定的な違い — 歴史の必然

コンテナを理解するには、まず仮想マシン(VM)との対比が要る。

2000年代、一台の物理サーバーで複数のサービスを動かすため、VMが普及した。VMはハードウェアそのものを仮想化し、その上でゲストOS(カーネルごと)を丸々動かす。分離は完璧だが、代償が大きい。ゲストOSの起動に数十秒、メモリも各VMがGB単位で消費する。

仮想マシン(VM)                        コンテナ
┌──────┐┌──────┐┌──────┐            ┌──────┐┌──────┐┌──────┐
│ App A ││ App B ││ App C │            │ App A ││ App B ││ App C │
├──────┤├──────┤├──────┤            └──────┘└──────┘└──────┘
│ゲストOS││ゲストOS││ゲストOS│  ← 重い    (ゲストOSが無い!)
├──────┴┴──────┴┴──────┤            ┌──────────────────────┐
│    ハイパーバイザ        │            │  共有する1つのカーネル   │ ← 軽い
├────────────────────┤            ├──────────────────────┤
│     ホストOS/カーネル    │            │     ホストOS/カーネル    │
├────────────────────┤            ├──────────────────────┤
│      物理ハードウェア     │            │      物理ハードウェア     │
└────────────────────┘            └──────────────────────┘

コンテナにはゲストOSが無い。ホストのカーネルを共有し、その上でプロセスとして直接動く。だから起動は一瞬(ミリ秒)、メモリも数MBで済む。VMが「一軒家を建てる」なら、コンテナは「一つの家を間仕切りで区切る」ようなものだ。

ではその「間仕切り」は何でできているのか。答えは、Linuxカーネルの2つの機能——namespaces(何が見えるかの分離)とcgroups(どれだけ使えるかの制限)だ。


厨房のアナロジー — シェアキッチンの間仕切り

大きな一つの厨房(ホストOS)を、複数の料理人(コンテナ)でシェアする状況を考えよう。VM方式なら、料理人ごとに独立した厨房を建て増しする——贅沢だが場所も金もかかる。

コンテナ方式は違う。一つの厨房を間仕切りで区切る。

私が働いていた飲食店でも、忙しい時間帯は一つの厨房を持ち場(担当エリア)で分けていた。「あなたは前菜、あなたは焼き場」と見える範囲と使える設備を区切ることで、狭い厨房でも複数人が衝突せずに回る。コンテナの隔離は、まさにこの持ち場分けの発想だ。


namespaces — 「何が見えるか」の分離

namespace(名前空間)は、「そのプロセスに、システムの何が見えるか」を分離するカーネルの機能だ。種類ごとに、異なる資源を隔離する。

namespace分離するもの効果
PIDプロセスIDコンテナ内では自分が PID 1(他のプロセスが見えない)
Mountファイルシステムのマウント独自のファイルツリー(/ から別世界)
Networkネットワークスタック独自のIP・ポート・ルーティング
UTSホスト名独自のホスト名を持てる
IPCプロセス間通信共有メモリ等が隔離される
UserユーザーIDコンテナ内のrootをホストの一般ユーザーに対応づけ

たとえば PID namespace に入ったプロセスは、ps を実行しても自分と自分の子プロセスしか見えない。ホストでは数百のプロセスが動いていても、コンテナの中からはその存在すら観測できない。第7章の「すべてはファイル」でfdが番号札だったように、namespaceは「見えるものの範囲」そのものを付け替える。

生の syscall でコンテナを「手作り」する

Dockerを使わず、システムコールだけで簡易コンテナを作れる。核心は clone や unshare システムコールに渡すフラグだ。

#define _GNU_SOURCE
#include <sched.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>

static char child_stack[1024 * 1024];

/* 子プロセスとして実行される中身 */
int child_fn(void *arg) {
    /* この中では、新しい UTS/PID/Mount namespace が有効。
       ホスト名を変えても、ホスト側には影響しない */
    sethostname("in-container", 12);
    printf("コンテナ内 PID: %d\n", getpid());  /* → 1 と表示される! */
    execlp("/bin/sh", "sh", NULL);             /* シェルを起動 */
    return 0;
}

int main(void) {
    /* CLONE_NEW* フラグで「新しい名前空間」を要求する = これが隔離の本体 */
    int flags = CLONE_NEWUTS   /* ホスト名を分離 */
              | CLONE_NEWPID   /* プロセスIDを分離 */
              | CLONE_NEWNS    /* マウントを分離 */
              | SIGCHLD;
    clone(child_fn, child_stack + sizeof(child_stack), flags, NULL);
    wait(NULL);
    return 0;
}

getpid() が 1 を返すのが決定的だ。ホスト上では、このプロセスは実際には PID 12345 かもしれない。だがPID namespaceの中では「自分が最初のプロセス(PID 1)」に見える。コンテナの独立性は、この見え方の付け替えで作られている。Dockerは、これらのフラグ設定を人間に優しく包んだツールにすぎない。


cgroups — 「どれだけ使えるか」の制限

namespaceは「何が見えるか」を分けるが、資源の量は制限しない。放っておけば、一つのコンテナがCPUを100%使い切り、メモリを食い尽くして、ホストごと巻き添えにする。これを防ぐのが cgroups(control groups) だ。

cgroupsは、プロセスのグループごとにCPU・メモリ・ディスクI/O・ネットワーク帯域の上限を設定する。Linuxでは擬似ファイルシステム(/sys/fs/cgroup/)として公開されており、第7章の「すべてはファイル」らしく、ファイルへの書き込みで制限を設定する。

# メモリ上限を 100MB に制限する例(cgroups v2)
mkdir /sys/fs/cgroup/mygroup
echo 104857600 > /sys/fs/cgroup/mygroup/memory.max   # 100MB = 100 * 1024 * 1024
echo $$        > /sys/fs/cgroup/mygroup/cgroup.procs  # 今のシェルをこのグループに入れる

# これ以降、このシェルとその子孫は 100MB を超えるとOOM Killerに殺される

CPUの配分も、比率や上限で指定できる。たとえば「このコンテナには全CPU時間の20%まで」と決めるなら、周期あたりの使用可能時間を制限する:

CPU使用率上限=cpu.max_quotacpu.max_period=20,000 μs100,000 μs=0.2=20%\text{CPU使用率上限} = \frac{\texttt{cpu.max\_quota}}{\texttt{cpu.max\_period}} = \frac{20{,}000\,\mu s}{100{,}000\,\mu s} = 0.2 = 20\%

docker run --memory=100m --cpus=0.2 ... というお馴染みのオプションは、内部でこのcgroupsの値を設定しているだけだ。Kubernetesの resources.limits も、辿ればここに行き着く。


Docker が本当にやっていること

ここまでで、Dockerの「魔法」を分解できる。docker run nginx を実行したとき、Dockerが行うのはおおよそ次の手順だ。

docker run nginx
   │
   ├─(1) イメージ層を重ねて root ファイルシステムを用意(OverlayFS)
   │       └ これが Mount namespace の「新しい / 」になる
   │
   ├─(2) 新しい namespaces を作成(PID/Net/Mount/UTS/IPC/User)
   │       └ 「何が見えるか」を隔離
   │
   ├─(3) cgroups で資源上限を設定(CPU/メモリ/...)
   │       └ 「どれだけ使えるか」を制限
   │
   └─(4) その隔離環境の中で nginx プロセスを起動
           └ nginx から見れば、自分専用のサーバーの中にいる

つまりコンテナ= 「独自のファイルシステムを持ち(Mount ns)、独自のプロセス空間・ネットワークを持ち(PID/Net ns)、資源上限を課された(cgroups)、ただのプロセス」 だ。仮想化された別世界に見えるが、ホストの ps aux を叩けば、nginxはホストのプロセス一覧に普通に並んでいる。

この事実は、コンテナの利点も限界も説明する。ゲストOSが無いから軽量で速い。だがカーネルはホストと共有しているから、VMほど分離は強くない(カーネルの脆弱性はコンテナの壁を越えうる)。「軽さ」と「分離の強度」は、このアーキテクチャの必然的なトレードオフなのだ。

イメージが「軽い差分」で済む理由

Dockerイメージが層(レイヤー)を重ねて作られ、共通部分を使い回せるのも、OverlayFSという「差分を重ねるファイルシステム」のおかげだ。ベースの ubuntu 層は複数イメージで共有し、変更点だけを上に薄く重ねる。これも第7章「すべてはファイル」の思想の延長線上にある。


まとめ — 「おまじない」の消し方

  1. コンテナはVMではない:ゲストOSを持たず、ホストのカーネルを共有する「ただのプロセス」
  2. namespacesが「見える範囲」を分離:PID・ネットワーク・マウント等を隔離し、自分専用のマシンという幻想を作る
  3. cgroupsが「使える量」を制限:CPU・メモリ等の上限をファイル書き込みで設定し、資源の独占を防ぐ
  4. Dockerは包み紙:namespaces + cgroups + OverlayFS の設定を、人間に優しくまとめたツール
  5. 軽さと分離はトレードオフ:カーネル共有ゆえに軽量だが、VMほど強く隔離はされない

「なぜコンテナは一瞬で起動するのか」「docker run --memory は何をしているのか」——その答えは、カーネルの隔離機能そのものにあった。これでPart 3——カーネルが作る分離と幻想の世界——を一巡した。

次のPart 4では、視点を「一台のマシンの中」から「大量の要求を捌くサーバー」へ移す。1万の接続を同時に相手にするとき、素朴なプログラムはなぜ破綻するのか。次章、C10K問題とepollへ。


「魔法だと思っていたものが、ただの仕組みの組み合わせだと分かるとき、人は初めてそれを支配できる。コンテナは魔法ではない。カーネルが元から持っていた力の、賢い使い方だった。」