Kubernetes 解体新書Part 3 ランタイム

手作りコンテナ

Linux のコマンドで小さなコンテナを作ります。ファイルやプロセスの見え方と、メモリ上限の効果を確かめます。

  • L2 ノードの中
  • 更新 2026年10月6日

Linux のコマンドで、プロセス一覧がホストと分かれたシェルを起動してみましょう。 そこにアプリ用のファイルを用意し、メモリの上限も付けて、小さなコンテナを組み立てます。

設定を 1 つ足すたびに、見えるものや動きがどう変わるかを確かめます。 最後は実習用のプロセスに上限を超えるメモリを使わせ、メモリ不足で終了する OOM kill を観察します。

GOAL この章のゴール

  • ホストとは見える範囲が異なるシェルを起動できる
  • そのシェルのルートディレクトリを入れ替えられる
  • メモリの上限を設定し、超えたときの終了を観察できる

ブラウザで組み立てる

前の章の 5 ステップのうち、手でやるのは rootfs の mount、namespace、pivot_root、cgroup の 4 つです。 まず、ノードに入る前にブラウザで順番を確かめます。 トグルを 1 つずつ入れると、右側の「中から見える世界」がそのたびに変わります。 全部入れた状態が、runc が作るコンテナです。 mnt namespace を入れる前に pivot_root を入れると何が起きるかも、試してみてください。

コンテナ度 0/9: ただのプロセス

トグルを 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 -muinpf --mount-proc bash の流れ。unshare が 5 つの namespace を作り、fork した子が PID 1 になり、新しい /proc を mount するホストの bash が unshare(2) を呼び、mnt / uts / ipc / net / pid の namespace を新しく作る。pid namespace は呼んだプロセス自身には効かないので、-f で fork した子 bash が新しい pid namespace の PID 1 になる。--mount-proc がその子のために /proc を mount し直す。ファイルシステムの / はまだホストのまま。bash (ホスト側)PID 4213、/ はホストunshare(2)namespace を 5 つ新しく作る-m -u -i -n -p-f (fork)子 bash新しい pid ns の PID 1--mount-proc/proc を mount し直すps が中の世界を見るファイルシステムの / はまだホストのままpivot_root は次の節 (mount namespace の中でやる)pid namespace は unshare を呼んだプロセス自身には効かない (pid_namespaces(7))。だから -f で fork した子から有効になる。--mount-proc は mount namespace も自動で作る。新しい /proc の mount がホストの一覧に出ないようにするため。
図 1unshare -muinpf --mount-proc bash の流れ。フラグ 1 文字が namespace 1 種類に対応し、-f で fork した子だけが新しい pid 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))。

5 つの namespace の中で bash を起動するノードの中で実行
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/<pid>/ns の inode 番号を、ホストの PID 1 と unshare した子で比べるunshare で作った 5 種類は inode が変わり、作らなかった user と cgroup はホストと同じ番号のまま。番号が同じなら同じ namespace にいる。ls -l /proc/1/ns (ホストの systemd)ls -l /proc/self/ns (unshare した子)mntmnt:[4026531841]mntmnt:[4026532712]別utsuts:[4026531838]utsuts:[4026532713]別ipcipc:[4026531839]ipcipc:[4026532714]別netnet:[4026531840]netnet:[4026532716]別pidpid:[4026531836]pidpid:[4026532717]別useruser:[4026531837]useruser:[4026531837]同じcgroupcgroup:[4026531835]cgroupcgroup:[4026531835]同じ同じ番号 = 同じ namespace を共有している
図 2/proc/<pid>/ns の inode 番号。unshare で作った 5 つは番号が変わり、user と cgroup はホストと同じ。

/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 のデバッグ専用」と位置付けています。

Alpine の rootfs を用意するノードの中で実行
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 の 3 段: 新しい mount namespace を作り、root mount を rootfs に入れ替え、古い root を umount する1. 新しい mount namespace では mount 一覧がホストのコピーで、root mount はホストの / のまま、その下に OverlayFS の mount がある。2. pivot_root(new_root, put_old) がこの namespace の root mount を OverlayFS に入れ替え、古い root mount を put_old (/oldroot) の下に移す。3. umount(put_old) で古い root mount を外す。ホスト側の mount namespace はこの間ずっと変わらない。1. 新しい mount namespacemount 一覧はホストのコピー/root mount はホストの //tmp/rootfsoverlay (イメージの層)new_root = /tmp/rootfs2. pivot_root(new_root, put_old)root mount を入れ替える/overlay が root mount に/oldroot古い root mount がここへput_old = /oldroot3. umount(put_old)古い root mount を外す/overlay だけが残る/oldroot (空)ホストへ戻る道が無い入れ替わるのはこの mount namespace の root mount だけ。ホスト側の mount namespace の / は 3 段の間ずっと変わらない。
図 3これから手でやる pivot_root の 3 段。新しい mount namespace の root mount を /tmp/rootfs に入れ替え、古い root を /oldroot に退避してから umount する。

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 にするためでもあります。

pivot_root で / を Alpine にするノードの中で実行
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 ps

Ubuntu 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 の直下に system.slice と kubepods.slice が並び、kubepods.slice の下に QoS クラス、Pod、コンテナの順で深くなる1 本のツリー。root の直下に systemd の system.slice (containerd.service、kubelet.service など) と、kubelet が作る kubepods.slice がある。kubepods.slice の下は QoS クラスの slice、Pod の slice、コンテナの scope の順。各ディレクトリに cpu.max / memory.max / cgroup.procs がある。/sys/fs/cgroupsystem.slicecontainerd.servicekubelet.service などもkubepods.sliceroot の直下。kubelet が作るkubepods-burstable.sliceQoS: Guaranteed / Burstable / BestEffortkubepods-burstable-pod<UID>.slicePod 1 つcri-containerd-<CID>.scopeコンテナ 1 つ (プロセスはここだけ)各ディレクトリの中cpu.maxmemory.maxpids.maxcgroup.procs (PID 一覧)
図 4cgroup v2 のツリー。root の直下に system.slice と kubepods.slice が並び、kubelet は kubepods.slice の下に QoS クラス → Pod → コンテナの順でディレクトリを掘る。

cgroup v2 は /sys/fs/cgroup に 1 本のツリーがあるだけです。 ディレクトリを作れば cgroup が 1 つでき、中のファイルに書けば上限が付き、cgroup.procs に PID を書けばそのプロセスが入ります。 cgroup.procs に書くと、そのプロセスは元いた cgroup から自動的に抜けます (cgroups(7))。 v2 には「root 以外の cgroup は、子の cgroup に資源を配っている間は自分にプロセスを持てない」という規則もあります。 kubelet が掘るツリーもこの規則に沿っていて、プロセスがいるのは一番下のコンテナの cgroup だけです。

64Mi の cgroup を作って bash を入れるノードの中で実行
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::/demo

cpu.max の形式は $MAX $PERIOD で、50000 100000 は「100,000 マイクロ秒のうち 50,000 マイクロ秒まで」、つまり 0.5 CPU です。 Pod の limits.cpu: 500m がこの数字になります。 memory.max は硬い上限で、カーネルのドキュメントによると、cgroup のメモリ使用量がここに達して減らせなければ、その cgroup の中で OOM killer が動きます。 この強制終了を OOM kill と呼びます。

100MiB 確保して 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 が作っているツリーを見ます。

kubepods.slice を辿るノードの中で実行
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 /pause

Pod 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 と伝わって、最後にこのファイルに落ちます。

参考