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
例 1-1コンテナの nginx はホストの ps にそのまま現れ、PID を 2 つ持つ

nginx はホストの PID 4321 として動いている。仮想マシンなら、ゲスト OS の中のプロセスはホストからは見えず、ホストに見えるのは QEMU などのプロセス 1 つだけだ。ここではコンテナの中身そのものがホストのプロセスとして並んでいる。

もう 1 つ注目したいのが NSpid 行だ。同じプロセスが、ホストから見ると 4321、コンテナの中から見ると 1 という 2 つの PID を持っている。1 つのプロセスが見る側によって違う番号を持つ。この「見え方を切り替える」仕掛けが、第 3 章で扱う名前空間である。

仮想マシンとの違いはカーネルの数

図 1-1 に、仮想マシンとコンテナの層の違いを示す。仮想マシンはハイパーバイザーの上でゲストごとにカーネルを起動する。コンテナはホストのカーネル 1 つを共有し、プロセスごとに「何が見えるか」「どれだけ使えるか」「ルートにどのファイルがあるか」だけを変えている。

仮想マシン アプリ + ライブラリ ゲストのユーザー空間 ゲスト OS カーネル アプリ + ライブラリ ゲストのユーザー空間 ゲスト OS カーネル ハイパーバイザー(KVM など) ホスト OS カーネル ハードウェア コンテナ プロセス 名前空間: 見える範囲 cgroup: 使える量 OverlayFS: ルート コンテナ A プロセス 名前空間: 見える範囲 cgroup: 使える量 OverlayFS: ルート コンテナ B sshd など ホストの ふつうのプロセス ホスト OS カーネル(1 つだけ) ハードウェア
図 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 つの手順に収まる。

ランタイム(親) 子プロセス カーネル 1 clone(CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWUTS | CLONE_NEWIPC …) 新しい名前空間の中に子を作る 2 write(cgroup.procs, 子の PID) 3 mount(rootfs を bind、/proc など) 4 pivot_root(".", ".") と umount2(".", MNT_DETACH) 5 execve("/docker-entrypoint.sh", …) ユーザーのコマンドとして動く
図 2-1ランタイムは clone、cgroup への登録、マウント、pivot_root、execve の順にプロセスを仕立てる
  1. clone。親が CLONE_NEW* フラグを付けて clone(2) を呼ぶ。カーネルはフラグごとに新しい名前空間を作り、子プロセスをその中に置く。
  2. cgroup への登録。親が、コンテナ用に作った cgroup の cgroup.procs に子の PID を書く。子がこの後に作るプロセスは、同じ cgroup に自動で入る。
  3. マウントの準備。子は新しいマウント名前空間の中で、イメージを展開したディレクトリ(rootfs)を自分自身に bind mount してマウントポイントにし、/proc、/dev、/sys などを重ねる。
  4. ルートの切り替え。子は rootfs に chdir して pivot_root(".", ".") を呼び、古いルートを umount2(".", MNT_DETACH) で外す。これでホストのファイルシステムはこのマウント名前空間から見えなくなる。
  5. 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
例 2-1cgroup v2 のディレクトリを作り、制限を書き、PID を登録する

ランタイムがやっていることもこれと同じだ。ホストが systemd で動いている場合は、systemd に scope ユニットを作ってもらい、同じ操作を任せる。Linux 5.7 以降は clone3(2) の CLONE_INTO_CGROUP で、プロセスを作った瞬間から指定の cgroup に入れることもできる。

ホストのプロセスツリーのどこにいるか

起動が終わると、runc 自身は終了する。runc は shim の子として動き、コンテナのプロセスを残して抜ける。残ったプロセスの親は、containerd が起動した containerd-shim-runc-v2 になる。図 2-2 は、例 1-1 の状態をホストのプロセスツリーとして描いたものだ。

systemd PID 1 containerd PID 890 sshd PID 1020 containerd-shim-runc-v2 PID 4310 bash PID 2211 PID 名前空間(コンテナの中からはこの枠だけが見える) nginx(master) ホスト PID 4321 / コンテナ内 PID 1 nginx(worker) ホスト PID 4350 / コンテナ内 PID 7
図 2-2コンテナのプロセスは shim の子としてホストのプロセスツリーに直接ぶら下がる

