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コンテナディー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 では、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 のスタックにはありません。
下のスタックを切り替えると、何が入れ替わり何が残るかが見えます。
- kubeletPod の spec を containerd への呼び出しに分解↓ gRPC over /run/containerd/containerd.sock
- containerdイメージ、Pod の土台、コンテナ↓ ttrpc
- containerd-shim-runc-v2Pod 1 つに 1 プロセス↓ exec + config.json
- runcnamespace / cgroup を作って exec、終了↓ clone / pivot_root / execve
- Podpause + アプリのコンテナ
- Linux カーネル (ホスト、共有)
この教材のクラスタの構成。dockerd の層が無く、kubelet が containerd に直接話す。shim から下は Docker と同じバイナリ。
shim はなぜ要るのか
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 | 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/Corefileshim の 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 の目線をそのまま再現するコマンドです。
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-abcdecrictl pods の POD ID が、さっきの shim の -id と一致します。
crictl ps の一覧に pause は出てきません。
kubelet の目線では pause は Pod の土台という別扱いの部品で、crictl pods の Pod として出て、コンテナの一覧には入らないからです。
最後に、containerd 自身の目線です。
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 では普通のコンテナとして見えます。見る層によって見え方が変わる例です。
参考
-
containerd ドキュメント「Runtime v2」: https://github.com/containerd/containerd/blob/main/docs/runtime-v2.md
-
containerd ドキュメント「containerd 2.0」: https://github.com/containerd/containerd/blob/main/docs/containerd-2.0.md
-
containerd ドキュメント「Namespaces」: https://github.com/containerd/containerd/blob/main/docs/namespaces.md
-
containerd ドキュメント「Using crictl」: https://github.com/containerd/containerd/blob/main/docs/cri/crictl.md
-
containerd ドキュメント「Getting started」: https://github.com/containerd/containerd/blob/main/docs/getting-started.md
-
ttrpc README: https://github.com/containerd/ttrpc
-
runc README: https://github.com/opencontainers/runc
-
crun README: https://github.com/containers/crun
-
CRI-O: https://cri-o.io/
-
Red Hat ブログ「Red Hat contributes CRI-O to the CNCF」: https://www.redhat.com/en/blog/red-hat-contributes-cri-o-cloud-native-computing-foundation
-
Docker ブログ「Introducing containerd」(2016 年 12 月): https://www.docker.com/blog/introducing-containerd/
-
cri-tools ドキュメント「crictl」: https://github.com/kubernetes-sigs/cri-tools/blob/master/docs/crictl.md
-
Kubernetes ドキュメント「Container Runtimes」: https://kubernetes.io/docs/setup/production-environment/container-runtimes/
-
crun README: https://github.com/containers/crun
-
Red Hat OpenShift 4.21「Configuring the container runtime」: https://docs.redhat.com/en/documentation/openshift_container_platform/4.21/html/machine_configuration/machine-configs-custom
-
youki README: https://github.com/youki-dev/youki
-
youki「Adopters and Use Cases」: https://youki-dev.github.io/youki/community/adopters_and_use_cases.html
-
runwasi Developer Documentation: https://runwasi.dev/