Linux のコマンドで、プロセス一覧がホストと分かれたシェルを起動してみましょう。 そこにアプリ用のファイルを用意し、メモリの上限も付けて、小さなコンテナを組み立てます。
設定を 1 つ足すたびに、見えるものや動きがどう変わるかを確かめます。 最後は実習用のプロセスに上限を超えるメモリを使わせ、メモリ不足で終了する OOM kill を観察します。
GOAL この章のゴール
- ホストとは見える範囲が異なるシェルを起動できる
- そのシェルのルートディレクトリを入れ替えられる
- メモリの上限を設定し、超えたときの終了を観察できる
ブラウザで組み立てる
前の章の 5 ステップのうち、手でやるのは rootfs の mount、namespace、pivot_root、cgroup の 4 つです。 まず、ノードに入る前にブラウザで順番を確かめます。 トグルを 1 つずつ入れると、右側の「中から見える世界」がそのたびに変わります。 全部入れた状態が、runc が作るコンテナです。 mnt namespace を入れる前に pivot_root を入れると何が起きるかも、試してみてください。
トグルを 1 つずつ入れて、右の「中から見える世界」がどう変わるか見てください。
$ ls / bin boot etc home lib opt proc root run srv sys tmp usr var (ホストの /) $ hostname node-1 (ホストと同じ) $ ps PID 1 systemd PID 812 containerd …(ホストの全プロセス) PID 4213 sh ← 自分 $ ip addr 1: lo 2: eth0 10.0.1.10/24 3: cilium_host … (ホストの NIC) $ id uid=0(root) ← ホストの root と同じ $ cat /sys/fs/cgroup/memory.max cpu.max memory.max = max (上限なし) $ mount / ipcs mount するとホストの一覧にも出る ホストの共有メモリが見える
順番に意味があります。pivot_root の前に rootfs を mount しておかないと新しい / は空で、mnt namespace を作る前に pivot_root するとホストの / まで入れ替わります。runc は namespace を作り、cgroup に入れ、mnt namespace の中で pivot_root してから exec します。
ノードに入る
ここから先はノードの中で root として作業します。
kubectl debug node/$(kubectl get node -o jsonpath='{.items[0].metadata.name}') -it \
--profile=sysadmin --image=busybox:1.37 -- chroot /host nsenter -t 1 -m -u -i -n -p -- bash抜けるときは exit。デバッグ用 Pod は自動では消えないので、終わったら kubectl delete pod -l app.kubernetes.io/managed-by=kubectl-debug かkubectl get pods で確認して消してください。
namespace を作る
unshareアンシェア新しい namespace を作って、その中でコマンドを実行する util-linux のコマンド。同名のシステムコールを呼ぶ。用語集で見る は、フラグ 1 つにつき namespace を 1 種類新しく作ります。
-m が mnt、-u が uts、-i が ipc、-n が net、-p が pid です。
-f は bash を fork してから起動する指示です。
pid_namespaces(7) によると、unshare を呼んだプロセス自身の pid namespace は変わらない (変えると自分の PID の認識がずれてしまう) ので、新しい pid namespace に入るのは fork した子からです。
--mount-proc は、その子のために /proc を mount し直す指示です。
/proc を mount し直さないと、ps はホストの /proc を読んでホストのプロセスを表示してしまいます。
この /proc の mount がホストの mount 一覧に出ないように、--mount-proc は mount namespace も自動で作ります (unshare(1))。
unshare -muinpf --mount-proc bashプロンプトが戻れば、もう新しい namespace の中にいます。 見た目は何も変わらないので、中から見える世界を確かめます。
hostname c1 && hostname
ps -ef
ip link
ls -l /proc/self/ns出力例
c1
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 10:00 pts/0 00:00:00 bash
root 9 1 0 10:00 pts/0 00:00:00 ps -ef
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN
lrwxrwxrwx 1 root root 0 ... cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 ... ipc -> 'ipc:[4026532714]'
lrwxrwxrwx 1 root root 0 ... mnt -> 'mnt:[4026532712]'
lrwxrwxrwx 1 root root 0 ... net -> 'net:[4026532716]'
lrwxrwxrwx 1 root root 0 ... pid -> 'pid:[4026532717]'
lrwxrwxrwx 1 root root 0 ... user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 ... uts -> 'uts:[4026532713]'ps に出るのは bash と ps だけで、bash が PID 1 です。
NIC は lo だけで、hostname を変えてもホストには影響しません。
ファイルシステムはまだホストのままなので、ls / はホストの / を見せます。
/proc/self/ns の中身はシンボリックリンクで、リンク先の [数字] が namespace の識別子 (inode 番号) です。
namespaces(7) によると、2 つのプロセスが同じ namespace にいるなら、この inode 番号は同じです。
別のターミナルでホスト側の ls -l /proc/1/ns と見比べると、mnt / uts / ipc / net / pid は番号が違い、user と cgroup は同じ番号です。
作っていない namespace はホストと共有されたままだからです。
根を入れ替える
次に / を入れ替えます。
入れ替え先の rootfs が要るので、containerd に Alpine のイメージを取ってきてもらい、その層を OverlayFS で mount します。
ctrシーティーアールcontainerd 付属のデバッグ用 CLI。CRI を通らず containerd の API を直接叩く。-n k8s.io で kubelet が使う namespace を指定する。用語集で見る は containerd 付属のコマンドで、images pull と images mount を持っています。
containerd のドキュメントは ctr を「containerd のデバッグ専用」と位置付けています。
exit
mkdir -p /tmp/rootfs
ctr -n k8s.io images pull docker.io/library/alpine:3.22
ctr -n k8s.io images mount --rw docker.io/library/alpine:3.22 /tmp/rootfs
ls /tmp/rootfs
mount | grep /tmp/rootfs出力例
bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
overlay on /tmp/rootfs type overlay (rw,relatime,lowerdir=…,upperdir=…,workdir=…)ctr images mount は、イメージの snapshot を用意して指定した場所に mount するコマンドで、--rw を付けると書き込みもできます。
mount の出力に lowerdir= と upperdir= が見え、前の章で説明した OverlayFS がそのまま使われています。
pivot_root(2) には条件があります。
new_root は mount point でなければならず、put_old は new_root の下になければなりません。
また、root mount と new_root の親 mount の伝播設定が shared であってはいけません。
ctr images mount で OverlayFS の mount を作ったのは、/tmp/rootfs を mount point にするためでもあります。
unshare -muinpf bash -c '
mount --make-rprivate /
cd /tmp/rootfs && mkdir -p oldroot
pivot_root . oldroot
cd /
mount -t proc proc /proc
umount -l /oldroot
exec /bin/sh
'cat /etc/os-release | head -2
ls /
ps
ls /oldroot出力例
NAME="Alpine Linux"
ID=alpine
bin dev etc home lib media mnt oldroot opt proc root run sbin srv sys tmp usr var
PID USER TIME COMMAND
1 root 0:00 /bin/sh
4 root 0:00 psUbuntu 26.04 のノードの上で、Alpine の /bin/sh が PID 1 として動いています。
/oldroot は umount 済みなので空です。
runc が作るコンテナとの違いは、ネットワークに veth がまだ無いことと、cgroup の上限がまだ無いことの 2 点だけです。
手順を図の 3 段に対応づけます。
unshare -m が 1 段目で、新しい mount namespace の mount 一覧はホストのコピーなので、/tmp/rootfs の overlay mount もそのまま見えています。
mount --make-rprivate / は、この namespace の中の mount と umount がホスト側に伝播しないようにする指示です。
mount_namespaces(7) によると systemd は起動時に全 mount を shared にするので、何もしないと新しい mount namespace の中の操作がホストにも伝わります。
unshare(1) は既定で同じことをしてくれるので、この行はその動作を明示的に書き下したものです。
pivot_root . oldroot が 2 段目で、この namespace の root mount が /tmp/rootfs に入れ替わり、古い root mount は /oldroot の下に移ります。
umount -l /oldroot が 3 段目で、古い root mount との縁を切ります。
cgroup で上限を付ける
cgroup v2 は /sys/fs/cgroup に 1 本のツリーがあるだけです。
ディレクトリを作れば cgroup が 1 つでき、中のファイルに書けば上限が付き、cgroup.procs に PID を書けばそのプロセスが入ります。
cgroup.procs に書くと、そのプロセスは元いた cgroup から自動的に抜けます (cgroups(7))。
v2 には「root 以外の cgroup は、子の cgroup に資源を配っている間は自分にプロセスを持てない」という規則もあります。
kubelet が掘るツリーもこの規則に沿っていて、プロセスがいるのは一番下のコンテナの cgroup だけです。
exit
ORIG=$(cut -d: -f3 /proc/self/cgroup)
mkdir /sys/fs/cgroup/demo
echo 64M > /sys/fs/cgroup/demo/memory.max
echo "50000 100000" > /sys/fs/cgroup/demo/cpu.max
echo $$ > /sys/fs/cgroup/demo/cgroup.procs
cat /proc/self/cgroup出力例
0::/democpu.max の形式は $MAX $PERIOD で、50000 100000 は「100,000 マイクロ秒のうち 50,000 マイクロ秒まで」、つまり 0.5 CPU です。
Pod の limits.cpu: 500m がこの数字になります。
memory.max は硬い上限で、カーネルのドキュメントによると、cgroup のメモリ使用量がここに達して減らせなければ、その cgroup の中で OOM killer が動きます。
この強制終了を OOM kill と呼びます。
python3 -c 'x = b"x" * (100 * 1024 * 1024); print("確保できた")'
cat /sys/fs/cgroup/demo/memory.events出力例
Killed
low 0
high 0
max 7
oom 1
oom_kill 1出力は Killed だけで、memory.events の oom_kill が 1 増えています。
Pod の OOMKilled は、同じことが kubelet の作った cgroup で起きた結果です。
最後に、kubelet が作っているツリーを見ます。
echo $$ > /sys/fs/cgroup$ORIG/cgroup.procs && rmdir /sys/fs/cgroup/demo
systemd-cgls --no-pager -u kubepods.slice | head -30出力例
Unit kubepods.slice (/kubepods.slice):
├─kubepods-burstable.slice
│ ├─kubepods-burstable-pod1a2b3c4d_….slice
│ │ ├─cri-containerd-9f8e….scope
│ │ │ └─12345 /coredns -conf /etc/coredns/Corefile
│ │ └─cri-containerd-7d6c….scope
│ │ └─12300 /pausePod 1 つが .slice、コンテナ 1 つが .scope です (どちらも systemd が cgroup のディレクトリに付ける呼び名です)。
各 Pod に pause というコンテナが 1 つずついるのが見えます。
containerd のドキュメントによると、Pod を作るとき containerd はまず Pod の net namespace を作って CNI で設定し、次に pause という特別なコンテナをその namespace で起動します。
pause は何もせず眠っているだけのプロセスで、Pod の他のコンテナが全部落ちても namespace が消えないように、そこに居続けるのが仕事です。
他のコンテナは、起動時にその namespace に参加します。
片付け
ctr -n k8s.io images unmount --rm /tmp/rootfs
exitふりかえり
Qunshare -muinpf で作られなかった namespace は?
-U を付けていないので user namespace はホストと共有です。/proc/self/ns/user の番号がホストと同じだったのはそのためです。runc の既定も同じです。
Qpivot_root の前に unshare -m で mount namespace を作る理由は?
pivot_root は呼んだプロセスの mount namespace の root mount を入れ替えます。ホストの mount namespace で呼べば、ホストの全プロセスの / が変わってしまいます。
QPod の limits.cpu: 500m は、ノード上ではどこに書かれる?
cpu.max に 50000 100000 と書かれます。kubelet → containerd → runc と伝わって、最後にこのファイルに落ちます。
参考
- unshare(1), Linux manual page: https://man7.org/linux/man-pages/man1/unshare.1.html
- 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
- cgroups(7), Linux manual page: https://man7.org/linux/man-pages/man7/cgroups.7.html
- Linux kernel documentation, Control Group v2: https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html
- containerd, ctr images mount (ソース): https://github.com/containerd/containerd/blob/main/cmd/ctr/commands/images/mount.go
- containerd ドキュメント「Getting started」: https://github.com/containerd/containerd/blob/main/docs/getting-started.md
- containerd ドキュメント「CRI Plugin Architecture」: https://github.com/containerd/containerd/blob/main/docs/cri/architecture.md
- Kubernetes ドキュメント「About cgroup v2」: https://kubernetes.io/docs/concepts/architecture/cgroups/
- Kubernetes ドキュメント「kubectl debug」: https://kubernetes.io/docs/reference/kubectl/generated/kubectl_debug/