コンテナのプロセスは、ホストのプロセスツリーの中に直接置かれている。別のカーネルもなければ、間に挟まる仮想ハードウェアもない。ホストの 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 のフラグ分けるものポインタの置き場導入
MountCLONE_NEWNSマウントの木nsproxy->mnt_ns2.4.19
UTSCLONE_NEWUTSホスト名、NIS ドメイン名nsproxy->uts_ns2.6.19
IPCCLONE_NEWIPCSystem V IPC、POSIX メッセージキューnsproxy->ipc_ns2.6.19
PIDCLONE_NEWPIDプロセス ID の番号nsproxy->pid_ns_for_children2.6.24
NetworkCLONE_NEWNETネットワークデバイス、経路表、ファイアウォール、ポートnsproxy->net_ns2.6.29
UserCLONE_NEWUSERUID / GID の対応とケーパビリティの効く範囲cred->user_ns3.8
CgroupCLONE_NEWCGROUPcgroup 階層のどこをルートに見せるかnsproxy->cgroup_ns4.6
TimeCLONE_NEWTIMECLOCK_MONOTONIC と CLOCK_BOOTTIME のずれnsproxy->time_ns5.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-1include/linux/nsproxy.h の struct nsproxy

図 3-1 は、ホストの sshd と、コンテナの nginx(master と worker)が、それぞれどの nsproxy を指しているかを描いたものだ。

task_struct(sshd) nsproxy cred->user_ns thread_pid task_struct(nginx master) nsproxy thread_pid task_struct(nginx worker) nsproxy thread_pid nsproxy 1(count = 多数) uts_ns ipc_ns mnt_ns pid_ns_for_children net_ns time_ns cgroup_ns nsproxy 2(count = 2) uts_ns ipc_ns mnt_ns pid_ns_for_children net_ns time_ns cgroup_ns 初期名前空間(起動時に 1 組だけ作られる) init_uts_ns: hostname = "host01" init_net: eth0、lo、ホストの経路表 init_pid_ns: PID 1 = systemd 初期の mnt_ns: ホストの / 以下 init_time_ns、init_ipc_ns … clone で作った名前空間 uts_ns: hostname = "3f2a9c1b" net: eth0(veth の片側)、lo pid_ns: PID 1 = nginx mnt_ns: イメージの rootfs 以下 ipc_ns、cgroup_ns 共有
図 3-1プロセスは nsproxy を指し、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)、gethostnamecurrent->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 名前空間(level 0) 1 systemd 890 containerd 4310 containerd-shim コンテナの PID 名前空間(level 1) 1 nginx(master) … 外からは 4321 7 nginx(worker) … 外からは 4350 外の PID は内側からは見えない struct pid(nginx master) level = 1 numbers[0]: nr = 4321 ns = 初期 PID 名前空間 numbers[1]: nr = 1 ns = コンテナの PID 名前空間
図 3-2struct pid は入れ子の段ごとに 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
例 3-2net は別の名前空間、time はホストと共有していることが inode 番号でわかる

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-12 つの下の層(app が上、base が下)と上の層を重ねてマウントする

図 4-1 は、例 4-1 の構成でどのファイルがどの層から merged に見えているかを描いたものだ。

層 /bin/sh /etc/app.conf /var/log/app.log /tmp/old merged コンテナの / に見える sh base から app.conf(v2) app から app.log upper から (見えない) upperdir 書き込み層(コンテナごと) app.log whiteout c 0,0 lowerdir: app イメージの上の層 app.conf(v2) lowerdir: base イメージの下の層 sh app.conf(v1) old ここで止まる workdir copy-up の組み立て場所。upperdir と同じファイルシステムに置き、完成したら upperdir に rename する
図 4-1merged には、上の層から順に探して最初に見つかったファイルが見える

ディレクトリは層をまたいで中身が合わさる。同じパスのファイルがあれば、上の層のものが見える。/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(書き込むときに初めてコピーする方式)の実装にあたる。

