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

コンテナを起動するプログラムたち

containerd、runc、shim は何を分担しているのでしょうか。起動時と起動後のプロセスを見比べます。

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

nginx のコンテナを動かしてホストのプロセス一覧を見ると、アプリ以外にも containerd や shim という名前が並びます。 コンテナを起動したはずの runc は、起動後には見当たりません。

コンテナの実行は、1 つのプログラムだけで完結しているわけではありません。 この章では、起動後も管理を続ける担当と、起動の準備を終えると終了する担当に分けて、それぞれの役割を見ます。

GOAL この章のゴール

  • containerd、runc、shim の役割を区別できる
  • Docker と Kubernetes がコンテナを起動する経路を図で追える
  • ノード上のプロセスと、Pod 内のコンテナを対応づけられる

2 つの層

ここまでの 2 章で、コンテナを 1 つ起動するまでの作業が見えてきました。 イメージをレジストリから取ってきて、層を重ねて rootfs を作り、namespace と cgroup を作り、pivot_root して、execve する、という一連の作業です。 このうち、イメージの保管やコンテナの状態管理はノードにずっと居続けて覚えておく必要があり、namespace や cgroup の設定は 1 回やれば終わる作業です。

この性質の違いに沿って、ノード上でコンテナを動かすプログラムの総称である コンテナランタイムコンテナランタイムノード上でコンテナを動かすプログラムの総称。実際には、常駐してイメージとコンテナの状態を管理する高レベルランタイムと、1 回実行してコンテナのプロセスを起動する低レベルランタイムの 2 層に分かれる。用語集で見る は 2 層に分かれています。

高レベルランタイムと低レベルランタイムの分担高レベル (containerd / CRI-O) は常駐デーモンで、kubelet の依頼を受け、イメージを管理し、rootfs と config.json を用意して低レベルランタイムを実行する。低レベル (runc / crun / youki) はバイナリで、config.json を読んで namespace と cgroup を作り、プロセスを起動する。高レベルランタイム (デーモン)containerdCRI-Okubelet の依頼を UNIX ソケットで受けるイメージの pull と保管、層の展開 (snapshotter)Pod の土台 (pause) と CNI の呼び出しrootfs と config.json を用意する低レベルランタイムのバイナリを実行するログと stdio、exec、metricsrootfs + config.json低レベルランタイム (バイナリ)runccrunyoukiconfig.json を読むnamespace / cgroup / pivot_root を実際にやるseccomp、capabilities、AppArmor を適用プロセスを execve して自分は終了するcreate / start / kill / delete で操作されるgVisor / Kata の隔離方式は第 6 章へ高レベルは「何を動かすか」を知っている。低レベルは「どう隔離するか」だけを知っている。
図 1高レベルは「何を動かすか」(イメージ、Pod、ログ)、低レベルは「どう隔離するか」(namespace、cgroup、seccomp) を担当する。

高レベルランタイム は、ノードに常駐するデーモンです。 containerdコンテナディーDocker から切り出された高レベルランタイム。kubelet からの依頼を受け付ける機能を内蔵し、イメージの保管、層の展開、コンテナのライフサイクル管理を担う。CNCF の graduated プロジェクト。用語集で見る と CRI-OクライオKubernetes の kubelet からの依頼だけを受けるために作られた高レベルランタイム。Red Hat が 2016 年に始めたプロジェクトで、2019 年に CNCF へ寄贈された。CNCF の graduated プロジェクト。用語集で見る がこれにあたります。 kubelet からの依頼を UNIX ソケットで受け、レジストリからイメージを pull し、層を重ねて rootfs を作り、そこに起動するコンテナの設定を書いた JSON ファイル (config.json) を添えて、低レベルランタイムのバイナリを実行します。 config.json には、実行するコマンド、環境変数、作る namespace の種類、cgroup の上限などが入っています。

低レベルランタイム は、常駐しない実行ファイルです。 runcランシーOCI Runtime Spec を実装する低レベルランタイム。Docker が OCI に寄贈した Go 製のバイナリで、config.json と rootfs を読んで namespace と cgroup を作り、プロセスを起動する。用語集で見る がこの教材の環境で使われています。 同じ OCI Runtime Spec を実装する crun や youki も、この層の選択肢です。 gVisor や Kata Containers のように、システムコールの処理や VM の起動まで担当する実装については第 6 章で扱います。 仕事は前の章で手でやったこと、つまり namespace / cgroup / pivot_root / execve です。 runc はコンテナのプロセスを起動したら自分は終了します。 runc の README は、runc を「低レベルのツールで、エンドユーザーを念頭に作られていない。多くは上位のコンテナソフトウェアから使われる」と説明しています。 config.json には、プロセスが呼べるシステムコールを絞る seccomp や、root の権限を細かく分けた capabilities の設定も入っていて、runc がそれを適用します (次の章で中身を見ます)。

