TT Lab
ブログ

한국어 English 日本語 中文

はじめる
ブログ

ブログ

containerd と CRI の読み方:kubelet からの gRPC 呼び出しが runc に届くまで

ステータス:ドラフト(2026-10-17)、一部実測。 基準バージョン:containerd/containerd v2.3.5(コミット 1294c24a7da8)、kubernetes/cri-api v0.34.0(コミット 9b3ce5ec4798)。 1.7 との比較は、containerd v1.7.27(コミット 05044ec0a9a7)も併せて開き、ソースコードで確認できた内容のみを記載しています。実測は 2026-10-17 に小規模クラスターで 読み取りのみ を行いました(ノード一覧の読み取り、2台のワーカーへの一般ユーザーでの ssh による ps·mount設定ファイルの読み取り、sudo なし)。 作成規則:実測データを基本とし、測定できなかったものは「未測定」、確認できなかったものは「未確認」と表記します。

目次

1. 1行要約:発注書フォーマットと厨房

kubelet はコンテナを直接作成しません。「このポッドのサンドボックスを用意しろ」、「このイメージでコンテナを作れ」、「起動しろ」といった注文を CRI(Container Runtime Interface) という決められたフォーマットで送信しているだけです。発注書のフォーマットが決まっているため、厨房(ランタイム)は containerd であろうと CRI-O であろうと変更できます。containerd はその注文を受け取り、材料(イメージレイヤー)を取り出して下ごしらえし(スナップショット)、テーブルごとに担当スタッフ(shim)を配置し、実際に火を点ける作業は調理師(runc)に任せます。調理師は料理をコンテナに据え付けるとすぐに退勤し、その後の世話は担当スタッフが引き受けます。

構成要素 役割 稼働場所
kubelet ポッド仕様を CRI 呼び出しに変換して送信 ノードごとの systemd サービス
CRI(cri-api) RuntimeService・ImageService の2つの gRPC サービスの規約 api.proto 1つのファイル
containerd CRI プラグイン CRI リクエストを受け取りコアサービス・CNI を呼び出す containerd プロセス内
containerd コアサービス コンテナメタデータ・タスク・イメージ・コンテンツ・スナップショット・リース(lease) containerd プロセス内
containerd-shim-runc-v2 ポッドあたりのコンテナプロセスを保護し、終了コードを受け取る ポッドごとに1プロセス
runc OCI バンドルで名前空間・cgroup を作成しプロセスを起動する 実行後すぐに終了
CNI プラグイン ポッドのネットワーク名前空間にインターフェース・IP を付与する containerd が実行するバイナリ

2. 構成図

kubelet 에서 runc 까지: 한 노드 안의 호출 사슬 kubelet CRI 클라이언트 CRI gRPC (api.proto) unix 소켓 containerd.sock containerd 데몬 (프로세스 1개) CRI 플러그인 io.containerd.grpc.v1.cri runtime images podsandbox RunPodSandbox·CreateContainer·PullImage CNI 바이너리 코어 서비스 (같은 프로세스 안에서 직접 호출) containers tasks images content snapshots leases meta.db blobs/sha256 snapshots/N runtime v2 태스크 관리자 · shim 관리자 ttrpc (소켓 /run/containerd/s/…) containerd-shim-runc-v2 파드마다 1개(sandbox-id 로 묶음) · rootfs 마운트 runc create·start 후 종료 pause 앱 컨테이너들 읽는 법 파랑: 코어 보라: 런타임 주황: 데몬 밖 초록: 프로세스
図:kubelet から runc に至る呼び出しチェーンと containerd の内部構成
이미지 레이어가 컨테이너 rootfs 가 되기까지 overlayfs 스냅샷터의 디렉터리 4/fs 쓰기 층 (active) 3/fs 레이어 3 (committed) 2/fs 레이어 2 (committed) 1/fs 레이어 1 (committed) 맨 위 active 층만 쓰기 가능 ① Pull 레이어 blob → content 저장소 ② Unpack Prepare→Apply→Commit 반복 ③ 컨테이너 생성 위에 새 active 스냅샷 ④ 태스크 생성 shim 이 overlay 로 마운트 결과: 컨테이너 번들의 rootfs 에 overlay 하나 overlay on /run/containerd/io.containerd.runtime.v2.task/ k8s.io/(컨테이너 ID)/rootfs type overlay lowerdir=3/fs:2/fs:1/fs (왼쪽이 위층) upperdir=4/fs, workdir=4/work 실측 2026-10-17 (워커 두 대, 읽기만) 1.7 워커: overlay 50개 중 1층 22개 = pause 22개 2.3 워커: overlay 74개, 가장 깊은 것 52층
図:イメージレイヤーがスナップショットを経て overlay マウントされるまでのプロセスと実測

