Linux Kernel / Containers
Linux カーネルの仕組みから理解するコンテナの正体
コンテナは小さな仮想マシンではない。1 つのカーネルの上で動く、ふつうの Linux プロセスだ。この記事では、そのプロセスがどう起動するのか、なぜ自分だけの世界にいるように見えるのかを、カーネルの中の動きで説明する。扱うのは clone(2) の名前空間フラグ、task_struct と nsproxy、cgroup、OverlayFS だ。
01コンテナはプロセスである
コンテナを初めて使うと、「軽い仮想マシン」として理解したくなる。中に入ればホスト名もプロセス一覧もファイルシステムも別物に見えるからだ。しかしカーネルから見ると、コンテナの中身はホストのプロセスツリーにぶら下がった Linux プロセスにすぎない。この章では、その事実を手元で確かめ、仮想マシンとの違いを図で整理する。読み終えると、以降の章で扱う 3 つの仕組み(名前空間・cgroup・OverlayFS)がなぜ必要なのかがわかる。
ホストの ps に、コンテナの中身がそのまま出る
まず手を動かしてみよう。Docker で nginx を起動し、ホスト側で ps を見る。
$ docker run -d --name web nginx
$ ps -eo pid,ppid,comm | grep -E 'nginx|shim'
4310 1 containerd-shim
4321 4310 nginx
4350 4321 nginx
$ grep NSpid /proc/4321/status
NSpid: 4321 1
nginx はホストの PID 4321 として動いている。仮想マシンなら、ゲスト OS の中のプロセスはホストからは見えず、ホストに見えるのは QEMU などのプロセス 1 つだけだ。ここではコンテナの中身そのものがホストのプロセスとして並んでいる。
もう 1 つ注目したいのが NSpid 行だ。同じプロセスが、ホストから見ると 4321、コンテナの中から見ると 1 という 2 つの PID を持っている。1 つのプロセスが見る側によって違う番号を持つ。この「見え方を切り替える」仕掛けが、第 3 章で扱う名前空間である。
仮想マシンとの違いはカーネルの数
図 1-1 に、仮想マシンとコンテナの層の違いを示す。仮想マシンはハイパーバイザーの上でゲストごとにカーネルを起動する。コンテナはホストのカーネル 1 つを共有し、プロセスごとに「何が見えるか」「どれだけ使えるか」「ルートにどのファイルがあるか」だけを変えている。
カーネルが 1 つなので、コンテナはカーネルの起動を待たずに立ち上がり、メモリもゲスト OS の分だけ余計に食うことがない。その代わり、隔離の強さはカーネルがプロセスごとに見せ方を変える仕組みに全部かかっている。
コンテナを作る 3 つの部品
「コンテナ」という名前のカーネル機能は存在しない。コンテナランタイム(runc など)は、次の 3 つの独立した機能を組み合わせて、プロセスを「コンテナらしく」しているだけだ。
- 名前空間
- プロセスから見えるもの(PID、マウント、ネットワーク、ホスト名など)を切り替える。第 3 章で扱う
- cgroup
- プロセスの集まりが使える CPU・メモリ・プロセス数などの量を制限し、数える。第 2 章で扱う
- OverlayFS
- 読み取り専用のイメージ層の上に書き込み層を重ね、コンテナのルートファイルシステムを作る。第 4 章で扱う
読み終えるころには、docker run の裏でこの 3 つがどの順に使われ、カーネルの中でどのデータ構造が書き換わるのかを説明できるようになる。
この章のまとめ
- コンテナの中身はホストのプロセスツリーに並ぶふつうの Linux プロセスだ
- 仮想マシンとの違いはカーネルの数で、コンテナはホストのカーネル 1 つを共有する
- 「コンテナ」というカーネル機能はなく、名前空間・cgroup・OverlayFS の組み合わせでできている
次の章では、ランタイムがこの 3 つを使ってプロセスを起動する手順を見る。
02起動の流れとプロセスツリー
コンテナの起動は、特別なカーネル呼び出し 1 回で済むわけではない。ランタイムは clone(2)、cgroup ファイルへの書き込み、mount(2)、pivot_root(2)、execve(2) という、どれも昔からあるシステムコールを決まった順に呼ぶ。この章では runc を例にその順序を説明し、起動したプロセスがホストのプロセスツリーのどこに置かれるかを確認する。読み終えると、コンテナの起動に失敗したとき、どの手順で止まったのかを見当付けられるようになる。
起動は 5 つのシステムコールの組み合わせ
図 2-1 に、runc がコンテナを起動するときの流れを簡略化して示す。実際の runc は C で書いた nsexec の中で何段かに分けて fork するが、カーネルから見た要点は次の 5 つの手順に収まる。
- clone。親が
CLONE_NEW*フラグを付けてclone(2)を呼ぶ。カーネルはフラグごとに新しい名前空間を作り、子プロセスをその中に置く。 - cgroup への登録。親が、コンテナ用に作った cgroup の
cgroup.procsに子の PID を書く。子がこの後に作るプロセスは、同じ cgroup に自動で入る。 - マウントの準備。子は新しいマウント名前空間の中で、イメージを展開したディレクトリ(rootfs)を自分自身に bind mount してマウントポイントにし、
/proc、/dev、/sysなどを重ねる。 - ルートの切り替え。子は rootfs に
chdirしてpivot_root(".", ".")を呼び、古いルートをumount2(".", MNT_DETACH)で外す。これでホストのファイルシステムはこのマウント名前空間から見えなくなる。 - execve。最後に子は
execve(2)でユーザーが指定したコマンドに置き換わる。PID は変わらないので、このコマンドがコンテナの PID 1 になる。
clone の CLONE_NEW* フラグ
clone(2) はもともとスレッドやプロセスを作るシステムコールで、フラグで「親と何を共有するか」を選ぶ。CLONE_NEW* はその逆で、「親と共有せず、新しく作る」名前空間を指定する。フラグを 1 つも付けなければ、子は親と同じ名前空間にいる。ここがコンテナとふつうのプロセスの分かれ目になる。
既に動いているプロセスが自分の名前空間を切り替えるには unshare(2) を、既存の名前空間に入るには setns(2) を使う。docker exec がコンテナの中でシェルを開けるのは、setns(2) でコンテナの名前空間に入ってから execve(2) しているからだ。
chroot ではなく pivot_root でルートを付け替える
ルートを変えるだけなら chroot(2) もある。しかし chroot(2) は呼んだプロセスのルートディレクトリを変えるだけで、古いルートのマウントは残る。そのため、権限があれば chroot の外へ抜け出せる。
pivot_root(2) はマウントの木そのものを組み替える。新しいルート(new_root)をマウント名前空間のルートに据え、古いルートを put_old に移す。さらにカーネルは、同じマウント名前空間の中で古いルートをルートやカレントディレクトリにしていたプロセスを探し、すべて新しいルートに付け替える。その後で古いルートをアンマウントすれば、ホストのファイルシステムへの経路はこの名前空間から消える。
Warning
pivot_root(2) には前提条件が 2 つある。1 つは、new_root がマウントポイントであること(ただのディレクトリなら bind mount でマウントポイントにする)。もう 1 つは、new_root の親マウントと現在のルートの親マウントの伝播設定が MS_SHARED でないことだ。systemd のホストでは / が shared なので、ランタイムはまずマウント名前空間の中でルートを MS_PRIVATE か MS_SLAVE に変えてから切り替える。
cgroup への登録で使える量を縛る
名前空間は「何が見えるか」を変えるだけで、CPU やメモリを使いすぎるのは止めない。それを止めるのが cgroup(control group)だ。cgroup v2 では /sys/fs/cgroup 以下のディレクトリ 1 つが 1 つの cgroup で、そこにあるファイルに値を書くと制限がかかる。
# mkdir /sys/fs/cgroup/demo
# echo "50000 100000" > /sys/fs/cgroup/demo/cpu.max # 100ms あたり 50ms まで
# echo 256M > /sys/fs/cgroup/demo/memory.max # 256 MiB を超えたら OOM
# echo 100 > /sys/fs/cgroup/demo/pids.max # fork 爆弾よけ
# echo 4321 > /sys/fs/cgroup/demo/cgroup.procs
ランタイムがやっていることもこれと同じだ。ホストが systemd で動いている場合は、systemd に scope ユニットを作ってもらい、同じ操作を任せる。Linux 5.7 以降は clone3(2) の CLONE_INTO_CGROUP で、プロセスを作った瞬間から指定の cgroup に入れることもできる。
ホストのプロセスツリーのどこにいるか
起動が終わると、runc 自身は終了する。runc は shim の子として動き、コンテナのプロセスを残して抜ける。残ったプロセスの親は、containerd が起動した containerd-shim-runc-v2 になる。図 2-2 は、例 1-1 の状態をホストのプロセスツリーとして描いたものだ。
コンテナのプロセスは、ホストのプロセスツリーの中に直接置かれている。別のカーネルもなければ、間に挟まる仮想ハードウェアもない。ホストの root は kill 4321 でコンテナを止められるし、/proc/4321/root をたどればコンテナのルートファイルシステムをホスト側から覗ける。
この章のまとめ
- 起動は clone、cgroup への登録、mount、pivot_root、execve の組み合わせでできている
CLONE_NEW*は「親と共有せず新しく作る」名前空間を指定するフラグだpivot_root(2)はマウントの木を組み替えるので、古いルートを外せばホストへの経路が消える- cgroup は
cgroup.procsへの PID の書き込みで登録し、使える量を縛る - 起動後のコンテナは shim の子として、ホストのプロセスツリーに直接並ぶ
次の章では、clone で作った名前空間のカーネル内部の実装を見る。
03名前空間による見え方の分離
第 1 章で、同じ nginx がホストからは PID 4321、コンテナの中からは PID 1 に見えることを確かめた。この章では、その切り替えがカーネルの中でどう実装されているかを説明する。鍵になるのは、プロセスを表す構造体 task_struct が持つ nsproxy へのポインタ 1 本だ。読み終えると、「名前空間とは、カーネルがグローバルに持っていた表をプロセスごとに切り替えられるようにしたもの」と説明できるようになる。
名前空間は 8 種類ある
表 3-1 に、現在の Linux にある名前空間をまとめる。どれも「以前はカーネルに 1 つしかなかった表」を複数持てるようにしたものだ。
表 3-1Linux の名前空間と、それぞれが分ける表
| 名前空間 | clone のフラグ | 分けるもの | ポインタの置き場 | 導入 |
|---|---|---|---|---|
| Mount | CLONE_NEWNS | マウントの木 | nsproxy->mnt_ns | 2.4.19 |
| UTS | CLONE_NEWUTS | ホスト名、NIS ドメイン名 | nsproxy->uts_ns | 2.6.19 |
| IPC | CLONE_NEWIPC | System V IPC、POSIX メッセージキュー | nsproxy->ipc_ns | 2.6.19 |
| PID | CLONE_NEWPID | プロセス ID の番号 | nsproxy->pid_ns_for_children | 2.6.24 |
| Network | CLONE_NEWNET | ネットワークデバイス、経路表、ファイアウォール、ポート | nsproxy->net_ns | 2.6.29 |
| User | CLONE_NEWUSER | UID / GID の対応とケーパビリティの効く範囲 | cred->user_ns | 3.8 |
| Cgroup | CLONE_NEWCGROUP | cgroup 階層のどこをルートに見せるか | nsproxy->cgroup_ns | 4.6 |
| Time | CLONE_NEWTIME | CLOCK_MONOTONIC と CLOCK_BOOTTIME のずれ | nsproxy->time_ns | 5.6 |
Mount のフラグだけ CLONE_NEWNS と名前が素っ気ないのは、当時は名前空間がこれ 1 種類しかなく、将来ほかの種類が増えるとは考えられていなかったからだ。
task_struct から nsproxy へのポインタ
カーネルはプロセス(正確にはスレッド)を task_struct という構造体で表す。その中に struct nsproxy *nsproxy というポインタがあり、指す先の nsproxy に、プロセスが属する名前空間へのポインタがまとめてある。
struct nsproxy {
refcount_t count;
struct uts_namespace *uts_ns;
struct ipc_namespace *ipc_ns;
struct mnt_namespace *mnt_ns;
struct pid_namespace *pid_ns_for_children;
struct net *net_ns;
struct time_namespace *time_ns;
struct time_namespace *time_ns_for_children;
struct cgroup_namespace *cgroup_ns;
};
図 3-1 は、ホストの sshd と、コンテナの nginx(master と worker)が、それぞれどの nsproxy を指しているかを描いたものだ。
図 3-1 から、実装上の要点が 3 つ読み取れる。
nsproxyは同じ組み合わせの名前空間にいるプロセスどうしで共有される。nginx の master と worker は同じnsproxyを指し、参照カウントcountが 2 になる。fork のたびに 8 本のポインタを複製せずに済むのはこのためだ- 名前空間は種類ごとに独立している。
CLONE_NEWTIMEを付けずに起動したコンテナは、time_nsだけホストと同じものを指す。「コンテナ」という単位はカーネルにはなく、種類ごとに共有するかしないかが決まっているだけだ - User 名前空間は
nsproxyではなく資格情報(cred)の側にある。UID の対応やケーパビリティの効く範囲は「誰として動いているか」の一部として扱われるからだ
clone(2) や unshare(2) で名前空間を変えると、カーネルは新しい nsproxy を作る(create_new_namespaces())。新しい nsproxy は、変える種類だけ新しい名前空間を指し、残りは元の名前空間の参照カウントを増やして引き継ぐ。最後に task_struct のポインタを新しい nsproxy に付け替える。名前空間の切り替えの実体は、このポインタの付け替えだ。
システムコールは current の表を引く
ポインタを付け替えるだけで見え方が変わる理由は、システムコールの書き方にある。名前空間の影響を受けるシステムコールは、グローバル変数ではなく、今動いているプロセス(カーネルの中では current)の名前空間から表を引く。いくつか例を挙げる。
表 3-2システムコールが current から表を引く経路
| 操作 | カーネルが見る場所 | 結果 |
|---|---|---|
uname(2)、gethostname | current->nsproxy->uts_ns->name | コンテナ ID のようなホスト名が返る |
socket(2)、bind(2) | current->nsproxy->net_ns のデバイス一覧・経路表・ポート表 | ホストが 80 番を使っていても、コンテナは自分の 80 番を bind できる |
open(2) のパス解決 | current->fs->root から始め、mnt_ns に属するマウントをたどる | pivot_root 後の rootfs が / に見える |
getpid(2) | 自分の struct pid の、今いる PID 名前空間の段の番号 | コンテナの中では 1 が返る |
shmget(2) | current->nsproxy->ipc_ns の ID 表 | ホストの共有メモリのキーと衝突しない |
表 3-2 のどの操作も、表が 1 つしかなかった時代のコードに「どの名前空間の表か」を 1 つ足しただけだ。名前空間の正体は、カーネルの表をプロセスごとに引き分ける仕組みだ。
PID 名前空間は入れ子で、番号を段ごとに持つ
PID 名前空間だけは他と少し作りが違う。PID 名前空間は親子の入れ子になり、子の名前空間のプロセスは親の名前空間からも見える。そのため 1 つのプロセスが、入れ子の段ごとに別の PID を持つ。
プロセスの PID を表す struct pid には numbers[] という配列があり、段ごとに「どの名前空間で何番か」を記録している。getpid(2) は、呼んだプロセスがいる段の番号を返す。例 1-1 の NSpid: 4321 1 は、この配列をそのまま並べたものだ。
nsproxy のフィールド名が pid_ns ではなく pid_ns_for_children なのにも理由がある。プロセスの PID は生まれたときに決まり、後から変えられない。だから unshare(CLONE_NEWPID) を呼んでも呼んだ本人の PID 名前空間は変わらず、変わるのは「これから作る子をどの PID 名前空間に置くか」だけだ。本人が今いる PID 名前空間は、nsproxy ではなく struct pid の側から求める(task_active_pid_ns())。
Note
PID 名前空間の PID 1 は特別扱いされる。PID 1 が終了すると、カーネルは同じ名前空間の残りのプロセスにすべて SIGKILL を送る。コンテナのメインプロセスが終わるとコンテナ全体が止まるのはこのためだ。また PID 1 は、ハンドラーを登録していないシグナルを無視する(例外は親の名前空間から送られた SIGKILL と SIGSTOP だけ)。そのため SIGTERM のハンドラーを持たないアプリは、PID 1 として動くと docker stop で止まらない。そういうアプリには tini のような小さな init を挟む。
手元で名前空間を確かめる
プロセスが属する名前空間は /proc/[pid]/ns/ のシンボリックリンクで見える。リンク先の番号(inode 番号)が同じなら、同じ名前空間にいる。
$ sudo readlink /proc/1/ns/net /proc/4321/ns/net
net:[4026531840]
net:[4026532571]
$ sudo readlink /proc/1/ns/time /proc/4321/ns/time
time:[4026531834]
time:[4026531834]
$ sudo nsenter -t 4321 -n ip -brief addr # コンテナの Net 名前空間に入って実行
lo UNKNOWN 127.0.0.1/8
eth0@if12 UP 172.17.0.2/16
nsenter は内部で setns(2) を呼び、指定した名前空間に入ってからコマンドを実行する。コンテナの中に ip コマンドがなくても、ホストの ip コマンドをコンテナの Network 名前空間の中で実行できる。
この章のまとめ
task_structはnsproxyを指し、nsproxyが種類ごとの名前空間を指す- 名前空間の切り替えは、新しい
nsproxyを作ってポインタを付け替える操作だ - システムコールは
currentの名前空間から表を引くので、同じ呼び出しでも返る結果が変わる - PID 名前空間は入れ子で、
struct pidが段ごとの番号を持つ
次の章では、コンテナのルートに見えるファイルシステムを OverlayFS がどう組み立てているかを見る。
04OverlayFS の層構造と Copy-on-Write
名前空間でマウントの木を分けても、ルートに置くファイルシステムを毎回イメージから丸ごとコピーしていたら、起動は遅くディスクも足りない。同じイメージから 100 個のコンテナを起動しても、ディスクに置くのはイメージ 1 つ分で済ませたい。この章では、それを実現している OverlayFS の層構造と、読み書き・削除のときに VFS の下で何が起きるかを説明する。読み終えると、「コンテナの中で消したのにイメージが小さくならない」理由を説明できるようになる。
4 つのディレクトリの役割
OverlayFS は、既存のディレクトリを重ね合わせて 1 つのディレクトリに見せるファイルシステムだ。マウントには 4 つのディレクトリを指定する。
- lowerdir
- 下の層。読み取り専用として扱う。コロン区切りで複数並べられ、左に書いたものほど上に重なる。コンテナではイメージの各層がここに入る
- upperdir
- 上の層。書き込みはすべてここに入る。コンテナごとに 1 つ作る
- workdir
- 作業用の空ディレクトリ。upperdir と同じファイルシステムに置く。ファイルを upperdir に移す前の組み立て場所に使う
- merged
- マウントポイント。上の層と下の層を重ねた結果がここに見える。コンテナの rootfs になる
# mkdir -p /tmp/ov/{base,app,upper,work,merged}
# mount -t overlay overlay \
-o lowerdir=/tmp/ov/app:/tmp/ov/base,upperdir=/tmp/ov/upper,workdir=/tmp/ov/work \
/tmp/ov/merged
図 4-1 は、例 4-1 の構成でどのファイルがどの層から merged に見えているかを描いたものだ。
ディレクトリは層をまたいで中身が合わさる。同じパスのファイルがあれば、上の層のものが見える。/etc/app.conf は base にも app にもあるが、merged には上にある app 版が見える。
読むときは上から探し、見つかった層のファイルを開く
merged の中のファイルを開くと、VFS はパスを 1 段ずつ解決しながら OverlayFS の lookup を呼ぶ。OverlayFS は upperdir から順に下の層へ同じ名前を探し、最初に見つかった層のエントリを採る(ovl_lookup())。
読むだけなら、見つかったのが下の層のファイルでもコピーは起きない。open(2) は下の層のファイルシステムにあるファイルをそのまま開き、read(2) はそのファイルのページキャッシュを読む。同じイメージから起動した 100 個のコンテナが同じ libc.so を読むと、ページキャッシュも 1 つを共有する。コンテナが仮想マシンのディスクイメージより軽いのは、ここにも理由がある。
書くときは最初の書き込みで上の層にコピーする(copy-up)
下の層は読み取り専用なので、下の層にしかないファイルを書き込み用に開くと、OverlayFS はまずファイルを upperdir にコピーする。これを copy-up と呼び、Copy-on-Write(書き込むときに初めてコピーする方式)の実装にあたる。
図 4-2 の Write 列のとおり、copy-up は次の順に進む。
- 上の層に親ディレクトリが無ければ、先に親ディレクトリを(これも copy-up で)作る。
- workdir の中に一時ファイルを作り、データ、所有者やパーミッションなどの属性、拡張属性(xattr)をコピーする。
- 一時ファイルを
fsyncしてから、upperdir の正しい場所へrenameする。 - 以後の書き込みは、upperdir のファイルに入る。
workdir を経由するのは、コピーの途中の中途半端なファイルを merged に見せないためだ。同じファイルシステムの中なら、rename(2) はファイルを一度に置き換える。だから merged から見えるのは、コピー前の下の層のファイルか、コピーが終わった上の層のファイルのどちらかだけだ。workdir を upperdir と同じファイルシステムに置かなければならないのはこのためだ。
copy-up はファイル全体をコピーする(例外は後の Tip で触れる metacopy)。1 GB のデータベースファイルに 1 バイト書いても、1 GB のコピーが走る。コンテナの中で大きなファイルを書き換える用途(データベースやログ)には、OverlayFS を通らないボリュームを使うのが定石だ。
Tip
metacopy=on を付けてマウントすると、chmod や chown のように属性だけ変える操作ではメタデータだけを copy-up し、データのコピーは書き込み用に開かれるまで遅らせる。イメージのビルドで大量のファイルの所有者を変えるときに効く。
消すときは上の層に目印(whiteout)を置く
下の層のファイルは読み取り専用なので、実際には消せない。そこで OverlayFS は upperdir に同じ名前の whiteout を作る。whiteout の実体は、デバイス番号が 0/0 のキャラクターデバイスファイルだ。lookup は上の層から探すので、whiteout に当たった時点で「このファイルは無い」と判断し、下の層までは見に行かない。
ディレクトリを消してから同じ名前で作り直した場合は、upperdir の新しいディレクトリに trusted.overlay.opaque="y" という xattr を付ける。これを opaque ディレクトリと呼ぶ。lookup はここで探すのを止め、下の層にある同名ディレクトリの中身を合わせない。
# rm /tmp/ov/merged/tmp/old
# ls -l /tmp/ov/upper/tmp/
c--------- 1 root root 0, 0 Sep 30 10:12 old
# ls /tmp/ov/base/tmp/
old
ここから、冒頭の問いに答えが出る。Dockerfile の後の行で rm しても、前の行で追加したファイルは下の層に残り、上の層に whiteout が増えるだけだ。イメージを小さくしたければ、同じ RUN の中で作って消すか、マルチステージビルドで必要なものだけを最終イメージにコピーする。
Docker の overlay2 はこの構成を並べたもの
Docker の overlay2 ストレージドライバーは、イメージの各層を /var/lib/docker/overlay2/<id>/diff に展開し、コンテナを作るたびに upperdir と workdir を新しく作る。マウントの形は例 4-1 と同じだ。実行中のコンテナで mount | grep overlay を見ると、長い lowerdir= の並びが確認できる(パスが長くなりすぎないよう、l/ 以下の短いシンボリックリンクで指している)。
この章のまとめ
- OverlayFS は lowerdir(イメージ)の上に upperdir(コンテナごと)を重ね、merged に合わせた結果を見せる
- 読むときはコピーせず、下の層のファイルとページキャッシュをそのまま使う
- 書くときは workdir で組み立ててから rename する copy-up で、ファイル全体を上の層に移す
- 消すときは上の層に whiteout(0/0 のキャラクターデバイス)を置き、下の層は残る
次の章では、ここまでの 3 つの仕組みを 1 枚にまとめ、コンテナの隔離の限界にも触れる。
05まとめ
ここまでで、コンテナを形作る 3 つの仕組みをカーネルの中から見てきた。最後に、それぞれが何を担っているかを 1 枚の図にまとめ、この構成で隔離できないものを確認しておく。
3 つの仕組みの役割分担
表 5-1コンテナで目にする現象と、それを起こしている仕組み
| コンテナで目にすること | 仕組み | カーネルの中 |
|---|---|---|
中で ps すると自分が PID 1 | PID 名前空間 | struct pid の段ごとの番号 |
| ホスト名がコンテナ ID | UTS 名前空間 | nsproxy->uts_ns |
| ホストと同じポートを bind できる | Network 名前空間 | nsproxy->net_ns ごとのポート表 |
/ がイメージの中身 | Mount 名前空間 + pivot_root + OverlayFS | mnt_ns、fs->root、ovl_lookup() |
| メモリを使いすぎると OOM で落ちる | cgroup | memory.max |
| コンテナを削除すると、中で書いたファイルもなくなる | OverlayFS | upperdir がコンテナごと |
| 起動が 1 秒かからない | 3 つすべて | カーネルを起動せず、コピーもしない |
カーネルを共有していることの意味
コンテナが速くて軽いのは、カーネルを 1 つしか使わないからだ。同じ理由で、隔離の強さもカーネル 1 つに全部かかっている。名前空間が分けるのは、システムコールが引く表だけだ。システムコールの処理そのものは、どのコンテナから呼んでも同じカーネルのコードを通る。カーネルに脆弱性があれば、コンテナの中から外へ抜けられる可能性がある。
そのため実際のランタイムは、この記事の 3 つに加えて、root の権限を細かく削るケーパビリティ、呼べるシステムコールを絞る seccomp、AppArmor や SELinux といった LSM を重ねて使う。それでも足りない場面では、gVisor(ユーザー空間でシステムコールを受け止める)や Kata Containers(軽量な仮想マシンの中でコンテナを動かす)のように、カーネルの共有そのものをやめる選択肢がある。
この記事のまとめ
- コンテナは、ホストのプロセスツリーに並ぶふつうの Linux プロセスだ
- 名前空間は、
nsproxyのポインタを付け替えることで、システムコールが引く表をプロセスごとに切り替える - cgroup は、そのプロセスの集まりが使える CPU・メモリ・プロセス数を縛る
- OverlayFS は、イメージの層を共有したまま書き込みだけを上の層に集め、ルートを軽く作る
- 3 つとも単独のカーネル機能で、ランタイムが clone、mount、pivot_root、execve の順に組み合わせている