コンテナの中で /etc を開くと、ホストとは別のファイルが見えます。
プロセス一覧も分かれているのに、使っているカーネルはホストと同じです。
Part 1 で見たこの状態を、Linux はどう作っているのでしょうか。
この章では、アプリ用のファイルを用意し、見える範囲と使える量を設定してから起動するまでを追います。 次の章で手作りするコンテナの、準備にあたる章です。
GOAL この章のゴール
- コンテナの起動準備を、Linux の機能に分けて説明できる
- コンテナのルートとホストのルートが別に扱われる理由が分かる
- ファイルの変更が書き込み層に保存される仕組みが分かる
chroot から足りないもの
出発点は chroot です。
chroot は、呼んだプロセスの / を別のディレクトリに付け替えるシステムコールです。
ホスト上のディレクトリ (たとえば /srv/ubuntu) に Ubuntu や Alpine のファイル一式を置いて chroot すれば、その中のプロセスは別の OS の上で動いているように振る舞います。
コンテナはこの延長線上にあります。
ただし chroot が付け替えるのは / だけで、プロセス一覧、ネットワーク、hostname はホストと共有されたままです。
chroot した中で ps を打てばホストの全プロセスが見え、ip addr を打てばホストの NIC が見えます。
docker exec で入ったコンテナの中の ps に、ホストのプロセスが出てこなかったのは、これと違うことが起きているからです。
それは、プロセスに見せる一覧 (プロセス、NIC、マウント、hostname など) を、プロセスのグループごとに別々にする Linux カーネルの機能です。
この機能で作った区切りの中では、ps には同じ区切りの中にいるプロセスだけが出ます。
この機能を namespaceネームスペースプロセス一覧、NIC 一覧、マウント一覧などをプロセスのグループごとに別々に持たせる、Linux カーネルの機能。mnt / pid / net / uts / ipc / user / cgroup / time の 8 種類。用語集で見る と呼びます。
もう 1 つ足りないものがあります。
chroot と namespace で区切っても、中のプロセスがホストのメモリを食い尽くすのは止められません。
docker run --memory 256m が効くのは、使える量を決める別の機能があるからです。
CPU、メモリ、プロセス数などの上限を、プロセスの集まりに付けるカーネルの機能が cgroupsシーグループスcontrol group。プロセスの集合に CPU / メモリ / PID 数などの上限を付ける仕組み。v2 では /sys/fs/cgroup の 1 本のツリー。用語集で見る です。
chroot (正確には後述の pivot_root) に namespace と cgroup を足したものが、Linux のコンテナです。
5 ステップ
rootfs を用意する
コンテナの
/になるディレクトリを、rootfsルートエフエスroot filesystem。コンテナのプロセスが/として見るディレクトリ。ホスト上の普通のディレクトリで、中身はイメージから作られる。用語集で見る と呼びます。 Kubernetes のノードでは、containerd がイメージの層を重ねてホスト上に mount し、それを rootfs として runc に渡します。 重ね方は OverlayFS で、次の節で見ます。namespace を新しく作る
runc は
clone(2)をCLONE_NEWNS(mnt)、CLONE_NEWPID、CLONE_NEWNET、CLONE_NEWUTS、CLONE_NEWIPC、CLONE_NEWCGROUPのフラグ付きで呼び、新しい namespace に入った子プロセスを作ります。 pid_namespaces(7) によると、新しい pid namespace で最初に作られたプロセスが PID 1 になり、その namespace の init として振る舞います。 同じプロセスは、親の pid namespace からは別の番号で見えます。 ネットワークは NIC がloしか無い空の状態から始まり、あとで veth を差し込みます (Part 2 で手でやった操作です)。cgroup に入れる
/sys/fs/cgroupにディレクトリを 1 つ作り、cpu.maxとmemory.maxに上限を書き、プロセスの PID をcgroup.procsに書きます。 Pod のresources.limitsは、最終的にこのファイルに書かれた数字になります。 ノードが cgroup v2 かどうかは、stat -fc %T /sys/fs/cgroup/がcgroup2fsを返すかどうかで分かります。pivot_root で / を入れ替える
新しい mount namespace の中で、root mount を rootfs に入れ替えます。 このシステムコールが pivot_rootピボットルート呼んだプロセスの mount namespace の root mount を new_root に入れ替え、古い root mount を put_old の下に移すシステムコール。put_old を umount すると、中から古い root に戻る道が無くなる。用語集で見る です。 仕組みは次の節で 3 段に分けて見ます。
execve でプロセスを起動する
用意した namespace、cgroup、
/の中でexecveします。 Dockerfile のENTRYPOINT/CMDが実行されるのはここです。 これ以降「コンテナ」として残るのは、このプロセスとその子孫だけです。
namespace は種類ごとに独立していて、net だけ新しくして pid は共有する、という組み合わせもできます。
Pod の中のコンテナ同士が同じ IP を持つのは、net namespace を Pod 内で共有しているからです。
一方 pid namespace はコンテナごとに別が既定で、shareProcessNamespace: true を書いたときだけ Pod 内で共有されます。
pivot_root が入れ替えるもの
Part 1 では pivot_root を「chroot と似ていて、元のルートへの参照を切り離せる」と紹介しました。
ここでは、何を何に入れ替えているのかを mount の単位で見ます。
前提になるのは mount namespace の性質です。
mount_namespaces(7) によると、新しい mount namespace を作った直後、その中の mount 一覧は親 (ホスト) の mount 一覧のコピーです。
つまり新しい mount namespace の / は、作った直後はホストの / と同じ root mount を指しています。
1 段目で、新しい mount namespace の中に、containerd が mount した rootfs (OverlayFS) が見えています。
root mount はまだホストの / です。
2 段目の pivot_root(new_root, put_old) が、pivot_root(2) の言葉では「root mount を put_old に移し、new_root を新しい root mount にする」操作です。
rootfs が / になり、古い root mount は rootfs の中のディレクトリ (/oldroot) の下に移ります。
3 段目で /oldroot を umount すると、この mount namespace には rootfs だけが残り、中からホストのファイルシステムへ辿る道が無くなります。
この 3 段の間、ホスト側の mount namespace の root mount はホストの / のままです。
pivot_root が変えるのは、呼んだプロセスが属する mount namespace の root mount だけだからです。
同じ mount namespace にいるプロセスの / は一緒に変わるので、runc は必ず新しい mount namespace を作ってから pivot_root を呼びます。
ホストの mount namespace で呼べば、ホストの全プロセスの / が入れ替わってしまいます。
rootfs を作る OverlayFS
同じ nginx イメージから docker run で 2 つのコンテナを起動し、片方の中で /etc/nginx/nginx.conf を書き換えたとします。
もう片方のコンテナは元の設定のままで、docker pull し直したイメージも変わりません。
書き換えたコンテナを docker rm すると、書き換えも一緒に消えます。
コンテナの / は 1 つのディレクトリに見えるのに、元のファイルは守られ、書き込みはそのコンテナだけに閉じています。
この動きを作っているのが OverlayFSオーバーレイエフエス複数のディレクトリを重ねて 1 つに見せる Linux のファイルシステム。下のディレクトリ (lowerdir) は読み取り専用、書き込みは上のディレクトリ (upperdir) に落ちる。用語集で見る です。
読み取り専用のディレクトリの上に、書き込み用のディレクトリを重ねて、合成した結果を 1 つのディレクトリとして見せます。
mount コマンドで書くと mount -t overlay overlay -o lowerdir=…,upperdir=…,workdir=… merged で、4 つのディレクトリが登場します。
- lowerdir:下に重ねる読み取り専用のディレクトリ (イメージの中身が入ります)。同じイメージから起動した全コンテナで共有されます。複数指定でき、
:区切りで一番左が一番上になります。 - upperdir:コンテナごとの書き込み用ディレクトリ。lowerdir にあるファイルを書き換えると、まずそのファイルが upperdir にコピーされ (copy-up)、それから書き込まれます。
- merged:合成結果。コンテナの rootfs になるのはここです。
- workdir:カーネルが copy-up の途中で使う作業場所。upperdir と同じファイルシステムに置きます。
ファイルを消す操作も upperdir に落ちます。 upperdir に「このファイルは削除済み」を示す特別なファイル (whiteout。デバイス番号 0/0 のキャラクターデバイス) が作られ、merged からは見えなくなります。 lowerdir は一切変わらないので、イメージは不変です。 コンテナを消せば upperdir ごと消えるので、コンテナの中に書いたファイルは残りません。
イメージの層
lowerdir に入るイメージの中身は、いくつかの塊に分かれています。
docker pull の出力に Pull complete の行が何本も流れるのは、その塊を 1 つずつダウンロードしているからです。
この塊を 層そうイメージを構成する、前の層からの差分 (追加、変更、削除したファイル) だけを tar に固めたもの。多くは gzip か zstd で圧縮される。用語集で見る と呼びます。
OCI Image Spec の言葉では、層は「ファイルシステムの変更セット」を tar にしたもので、1 つずつ順に適用すると最終的なファイルシステムになります。
層は下から順に、OverlayFS の lowerdir として重ねられます。
たとえば下の層に /bin/sh、上の層に /app/server があれば、重ねた結果には両方が並びます。
同じ層は 1 回しかディスクに置かれないので、同じベースイメージから作った 2 つのイメージは、ベース部分の層を共有します。
VM との違い
コンテナの中のプロセスがファイルを開くと、openat システムコールがホストの Linux カーネルにそのまま届きます。
カーネルはホストの 1 つしか無く、namespace が変えているのは、カーネルが各プロセスに返す答え (どのプロセス一覧を見せるか、どの NIC を見せるか) だけです。
だから起動は clone と execve の速さで終わり、メモリもプロセス分しか使いません。
同じ理由で、カーネルに脆弱性があれば全コンテナに影響します。 VM でゲストカーネルの脆弱性を突いても、そこはまだ VM の中で、ホストに届くにはハイパーバイザー (VM を動かすホスト側のプログラム) の穴をもう一段突く必要があります。 コンテナにはこの一段がありません。 この一点が、この Part の後半で扱う gVisor や Kata Containers の存在理由です。
ふりかえり
Qrunc が新しい mount namespace の中で pivot_root を呼んだ。ホストの / はどうなる?
pivot_root が入れ替えるのは、呼んだプロセスの mount namespace の root mount です。新しい mount namespace の中で呼ぶので、ホストの mount namespace には何も起きません。
Qコンテナの中でイメージ由来のファイルを書き換えた。どこに書かれる?
copy-up です。lowerdir (イメージ) は決して変わらないので、同じイメージを使う他のコンテナには影響しません。
Qコンテナと VM の違いを 1 つだけ挙げるなら?
カーネルの数が違います。起動の速さも、密度も、分離の弱さも、全部ここから出てきます。
参考
- namespaces(7), Linux manual page: https://man7.org/linux/man-pages/man7/namespaces.7.html
- pid_namespaces(7), Linux manual page: https://man7.org/linux/man-pages/man7/pid_namespaces.7.html
- mount_namespaces(7), Linux manual page: https://man7.org/linux/man-pages/man7/mount_namespaces.7.html
- pivot_root(2), Linux manual page: https://man7.org/linux/man-pages/man2/pivot_root.2.html
- chroot(2), Linux manual page: https://man7.org/linux/man-pages/man2/chroot.2.html
- clone(2), Linux manual page: https://man7.org/linux/man-pages/man2/clone.2.html
- cgroups(7), Linux manual page: https://man7.org/linux/man-pages/man7/cgroups.7.html
- Linux kernel documentation, Overlay Filesystem: https://www.kernel.org/doc/html/latest/filesystems/overlayfs.html
- Linux kernel documentation, Control Group v2: https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html
- Docker ドキュメント「Storage drivers」: https://docs.docker.com/engine/storage/drivers/
- Docker ドキュメント「OverlayFS storage driver」: https://docs.docker.com/engine/storage/drivers/overlayfs-driver/
- OCI Image Format Specification, Image Layer Filesystem Changeset: https://github.com/opencontainers/image-spec/blob/main/layer.md
- Kubernetes ドキュメント「About cgroup v2」: https://kubernetes.io/docs/concepts/architecture/cgroups/
- Kubernetes ドキュメント「User Namespaces」: https://kubernetes.io/docs/concepts/workloads/pods/user-namespaces/
- KernelNewbies, Linux 3.18: https://kernelnewbies.org/Linux_3.18