3. コードマップ

containerd 2.x のリポジトリは1つの Go モジュール(github.com/containerd/containerd/v2)です。最初に見るべき重要な部分のみを抽出しました。

パス 内容 最初に読むべきファイル
cmd/containerd/ デーモンのエントリーポイント、組み込みプラグイン一覧、プラグインのロード builtins/builtins.go, server/server.go
plugins/ プラグインレジストリ。サービス、スナップショットター、コンテンツ、CRI、GC など types.go, cri/cri.go
internal/cri/ CRI 実装本体(サーバー、サンドボックスコントローラー、イメージサービス、設定) server/sandbox_run.go
core/ ドメインロジック:メタデータ(bolt)、runtime v2、アンパック、トランスファー runtime/v2/shim_manager.go
client/ Go 클라이언트. CRI 플러그인도 이것을 「메모리 안」으로 씀 container.go
cmd/containerd-shim-runc-v2/ runc 用 shim manager/manager_linux.go
api/ containerd 独自の gRPC・ttrpc プロト services/tasks/v1/tasks.proto

kubernetes/cri-api はほぼ1つのファイルです。 pkg/apis/runtime/v1/api.proto に 35 個の RPC とメッセージのすべてが含まれており、残りは生成された Go コードです。

1.7 と 2.x の違い(ソースコードで確認できたもののみ)

4. コアパスを辿る

4.1 デーモンはプラグイングラフで組み立てられる

containerd のほぼすべての機能はプラグインです。各パッケージが init() で registry.Register に自身を登録し、 builtins.go はそれらのパッケージをブランクインポート(_)で取り込んでいるだけです。ビルドに含めるかどうかは、インポートの1行で決まります。

  1. プラグインは タイプと ID によって識別されます。タイプは io.containerd.grpc.v1, io.containerd.snapshotter.v1, io.containerd.cri.v1 のような文字列です(plugins/types.go:35 付近)。設定ファイルの [plugins."io.containerd.cri.v1.images"] は、「タイプ.ID」をそのまま記述したものです。
  2. 登録には Requires(必要なタイプのリスト)が存在します。CRI gRPC プラグインは、CRI サービス、サンドボックスコントローラー、NRI、イベント、lease、transfer、shim など11種類のタイプを要求します(plugins/cri/cri.go:46 init).
  3. デーモンは LoadPlugins で registry.Graph(...) を使用して依存関係の順序に並べ替え(386行目)、 New のループ(161行目)で1つずつ p.Init を実行します(189行目)。グラフソート自体は別モジュールである github.com/containerd/plugin v1.1.0 に存在するため、このリポジトリには含まれていません。
  4. サービスは基本的に 2層構造になっています。実際に処理を行う io.containerd.service.v1 タイプの「local」実装(plugins/services/tasks/local.go:92)と、それを gRPC として外部に公開する薄い io.containerd.grpc.v1 ラッパー(containers/service.go:34)です。

なぜ2層構造になっているのでしょうか?CRI プラグインがその答えです。 initCRIService は containerd クライアントを作成しますが、 containerd.WithInMemoryServices(ic)(121行目)を渡します。外部の ctr や nerdctl はソケットを経由して gRPC ラッパーを呼び出し、同一プロセス内の CRI は local 実装を直接呼び出します。シリアライズのオーバーヘッドを1回分削減しつつも、 クライアントコードを一つに 維持するための設計です。CRI リクエストが使用するネームスペースもこの時に k8s.io に固定されます。そのため、 ctr -n k8s.io を指定して確認しないと Kubernetes のコンテナは見えません。

