ランタイムの内部:cgroups、runc、そして containerd の位置づけ
コンテナを実際に分離して制限しているものは何か。カーネルの機能から、Kubernetes が通信するデーモンまで。
「コンテナ」とは、組み合わされた一連の Linux カーネル機能を指す言葉であり、カーネル自体にコンテナというオブジェクトがあるわけではありません。このガイドでは、リソースを制限するカーネル機能、それを使ってコンテナを組み立てるツール、そのツールを大規模に管理するデーモンという順に、下から積み上げて説明します。
cgroups:カーネルレベルのリソース制御
コントロールグループ(cgroups) は、2008 年に遡る Linux カーネルの機能で、プロセスの集合に対して CPU、メモリ、I/O などのシステムリソースを割り当て、制限します。この仕組みにおけるリソースの種類はコントローラー(またはサブシステム)と呼ばれ、それによって制御されるプロセスは、ファイルシステムを模した階層に整理されます。cgroup はディレクトリであり、プロセスはその PID をディレクトリに書き込むことで参加します。
cgroups の構成には 2 つのルールがあります。
- 1 つの階層には複数のコントローラーをアタッチできますが、あるコントローラー(たとえば CPU)は、一度に 1 つの階層にしかアタッチできません。
- プロセスは、ある階層内ではちょうど 1 つの cgroup に属します。同じ階層内の別の cgroup に移動すると、元の cgroup から外れ、同じ階層内の 2 か所に同時に存在することはありません。
cgroups には v1 と v2 の 2 つのバージョンがあり、主に階層とコントローラーの関係が異なります。v2 は、v1 が許容していたより柔軟(でより複雑)な構成ではなく、コントローラーを単一の階層に統合しています。現代のディストリビューションと Kubernetes は、デフォルトで v2 を使用します。
cgroups 単体では、リソース制限を管理するだけです。ファイルシステム、イメージ形式、プロセスから見えるネットワークやプロセスツリーの分離については何も扱いません。それらこそが、単にリソースが制限されたものではなく、分離されたコンテナのように感じさせるものです。
runc:カーネルのプリミティブからコンテナを組み立てる
runc は OCI ランタイム仕様のリファレンス実装です。ファイルシステムと設定ファイルを受け取り、それを分離された実行中のプロセスに変えることだけが仕事の、低レベルのツールです。コンテナを実行するには 2 つのものが必要です。
- ルートファイルシステム(
rootfs):イメージから展開された、コンテナのファイルシステムの内容。 - config.json:
runc specで生成され、プロセスをどう実行するかのすべてを記述します。Linux の capability、マウントポイント、作成または共有する namespace、cgroups を通じて適用するリソース制限などです。
mkdir my-container && cd my-container
mkdir rootfs
# イメージのエクスポートされたファイルシステムレイヤーから rootfs を準備する
runc spec
runc run my-container-id
Kubernetes の Pod spec を書いたことがあれば、config.json の形はなじみのあるものでしょう。capability、マウント、namespace の設定は、Kubernetes が独自の API で公開しているのと同じ基本概念で、カーネルに 1 層近いだけです。runc は意図的に機能が絞られています。その設定に従って、1 つのコンテナのプロセスを開始および停止します。イメージの pull、長時間動作するデーモン、Kubernetes との通信といった概念はありません。
containerd:runc を大規模に管理する
containerd は、runc の周りのすべてを行うデーモンです。イメージの pull と展開、イメージが展開されるストレージスナップショットの管理、実行中のコンテナの追跡、そして直接のクライアントと Kubernetes(CRI 経由)の両方が使える gRPC API の公開を行います。kubelet が実際に通信するコンポーネントです。
containerd は、コンテナのプロセスを直接実行しません。それを shim に委譲します。shim は、containerd とコンテナの実際のプロセスの間に位置する、小さく長寿命のプロセスです。shim の役割は、containerd 自体とは独立してコンテナを動かし続けることで、containerd デーモンを再起動またはアップグレードしても、ノード上の実行中のすべてのコンテナが停止しないようにすることです。containerd 1.x には 3 つの shim 実装が付属していました。containerd 2.0 では、古い 2 つが削除され、runc ベースの shim v2 だけが残りました。これはすでに何年も前からデフォルトでした。
kubelet ── CRI (gRPC) ──▶ containerd ── shim ──▶ runc ──▶ コンテナプロセス
│
└── イメージ、スナップショット、ストレージを管理
これはレイヤー化されたアーキテクチャであるため、containerd は特に runc に縛られているわけではありません。shim は、まったく異なる実行エンジンを前面に出すことができます。より強力なサンドボックスのための gVisor、VM レベルの分離のための Kata Containers などで、その間、containerd のイメージ管理、CRI 実装、Kubernetes 側のすべては変わりません。
全体の経路をまとめる
Pod のコンテナを最初から最後まで追うと、次のようになります。kubelet が containerd に CRI リクエストを送ります。containerd は、イメージがまだなければ pull し、ノードのファイルシステム上のスナップショットに展開し、shim に引き渡します。shim は、そのスナップショットを rootfs として指す、生成された config.json を使って runc を呼び出します。runc は Linux の namespace を作成し、Pod の resource requests と limits から cgroup の制限を適用し、プロセスを開始します。その後は、shim が実行中のプロセスを監視し、そのステータスを containerd を通じて kubelet に報告します。kubectl get pods が最終的に読んでいるのは、これです。
各レイヤーは 1 つの問題を解決します。cgroups はリソースを制限し、runc は rootfs と設定から OCI 準拠のコンテナを組み立て、shim はデーモンとは独立にそのコンテナの監視を続け、containerd はそのすべてを調整して、Kubernetes が必要とする API を公開します。これらのどの部品も、他の部品の仕事をしません。だからこそ、shim レイヤーより上の何にも手を加えずに、runc を別の低レベルランタイムに置き換えることが可能なのです。