CRIはなぜ生まれ、shimは何を掴んでいるのか
一言でいうと
CRIは「Kubernetesがランタイムに話しかけるための標準の文法」で、shimは「containerdが落ちてもコンテナが生き続けるよう支える薄いプロセス」です。どちらもアダプターをなくすために作られた規約の産物です。
なぜ必要なのか
CRIがなかった時代、Kubernetesが使えるランタイムはDockerだけで、KubernetesはDocker Engineと対話するために自分のコードの中にアダプターを抱えていました。これがdockershimです。
このコードは当時は正しいものでした。なければ初期の普及は不可能だったからです。問題はランタイムが増えたときに表面化します。ランタイムが1つ増えるたびにKubernetes本体にアダプターを1つずつ足さなければならないなら、保守の負担がKubernetes側に際限なく積み上がります。
そこで順序を逆にしました。アダプターを取り除くには、まず規約を作る必要があります。CRI(Container Runtime Interface)というgRPCインターフェースを定義し、ランタイム側にそのインターフェースを実装させたのです。dockershimはKubernetes v1.24で削除され、公式FAQは、このコードが最初から一時的な解決策として意図されていたものだと明らかにしています。その場所をcontainerdとCRI-Oが埋めました。
ここで、試験によく出る誤解を1つ断ち切っておきます。dockershimの削除は「Dockerイメージが使えなくなる」という意味ではありません。イメージ形式はOCI Image Specなので、docker buildで作ったイメージはすべてのCRI実装でそのまま動きます。なくなったのはイメージではなく、kubelet内のアダプターコードです。
どう動くのか
CRIが提供する2つのサービス
- RuntimeService: Podサンドボックスとコンテナのライフサイクルを扱います。
RunPodSandbox、StopPodSandbox、CreateContainer、StartContainer、StopContainer、ListContainers、ContainerStatus、ExecSync、Exec、Attach、PortForward - ImageService: イメージを扱います。
PullImage、ListImages、ImageStatus、RemoveImage、ImageFsInfo
Pod1つが起動するとき実際に起きる順序
- kubeletが
RunPodSandboxを呼び出します。 - containerdがpauseコンテナを作ります。これがPodのネットワークネームスペースの持ち主です。
- CNIプラグインが呼び出され、そのネームスペースにIPを割り当てます。
- kubeletが
PullImage→CreateContainer→StartContainerを呼び出します。 - containerdがshimを通じてコンテナを実行します。
pauseコンテナがなぜあるのかはKCNAの定番です。Pod内のコンテナ同士がIPとポート空間を共有するには、誰かがそのネットワークネームスペースを先に作り、最後まで保持し続ける必要があります。アプリコンテナが再起動ですべて消える瞬間にもネームスペースが維持されてこそ、IPが変わりません。pauseはpause()システムコールで無限に待つだけなのでメモリを約1MBしか使わず、PIDネームスペースのinitの役割でゾンビプロセスも刈り取ります。
shim: containerdの再起動とコンテナの寿命を切り離す仕組み
containerd --> containerd-shim-runc-v2 --> runc --> 컨테이너 프로세스
shimの責務は4つです。
- containerdが再起動してもコンテナを維持します。
- コンテナのstdin/stdout/stderrを管理します。
- 終了コードを収集します。
- OOMイベントを報告します。
コンテナプロセスの親はcontainerdではなくshimで、shimは自分自身をデーモン化してcontainerdとは別のセッションで動きます。そのため、containerdを再起動してもコンテナプロセスのPIDは変わりません。「コンテナがおかしい」といってcontainerdを再起動しても、ほとんどの場合は何も直りません。管理プレーンが新しく立ち上がるだけで、コンテナを実際に保持しているのはshimだからです。
shimはコンテナの種類に応じて差し替えます。containerd-shim-runc-v2(runc)、containerd-shim-kata-v2(軽量VM)、containerd-shim-runsc-v1(gVisor)、containerd-shim-wasm(WebAssembly)です。RuntimeClassオブジェクトは、この選択をPod単位で公開したものです。
現場での姿
筆者のホームラボはクラスターを再構築する際、CNIをCalicoからCilium 1.20.1に変え、kube-proxyをそもそもインストールしませんでした(--skip-phases=addon/kube-proxy)。そして、それが実際に動作しているかを、主張ではなく測定で確認しました。kube-proxyのPodは0個、ノードのiptablesのKUBE-チェーンも0個でした。代わりにeBPFマップに、10.96.0.1:443/TCP → 10.0.0.120:6443/TCPのようなマッピングが直接入っていました。
ここで階層の話が現実になります。CNIを変えることとCRIを変えることは、別の層の作業です。CNIはステップ3(ネームスペースにIPを割り当てる)だけを担当し、コンテナを作るステップ1・4・5はそのままです。だからCNIを丸ごと入れ替えても、kubeletとcontainerdの間のやり取りは1文字も変わりません。
もう1点。この階層分離のおかげで、execだけが動かない障害が存在します。exec/attach/port-forwardはCRIのgRPC上を流れず、containerdがストリーミングURLを返すと、クライアントがそのURLでノードに改めて接続します。そのため、Podは問題なく動いているのにkubectl execだけがタイムアウトする状況が起こります。
次のクイズで確認すること
このモジュールはクイズで締めくくります。次のモジュールからは実際のクラスターに接続し、ここまで話してきた部品がAPIオブジェクトとしてどう現れるかを、自分の目で見ます。