2 層の間には「rootfs と config.json を置いたディレクトリを指定してバイナリを実行する」という取り決めがあります。 runc も runsc も同じ形式の config.json を読み、同じ引数 (create、start など) で呼ばれます。 containerd に対応する shim とランタイムを導入し、設定で選ぶことで実装を切り替えられます。 この取り決めの正式な名前と中身は、次の章で見ます。

runc、crun、youki が使われる場所

OCI Runtime Spec を実装するランタイムは、runc だけではありません。 crunクランC で実装された OCI ランタイム。CRI-O や Podman から呼び出され、namespace や cgroup を設定してコンテナを起動する。用語集で見る は、低い起動コストと小さなメモリ使用量を目指す実装です。 Red Hat の OpenShift は CRI-O を使い、4.18 以降の新規インストールでは crun が既定です。 4.17 から更新したクラスタは runc の既定設定を引き継ぐため、「OpenShift なら必ず crun」とは限りません。 kubelet → CRI-O → crun も、実際の製品で使われるコンテナの起動経路です。

youkiヨウキRust で実装された OCI ランタイム。単体の実行ファイルに加え、コンテナ生成の libcontainer や資源管理の libcgroups を Rust のライブラリとして提供する。用語集で見る は、Rust のメモリ安全性を活かして同じ層を実装するプロジェクトです。 youki の公式ドキュメントでは、Rust 製のオーケストレーター rk8s による libcontainer と libcgroups の利用、runwasi による libcontainer の利用が紹介されています。 単体のランタイムを入れ替える用途と、コンテナを作る部品を別のソフトウェアに組み込む用途があります。

runwasiランワシcontainerd で WebAssembly / WASI のワークロードを動かすための shim を開発するプロジェクト。youki の libcontainer を隔離環境の構築に利用する。用語集で見る は、youki と同じ種類の実行ファイルではなく、containerd と WebAssembly の実行エンジンをつなぎます。 WebAssembly はプログラムを実行するための命令形式で、WASI はファイルなどの外部機能を使うためのインターフェースです。 Spin アプリケーションを Kubernetes で動かす SpinKube も、runwasi を介して youki の部品を使います。 この関係を containerd → runwasi の shim → WebAssembly エンジン と捉えると、OCI の仕組みが Linux プロセス以外の実行にも使われる理由が分かります。

採用状況を読むときは、youki の実行ファイルを運用する話と、Rust のライブラリを組み込む話を分けます。 後者の事例を数えて、すべてのサービスで youki が既定ランタイムになったと解釈することはできません。

Docker のスタックと Kubernetes のスタック

Docker のスタックと Kubernetes のスタック。どちらも containerd → shim → runc に合流する左: docker CLI → dockerd → containerd → containerd-shim-runc-v2 → runc → コンテナ。右: kubelet → (gRPC) → containerd → shim → runc → Pod。DockerKubernetesdocker CLIREST (docker.sock)dockerdgRPCcontainerdttrpccontainerd-shim-runc-v2exec (rootfs + config.json)runcclone / execコンテナkubeletgRPC (containerd.sock)containerdttrpccontainerd-shim-runc-v2exec (rootfs + config.json)runcclone / execPodpause + app コンテナLinux カーネル (namespace / cgroup / OverlayFS)同じ同じ
図 2左が docker run、右が kubelet。containerd から下は同じバイナリで、違うのは一番上の 2 層だけ。

Docker では、docker CLI → dockerd → containerd → shim → runc の順に降りていきます。 dockerd はイメージのビルド、docker network、docker volume、ログ収集まで抱えた大きなデーモンです。 Docker 1.11 (2016 年 4 月) から dockerd は内部で containerd と runc を使うようになり、同じ年の 12 月に containerd は独立したプロジェクトとして切り出されました。 切り出した後も dockerd は containerd を使い続けています。

Kubernetes では、kubelet → containerd → shim → runc です。 kubelet は Pod の spec を「Pod の土台 (前の章で見た pause コンテナ) を作る」「イメージを pull する」「コンテナを作る」「起動する」という呼び出しに分解して、UNIX ソケット越しに containerd に投げます。 containerd の中の、その呼び出しを受け付ける機能 (CRI plugin) が、kubelet の相手をします。 dockerd に相当する層は Kubernetes のスタックにはありません。