4.2 RunPodSandbox:ネットワークが先、pause はコントローラーが

CRI 規約の最初の RPC は RunPodSandbox です。「成功すればサンドボックスが ready でなければならない」という1行が契約のすべてであり、サンドボックスとは何かについてはランタイム側が定義します。containerd の答えは「ネットワーク名前空間 + それを保持する pause コンテナ」です。

  1. criService.RunPodSandbox はまずサンドボックス ID 用の lease を作成します(97行目)。この lease が存続している間、サンドボックスが使用するコンテンツおよびスナップショットは GC の対象外になります。
  2. hostNetwork ではない場合、 netns.NewNetNS により /var/run/netns/ の下に名前付きのネットワーク名前空間を作成します(209行目)。
  3. 続いて setupPodNetwork が CNI の Setup を呼び出します(496行目)。 コンテナが1つも起動する前に ネットワークが先に取り付けられる形になります。取得した IP はサンドボックスの状態にキャッシュしておき、 PodSandboxStatus のたびに名前空間を探索しなくて済むようにします。
  4. その次にサンドボックスサービスの CreateSandbox(281行目)と StartSandbox(299行目)を呼び出します。ここで実際の処理を行うのがサンドボックスコントローラーです。
  5. デフォルトのコントローラー podsandbox の Start は、pause イメージを検索し(86行目)、そのイメージの上にスナップショットを準備してコンテナを作成し(209行目)、標準入出力なしでタスクを作成し(253行目)、終了監視(Wait、270行目)を設定した上で起動します(295行目)。

なぜこのように分割されているのでしょうか?サンドボックスを「pause コンテナ」に固定しないためです。コントローラーがプラグイン化されているため、VM ベースのランタイムのようにサンドボックスが仮想マシンである実装も、同じ CRI サーバの下に組み込むことができます。pause イメージ名が設定ファイルにおいて sandbox_image という1行ではなく、 pinned_images テーブルの sandbox 項目に変更されたのも(設定構造体 config.go:307)、同じ流れによるものです。コメントではこれを「CRI クライアントが削除してはならないイメージ群」と定義し、他のプラグインがキーとして参照できるようにしています。pause はその中の1つに過ぎません。

4.3 CreateContainer·StartContainer → tasks → shim → runc

  1. CreateContainer は CRI の ContainerConfig を OCI ランタイム仕様に変換し、 WithNewSnapshot(378行目)を介してイメージ上の書き込みレイヤーを準備した上で、 client.NewContainer(462行目)を呼び出します。この段階では メタデータのみ が作成されます。プロセスはまだ存在しません。
  2. StartContainer は、サンドボックスがタスク API のアドレスを保持している場合にそれをオプションとして付与し(197行目)、 container.NewTask(206行目)→ task.Start(247行目)を呼び出します。
  3. クライアントの NewTask は、 handleMounts(328行目)でスナップショットターの Mounts を受け取り、rootfsのマウント一覧をリクエストに載せます。tasksサービスの local.Create がこれを受け取り、runtime v2の TaskManager.Create に渡します。
  4. ShimManager.Start と binary.Start がshimバイナリを start サブコマンドとして実行します。shimは自身のttrpcソケットアドレスを返してデーモンから切り離されます。
  5. shim側の manager.Start には、この記事で最も重要な数行があります。