Read Write(copy-up) Delete(whiteout) 1 upperdir を探す: 無い 2 lowerdir を上から探す: 見つかる 3 下の層のファイルをそのまま開いて読む コピーは起きない 1 open(O_WRONLY) が下の層のファイルに当たる 2 workdir に一時ファイルを作り データ・属性・xattr をコピー 3 fsync して upperdir へ rename 4 以後は upperdir のファイルに書く 1 バイト書くだけでもファイル全体をコピー 1 unlink が下の層のファイルに当たる 2 upperdir に同名の whiteout を作る キャラクターデバイス 0/0 3 lookup は whiteout で止まり ENOENT 下の層のファイルは消えずに残る ディレクトリを作り直した場合は xattr trusted.overlay.opaque = "y"
図 4-2読むときはコピーせず、書くときは copy-up し、消すときは whiteout を置く

図 4-2 の Write 列のとおり、copy-up は次の順に進む。

  1. 上の層に親ディレクトリが無ければ、先に親ディレクトリを(これも copy-up で)作る。
  2. workdir の中に一時ファイルを作り、データ、所有者やパーミッションなどの属性、拡張属性(xattr)をコピーする。
  3. 一時ファイルを fsync してから、upperdir の正しい場所へ rename する。
  4. 以後の書き込みは、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
例 4-2削除すると upperdir に 0, 0 のキャラクターデバイスが現れ、下の層のファイルは残る

ここから、冒頭の問いに答えが出る。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 つの仕組みの役割分担

名前空間 何が見えるか nsproxy のポインタで PID・マウント・ネットワーク・ ホスト名などの表を切り替える 1 つの Linux プロセス task_struct がふつうに 1 つあるだけ スケジューラーもメモリ管理も ホストのプロセスと同じ OverlayFS ルートに何があるか イメージの層に書き込み層を重ね、 書くときだけ copy-up、 消すときは whiteout cgroup: どれだけ使えるか CPU・メモリ・プロセス数を制限し数える 土台: ホスト OS カーネル(1 つ)
図 5-1コンテナはふつうのプロセスに見え方・使える量・ルートの 3 つを後から被せたもの

表 5-1コンテナで目にする現象と、それを起こしている仕組み

コンテナで目にすること仕組みカーネルの中
中で ps すると自分が PID 1PID 名前空間struct pid の段ごとの番号
ホスト名がコンテナ IDUTS 名前空間nsproxy->uts_ns
ホストと同じポートを bind できるNetwork 名前空間nsproxy->net_ns ごとのポート表
/ がイメージの中身Mount 名前空間 + pivot_root + OverlayFSmnt_ns、fs->root、ovl_lookup()
メモリを使いすぎると OOM で落ちるcgroupmemory.max
コンテナを削除すると、中で書いたファイルもなくなるOverlayFSupperdir がコンテナごと
起動が 1 秒かからない3 つすべてカーネルを起動せず、コピーもしない

カーネルを共有していることの意味

コンテナが速くて軽いのは、カーネルを 1 つしか使わないからだ。同じ理由で、隔離の強さもカーネル 1 つに全部かかっている。名前空間が分けるのは、システムコールが引く表だけだ。システムコールの処理そのものは、どのコンテナから呼んでも同じカーネルのコードを通る。カーネルに脆弱性があれば、コンテナの中から外へ抜けられる可能性がある。

そのため実際のランタイムは、この記事の 3 つに加えて、root の権限を細かく削るケーパビリティ、呼べるシステムコールを絞る seccomp、AppArmor や SELinux といった LSM を重ねて使う。それでも足りない場面では、gVisor(ユーザー空間でシステムコールを受け止める)や Kata Containers(軽量な仮想マシンの中でコンテナを動かす)のように、カーネルの共有そのものをやめる選択肢がある。

この記事のまとめ

  • コンテナは、ホストのプロセスツリーに並ぶふつうの Linux プロセスだ
  • 名前空間は、nsproxy のポインタを付け替えることで、システムコールが引く表をプロセスごとに切り替える
  • cgroup は、そのプロセスの集まりが使える CPU・メモリ・プロセス数を縛る
  • OverlayFS は、イメージの層を共有したまま書き込みだけを上の層に集め、ルートを軽く作る
  • 3 つとも単独のカーネル機能で、ランタイムが clone、mount、pivot_root、execve の順に組み合わせている