下のスタックを切り替えると、何が入れ替わり何が残るかが見えます。

  1. kubeletPod の spec を containerd への呼び出しに分解
    ↓ gRPC over /run/containerd/containerd.sock
  2. containerdイメージ、Pod の土台、コンテナ
    ↓ ttrpc
  3. containerd-shim-runc-v2Pod 1 つに 1 プロセス
    ↓ exec + config.json
  4. runcnamespace / cgroup を作って exec、終了
    ↓ clone / pivot_root / execve
  5. Podpause + アプリのコンテナ
  6. Linux カーネル (ホスト、共有)

この教材のクラスタの構成。dockerd の層が無く、kubelet が containerd に直接話す。shim から下は Docker と同じバイナリ。

shim はなぜ要るのか

shim が要る理由: runc はコンテナを起動したら終了する。コンテナの親として残り、stdio と終了コードを預かるのが shim時間軸。containerd が shim を起動し、shim が runc を実行する。runc はコンテナの init を exec したあと終了する。コンテナは shim の子として動き続け、containerd を再起動してもコンテナは生き残る。containerdshimruncコンテナ再起動生き続ける (subreaper として stdio / 終了コードを預かる)create / start→ 終了親 (PPID) は shim起動runc createinit を起動SIGCHLD / 終了コード再接続横軸は時間。runc の生存時間はミリ秒単位。shim とコンテナはコンテナが終わるまで。
図 3runc の寿命はミリ秒。コンテナの親として残り、stdio と終了コードを預かるのが shim。containerd を再起動してもコンテナは落ちない。

runc は runc create と runc start でコンテナの init プロセスを起動したら終了します。 Linux では、プロセスの終了コードを受け取れるのは親プロセスだけです。 runc が終了したあとのコンテナのプロセスにも、親が要ります。 親は標準出力を受け取り、終了コードを拾い (拾わないと終了済みのプロセスが zombie として残ります)、kubectl exec の tty を繋ぎます。 containerd 本体がその親になると、containerd を再起動するたびに、全コンテナの標準出力と終了コードを受け取る相手が消えてしまいます。

そこで containerd は、コンテナの組ごとに shimシムcontainerd と runc の間に挟まる小さなプロセス (containerd-shim-runc-v2)。runc を実行したあとコンテナの親として残り、stdio、終了コード、exec、tty を預かる。containerd と ttrpc で通信する。用語集で見る を起動します。 shim は runc を呼んだあとも生き続け、コンテナの親として残ります。 containerd の設計文書 (runtime v2) によると、shim は sub-reaper (孤児になった子孫プロセスを引き取って終了を回収する役) を引き受け、終了したコンテナの後始末をします。 containerd は shim と ttrpc (gRPC から HTTP の層を外して、メモリとバイナリサイズを小さくした版) で話し、再起動後は既存の shim に繋ぎ直します。 containerd をアップグレードしても Pod が落ちないのは、この設計のおかげです。 shim が SIGKILL されるなどして containerd から繋げなくなったときは、shim バイナリの delete サブコマンドで残った資源を片付けます。

Kubernetes では shim の単位は Pod です。 runtime v2 の文書によると、containerd-shim-runc-v2 はラベルを見て同じ Pod のコンテナを 1 つの shim にまとめます。 その shim の下に pause コンテナと Pod 内の全コンテナがぶら下がります。 shim の実行ファイル名は、containerd の設定にある runtime の名前 io.containerd.runc.v2 から、ドットをハイフンに替え、末尾の 2 要素 (runc と v2) の前に containerd-shim を付けた形で決まります。

ノードで確かめる

ノードの中に入るノードの中で実行
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 で確認して消してください。

ps -ef --forest で見えるプロセスツリー。shim の親は PID 1 (systemd)systemd の直下に containerd、kubelet、そして Pod ごとの containerd-shim-runc-v2 が並ぶ。各 shim の子に pause とアプリのコンテナがぶら下がる。systemd (PID 1)├─ containerd├─ kubelet├─ containerd-shim-runc-v2 -namespace k8s.io -id <Pod の土台の id>│ ├─ /pause│ └─ /coredns -conf /etc/coredns/Corefile└─ containerd-shim-runc-v2 -namespace k8s.io -id <Pod の土台の id> ├─ /pause └─ cilium-agent --config-dir=/tmp/cilium/config-mapshim は起動後すぐに親から離れ (daemonize)、systemd の子になる。containerd が死んでも shim と Pod は残る。Pod 1 つに shim 1 つ。その下に pause と、Pod 内の全コンテナが並ぶ。
図 4ps -ef --forest の見え方。shim は systemd の直下にいる。