grouping := id
spec, err := readSpec()
...
for _, group := range groupLabels {
	if groupID, ok := spec.Annotations[group]; ok {
		grouping = groupID
		break
	}
}
...
s, err := newShimSocket(ctx, socketDir, opts.GetContainerdGrpcAddress(), grouping, false)
if err != nil {
	if errdefs.IsAlreadyExists(err) {
		params.Address = s.addr
		return &params, nil

出典: containerd v2.3.5 cmd/containerd-shim-runc-v2/manager/manager_linux.go, Apache-2.0

groupLabels には io.kubernetes.cri.sandbox-id があります(67行目)。同じPodのコンテナは同じサンドボックスIDで同じソケット名を取得し、 ソケットがすでに存在する場合は 新しいshimを起動せず既存のアドレスを返します。 そのため、shimはコンテナごとではなくPodごとに1つとなります。 第6節の実測値がこれをそのまま示しています。 6. shimの service.Create は runc.NewContainer で バンドル下の rootfs にoverlayを shimが直接マウントし(120行目)、 Init.Create が runc create を実行します(149行目)。 Start は start で runc start です。

なぜshimを別々に分けるのでしょうか?コンテナプロセスの親がcontainerdである場合、containerdを再起動したりアップグレードしたりすると、すべてのコンテナが一緒に影響を受けます。 shimが親の役割(終了コードの回収、標準入出力の維持)を引き受ければ、デーモンはいつでも停止して起動し直し、ソケットで再接続できます。 runcを常駐させないのも同じ理由です。長生きするプロセスが少ないほど、アップグレードが容易になります。

4.4 イメージプルとスナップショット:レイヤーがrootfsになるまで

  1. CRIの PullImage は CRIImageService.PullImage に届きます。 2.1からのデフォルトは transferサービスであり、 use_local_image_pull を有効にすると従来の client.Pull パスを使用します(184行目)。 transferのパスは pullImageWithTransferService → Transfer(326行目)→ localTransferService.pull です。
  2. ダウンロードされたマニフェスト・設定・レイヤーはすべて コンテンツアドレス指定ストレージに入ります。パスはダイジェストそのものの blobs/sha256/<hex> です(blobPath)。 同じレイヤーを使用するイメージが複数あっても、ディスク上には一度だけ存在します。
  3. プルの展開は Unpacker.unpack がレイヤーごとに スナップショットター Prepare(415行目)→ diffサービス Apply(tarを展開して格納、542行目)→ Commit(494行目)を繰り返します。コミットされたスナップショットの名前は そのレイヤーまでの chainID であるため、同じレイヤーチェーンはイメージが異なっていても1度だけ展開されます。
  4. コンテナを作成する際、 WithNewSnapshot はこの一番上の chainIDを親として Prepare にし、空のアクティブスナップショットを作成します(276行目)。イメージがまだ展開されていない場合、CRI側のラッパーが unpackImage でプルして再試行します。
  5. 最後にoverlayfsスナップショットターの mounts がマウントオプションを作成します。
if s.Kind == snapshots.KindActive {
	options = append(options,
		fmt.Sprintf("workdir=%s", o.workPath(s.ID)),
		fmt.Sprintf("upperdir=%s", o.upperPath(s.ID)),
	)
}
...
parentPaths := make([]string, len(s.ParentIDs))
for i := range s.ParentIDs {
	parentPaths[i] = o.upperPath(s.ParentIDs[i])
}
options = append(options, fmt.Sprintf("lowerdir=%s", strings.Join(parentPaths, ":")))

出典: containerd v2.3.5 plugins/snapshots/overlay/overlay.go, Apache-2.0

親スナップショットたちの fs ディレクトリがそのまま lowerdir になり、自分の fs·work が upperdir·workdir になります。コンテナが使用する ファイルはすべてupperにのみ蓄積されるため、同じイメージを使用する100個のコンテナも下層を共有します。同じ発想をVMディスクに適用した話は 学生VMが起動するまで4にあります。

これらすべてをつなぐ台帳がbolt DBです。ネームスペースの下にイメージ・コンテナ・スナップショット・コンテンツ・leaseバケットがあり (buckets.go:60 のコメント図)、 GarbageCollect はどのイメージ・コンテナ・leaseからも 参照されないコンテンツとスナップショットを削除します。プルの途中のレイヤーが削除されないのは、その作業がleaseを保持しているためです。 「参照がなければ削除する」を基本とし、実行中の作業のみleaseで例外とする設計です。

5. ユーザーコントリビューションとの連携

筆者は2026-10-17現在、containerdとkubernetes/cri-apiに提出したPRはありません。コントリビューション台帳には「コンテナランタイム低水準(containerd・CRI-O・runcなど)」を 調査中の分野として記載しており、この記事はその準備運動です。8節の候補は、この記事を書く過程でソースコードから直接見つけたものです。

1点あらかじめ記載しておきます。kubernetes/cri-apiはkubernetes/kubernetesの staging/src/k8s.io/cri-api をボットが移行するリポジトリであるため、 このリポジトリに直接送信されたPRは無視されます(リポジトリのCONTRIBUTINGを参照)。CRI側の修正はkubernetes/kubernetesに送信する必要があり、その場合はKubernetesの AI使用ルール(PR本文での告知、AIが作成したコミットメッセージ・共同執筆者の表記の禁止)に従う必要があります。

6. 実測:1.7ワーカーと2.3ワーカーの権限なし読み取り

2026-10-17、小規模クラスター(ノード8台)で読み取りのみを行いました。ノードランタイムの分布は kubectl get nodes -o custom-columns=...containerRuntimeVersion であり、ノード内は一般ユーザーのsshで ps·mount·/etc/containerd/config.toml のみを読み取りました。 /run/containerd·/var/lib/containerd は 権限がないため一覧を読み取れず(sudoは使用せず)、 crictl はインストールされていませんでした。

項目 ワーカーA(containerd 1.7.27) ワーカーB(containerd 2.3.5)
同バージョンのノード数 4台 4台
runc 1.2.5 1.5.1
実行中のPod(うちhostNetwork) 22 (10) 31 (6)
アプリコンテナ数 28 42
containerd-shim-runc-v2 プロセス 22 31
pause プロセス 22 31
タスクディレクトリ数(ディレクトリのハードリンク数 − 2) 50 = 22 + 28 74
overlayマウント 50 74
うち1層(lowerdirが1つ) 22 33
最も深いlowerdir 42層 52層
/var/run/netns 項目 12 = 22 − 10 25 = 31 − 6
shim RSS平均 / containerd RSS 15.7 MiB / 74 MiB 12.6 MiB / 153 MiB
設定バージョン version = 2 version = 4
CRI設定ブロック io.containerd.grpc.v1.cri つ io.containerd.cri.v1.images·.runtime に分かれる
pauseイメージ sandbox_image = pause:3.8 pinned_images.sandbox = pause:3.10.2
snapshotter / cgroup overlayfs / SystemdCgroup = true overlayfs / SystemdCgroup = true

shimの1行は次のようになっています(IDは隠しています)。

containerd-shim /usr/bin/containerd-shim-runc-v2 -namespace k8s.io -id 0c1e…(64자) -address /run/containerd/containerd.sock
overlay on /run/containerd/io.containerd.runtime.v2.task/k8s.io/0c1e…/rootfs type overlay (rw,relatime,lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/1/fs,upperdir=…/snapshots/3774/fs,workdir=…)

読み取った内容:

kubelet側は両方のバージョンで containerRuntimeEndpoint: unix:///run/containerd/containerd.sock(2.xノードは /var/run/... と表記)、 cgroupDriver: systemd でした。ランタイムの SystemdCgroup = true と一致しています。

7. どこから読むべきか

  1. api.proto の RuntimeService·ImageService のコメントのみをざっと読みます。契約が何を要求し、何をランタイムに委ねているのかが見えてきます。
  2. plugins/cri/cri.go と server.go でプラグインがどのように連携するのかを確認します。
  3. sandbox_run.go → podsandbox/sandbox_run.go の順序でPodを1つ起動します。
  4. container_start.go → shim_manager.go → manager_linux.go でプロセス層まで降ります。
  5. unpacker.go と overlay.go でディスク側を確認します。 docs/snapshotters/·docs/content-flow.md を添えると効果的です。

k3sやRKE2のようにcontainerdを内蔵して配布するディストリビューションがこの構造をどのようにラップしているのかは、 k3s・RKE2・k0sの構造比較で続けて確認できます。

8. 最初コントリビューションしやすい箇所

9. 制限事項

ティティの一言。 kubeletは注文書を出すだけで、テーブルごとに担当スタッフ(shim)がついています。だからシェフ(containerd)が少し休憩していても、お客さんの料理はそのままです!

参考

すべての資料は2026-10-17に閲覧しました。

ログインするといいねできます

コメント

まだコメントはありません。

ログインするとコメントできます