まずプロセスツリーを見ます。

shim と Pod のプロセスツリーを見るノードの中で実行
ps -ef --forest | grep -A3 'containerd-shim' | head -40

出力例

root  1234     1  0 10:00 ?  00:00:01 /usr/bin/containerd-shim-runc-v2 -namespace k8s.io -id 9f8e7d... -address /run/containerd/containerd.sock
65535 1260  1234  0 10:00 ?  00:00:00  \_ /pause
root  1301  1234  0 10:00 ?  00:00:03  \_ /coredns -conf /etc/coredns/Corefile

shim の PPID が 1 で、pause と coredns の PPID が shim です。 shim を起動するのは containerd ですが、shim は起動直後に親から切り離され、親を失ったプロセスは PID 1 の systemd の子になります。 そのため PPID は 1 になり、containerd が終了しても shim は残ります。

次に、kubelet の目線で同じものを見ます。 crictlクライシーティーエルkubelet が containerd に出すのと同じ呼び出しを、手元から送れるデバッグ用 CLI。kubelet が見ているのと同じ Pod / コンテナ / イメージを、kubelet を通さずに一覧と検査ができる。用語集で見る は、kubelet の目線をそのまま再現するコマンドです。

kubelet と同じ呼び出しで Pod とコンテナを見るノードの中で実行
export CONTAINER_RUNTIME_ENDPOINT=unix:///run/containerd/containerd.sock
crictl pods | head
crictl ps | head

出力例

POD ID         CREATED      STATE   NAME                     NAMESPACE     ATTEMPT  RUNTIME
9f8e7d6c5b4a   2 hours ago  Ready   coredns-7b5c4-abcde      kube-system   0        (default)
CONTAINER      IMAGE          CREATED      STATE    NAME      ATTEMPT  POD ID         POD
7d6c5b4a3f2e   1a2b3c4d5e6f   2 hours ago  Running  coredns   0        9f8e7d6c5b4a   coredns-7b5c4-abcde

crictl pods の POD ID が、さっきの shim の -id と一致します。 crictl ps の一覧に pause は出てきません。 kubelet の目線では pause は Pod の土台という別扱いの部品で、crictl pods の Pod として出て、コンテナの一覧には入らないからです。

最後に、containerd 自身の目線です。

containerd の API で直接見るノードの中で実行
ctr -n k8s.io containers ls | head
ctr -n k8s.io tasks ls | head

出力例

CONTAINER      IMAGE                                   RUNTIME
7d6c5b4a3f2e   registry.k8s.io/coredns/coredns:v1.13.1 io.containerd.runc.v2
9f8e7d6c5b4a   registry.k8s.io/pause:3.10.1            io.containerd.runc.v2
TASK           PID     STATUS
7d6c5b4a3f2e   1301    RUNNING
9f8e7d6c5b4a   1260    RUNNING

こちらには pause が普通のコンテナとして並びます。 ctr は前の章でも使った containerd 付属のデバッグ用コマンドで、kubelet を通さず containerd の API を直接叩きます。 -n k8s.io は containerd の namespace (Linux の namespace とは別物で、同じ containerd を使う複数の利用者が互いのコンテナを混ぜないための、名前の仕切り) で、containerd のドキュメントによると CRI plugin は k8s.io という namespace を使います。 RUNTIME 列の io.containerd.runc.v2 が shim の種類です。 tasks ls の PID が、ps で見た PID と一致します。

3 つの目線で同じ Pod を見ました。 kubelet (crictl) からは pause が隠れ、containerd (ctr) からは全部見え、カーネル (ps) から見れば全部ただのプロセスです。

ふりかえり

Qrunc の寿命として正しいのは?

runc はバイナリで、create / start を済ませたら終了します。だからコンテナの親として残る shim が必要になります。

Qcontainerd を再起動しても Pod が落ちない理由は?

shim は起動直後に systemd の子になるので、containerd が居なくても生き続けます。

Qcrictl ps に pause コンテナが出ない理由は?

ctr -n k8s.io containers ls では普通のコンテナとして見えます。見る層によって見え方が変わる例です。

参考