containerdイメージ管理:OCIイメージとスナップショット
containerdのイメージ管理サブシステムはOCIイメージスペックに基づいてイメージの保存、配布、アンパッキングを担当する核心機能です。この記事ではContent Store、Snapshotter、イメージPullフロー、ガベージコレクションの内部動作を分析します。
1. OCIイメージスペック
1.1 イメージ構造
OCIイメージは3つの核心コンポーネントで構成されます:
OCIイメージ構造:
1. Image Index(Fat Manifest)
- 複数プラットフォーム(linux/amd64、linux/arm64など)をサポート
- 各プラットフォーム別Manifestを指す
2. Image Manifest
- Configオブジェクトのダイジェスト
- レイヤーリスト(順序保証)
- メディアタイプ情報
3. Image Config
- 環境変数、エントリポイント、CMD
- レイヤーdiff IDリスト
- 作成履歴
1.2 コンテンツアドレス指定
コンテンツアドレス指定(Content Addressable Storage):
すべてのオブジェクトはSHA256ダイジェストで識別:
sha256:abc123... -> Image Index JSON
sha256:def456... -> Image Manifest JSON
sha256:789ghi... -> Image Config JSON
sha256:jkl012... -> Layer tar.gz
利点:
- 重複排除:同一レイヤーは1回のみ保存
- 整合性検証:ダイジェストでデータ検証
- キャッシング:ダイジェストベースのキャッシュルックアップ
2. Content Store
2.1 概要
Content Storeはcontainerdのコンテンツアドレスストレージで、イメージのすべてのバイナリデータを管理します。
Content Storeディレクトリ構造:
/var/lib/containerd/io.containerd.content.v1.content/
blobs/
sha256/
abc123... (Image Index)
def456... (Image Manifest)
789ghi... (Image Config)
jkl012... (Layer 1 tar.gz)
mno345... (Layer 2 tar.gz)
ingest/
(一時ダウンロードデータ)
2.2 Content Store API
Content Store主要操作:
Info(digest) -> コンテンツメタデータ照会(サイズ、作成時間)
ReaderAt(digest) -> コンテンツ読み取り(io.ReaderAtインターフェース)
Writer(ref) -> コンテンツ書き込み(アトミックコミット)
Delete(digest) -> コンテンツ削除
ListStatuses() -> 進行中の書き込み操作照会
Abort(ref) -> 進行中の書き込み操作キャンセル
2.3 Ingestプロセス
コンテンツ書き込み(Ingest)プロセス:
1. Writer作成(参照キー割り当て)
|
v
2. ingest/ディレクトリに一時ファイル作成
|
v
3. データストリーミング書き込み
(レジストリからレイヤーダウンロードなど)
|
v
4. ダイジェスト検証
(期待ダイジェストと実際のデータハッシュを比較)
|
v
5. アトミックコミット
(ingest/ -> blobs/sha256/ に移動)
|
v
6. 失敗時にingest/をクリーンアップ
3. Snapshotter
3.1 Snapshotter概要
Snapshotterはイメージレイヤーをファイルシステムスナップショットとして管理するプラグインです。コンテナが使用するルートファイルシステムを準備します。
Snapshotterの役割:
イメージレイヤー(tar.gz)
|
v
Snapshotterが各レイヤーをスナップショットに変換
|
v
スナップショットを重ねて統合ファイルシステムを構成
|
v
コンテナにマウントポイントを提供
3.2 スナップショットタイプ
スナップショット種類:
1. Committed(コミット済み)
- 読み取り専用スナップショット
- イメージレイヤーに対応
- 複数コンテナで共有可能
2. Active(アクティブ)
- 読み取り/書き込みスナップショット
- コンテナの書き込み可能レイヤー
- 1つのコンテナに割り当て
3.3 overlayfs Snapshotter
最も広く使用されるSnapshotterです:
overlayfs動作:
レイヤー1(base):/snapshots/1/fs (lowerdir)
レイヤー2(app):/snapshots/2/fs (lowerdir)
書き込みレイヤー:/snapshots/3/fs (upperdir)
作業ディレクトリ:/snapshots/3/work (workdir)
マウント:
mount -t overlay overlay \
-o lowerdir=/snapshots/2/fs:/snapshots/1/fs,\
upperdir=/snapshots/3/fs,\
workdir=/snapshots/3/work \
/container/rootfs
利点:
- Copy-on-Write:変更時のみコピー
- 高速なコンテナ起動
- レイヤー共有でディスク節約
3.4 native Snapshotter
native Snapshotter:
- 各スナップショットを独立ディレクトリに保存
- 親スナップショットを完全にコピー(hardlink使用)
- overlayfsをサポートしない環境で使用
- ディスク使用量が大きい
- シンプルで移植性が高い
3.5 devmapper Snapshotter
devmapper Snapshotter:
- Linux device mapperのthin provisioningを使用
- ブロックレベルCopy-on-Write
- 高パフォーマンスワークロードに適合
- Firecracker microVMと共に使用
- 設定が複雑(thin-pool事前構成が必要)
使用事例:
- AWS Fargate(Firecracker)
- 高パフォーマンスコンテナ環境
- ブロックストレージベースのインフラ
3.6 Snapshotter API
Snapshotter主要操作:
Stat(key) -> スナップショット情報照会
Prepare(key, parent) -> Activeスナップショット作成(書き込み可能)
View(key, parent) -> Committedスナップショットの読み取り専用ビュー
Commit(name, key) -> ActiveスナップショットをCommittedに変換
Mounts(key) -> スナップショットのマウント情報を返す
Remove(key) -> スナップショット削除
4. イメージPullフロー
4.1 全体フロー
イメージPull全体フロー:
1. イメージ参照の解決
docker.io/library/nginx:latest
|
v
2. Image Index/Manifestダウンロード
- レジストリからマニフェストを取得
- プラットフォームに合ったマニフェストを選択
|
v
3. Configダウンロード
- イメージ設定JSONをダウンロード
- Content Storeに保存
|
v
4. レイヤーダウンロード(並列)
- 各レイヤーをContent Storeに保存
- 既に存在するレイヤーはスキップ
|
v
5. レイヤーアンパッキング
- Content Storeからレイヤーを読み取り
- Snapshotterでスナップショット作成
|
v
6. イメージメタデータ登録
- BoltDBにイメージレコード作成
- タグとダイジェストのマッピング
4.2 レイヤーダウンロード詳細
レイヤーダウンロード:
1. マニフェストからレイヤーダイジェストリストを抽出
2. Content Storeに既に存在するか確認
3. 存在しないレイヤーのみレジストリからダウンロード
4. Transfer Serviceがダウンロード管理:
- 同時ダウンロード制限(デフォルト3個)
- 進捗状況追跡
- リトライロジック
5. 各レイヤーはgzip圧縮状態でContent Storeに保存
4.3 レイヤーアンパッキング
レイヤーアンパッキング(Unpack):
1. Content Storeからレイヤーblobを読み取り
2. gzip解凍
3. tarアーカイブ展開
4. Snapshotterにスナップショット作成:
a. 最初のレイヤー:親なしでPrepare
b. レイヤー内容をスナップショットに適用(Apply)
c. Commitで読み取り専用に変換
d. 次のレイヤー:前のスナップショットを親としてPrepare
5. 最終スナップショットチェーン完成
スナップショットチェーン:
Layer 1 (committed) <- Layer 2 (committed) <- Layer 3 (committed)
5. イメージメタデータ
5.1 イメージレコード
イメージメタデータ(BoltDB):
イメージレコード:
- Name: "docker.io/library/nginx:latest"
- Target:
MediaType: "application/vnd.oci.image.index.v1+json"
Digest: "sha256:abc123..."
Size: 1234
- Labels:
"containerd.io/gc.ref.content.0": "sha256:def456..."
"containerd.io/gc.ref.content.1": "sha256:789ghi..."
- CreatedAt: 2026-03-20T00:00:00Z
- UpdatedAt: 2026-03-20T00:00:00Z
5.2 イメージ照会
# ctrでイメージリスト照会
ctr -n k8s.io images list
# イメージ詳細情報
ctr -n k8s.io images check
# イメージコンテンツ確認
ctr -n k8s.io content get sha256:abc123... | jq .
6. ガベージコレクション
6.1 GCメカニズム
ガベージコレクション動作:
1. ルートオブジェクト識別:
- イメージレコード
- コンテナレコード
- Leaseレコード
2. 参照追跡(Mark):
- イメージ -> Manifest -> Config + Layers
- コンテナ -> スナップショットチェーン
- Lease -> 保護中のリソース
3. 未参照オブジェクト削除(Sweep):
- Content Storeの未参照blob削除
- Snapshotterの未参照スナップショット削除
- メタデータの孤立レコードクリーンアップ
6.2 GCラベル
GC参照ラベル:
containerdはGC参照をラベルで管理します:
イメージラベル:
"containerd.io/gc.ref.content.0": "sha256:..." (マニフェスト参照)
"containerd.io/gc.ref.content.1": "sha256:..." (レイヤー参照)
コンテンツラベル:
"containerd.io/gc.ref.content.config": "sha256:..." (config参照)
"containerd.io/gc.ref.content.l.0": "sha256:..." (レイヤー参照)
スナップショットラベル:
"containerd.io/gc.ref.snapshot.overlayfs": "sha256:..." (スナップショット参照)
6.3 Lease
Lease(リース):
- 進行中の操作のリソースをGCから保護
- イメージPull中にダウンロードされたレイヤーを保護
- コンテナ作成中のスナップショットを保護
- TTLベースの自動期限切れ
- 操作完了後に明示的削除可能
例:
イメージPull開始 -> Lease作成
レイヤーダウンロード -> Leaseがコンテンツを保護
イメージ登録完了 -> Lease削除(イメージレコードが参照を保持)
6.4 GCスケジューリング
GCトリガー:
1. スケジューラプラグイン(io.containerd.gc.v1.scheduler)の閾値:
- pause_threshold = 0.02
- deletion_threshold = 0
- mutation_threshold = 100
- schedule_delay = "0ms"
- startup_delay = "100ms"
2. イベントベース:
- イメージ削除時
- コンテナ削除時
- APIを通じた明示的呼び出し
3. ctrによる手動実行:
ctr -n k8s.io content prune references --dry
7. ノードで実際に実行するコマンド
ここまでは構造の説明であり、ノードに入って実際に手に取れるのはコマンドの出力です。最初にぶつかる壁が名前空間です。containerdの名前空間はKubernetesの名前空間と言葉が同じなだけで何の関係もなく、メタデータストアを論理的に区切る単位です。kubeletがCRIプラグイン経由で取得したイメージ、スナップショット、コンテナレコードはすべてk8s.io名前空間に入ります。一方ctrはオプションがなければdefault名前空間を見ます。そのためPodが数十個動いているノードで ctr images list を叩くと空の表が返り、そこからイメージが消えたとかcontainerdがイメージを失ったという誤った結論が生まれます。消えたものは何もなく、別の引き出しを開けただけです。Kubernetesノードでctrを使うときは例外なく -n k8s.io を付けると覚えておくほうが安全です。
# どの名前空間があるかをまず確認 (NAME / LABELS)
ctr namespaces list
# kubeletが使う名前空間を明示
ctr -n k8s.io images list # イメージレコード
ctr -n k8s.io content ls # DIGEST / SIZE / AGE / LABELS
ctr -n k8s.io content active # REF / SIZE / AGE (進行中のingest)
ctr -n k8s.io snapshots ls # KEY / PARENT / KIND
ctr -n k8s.io snapshots tree # スナップショットの親子ツリー
ctr -n k8s.io snapshots usage # KEY / SIZE / INODES
ctr -n k8s.io leases ls # ID / CREATED AT / LABELS
この数行が前節までの概念図をすべて目で確認できるものに変えてくれます。content ls のLABELS列には6.2で説明した containerd.io/gc.ref.content のような参照ラベルがそのまま出るため、どのblobが何に掴まれて生き残っているのかを推測ではなく照会で知ることができます。snapshots ls のKIND列はCommittedとActiveを区別してくれます。コンテナが1つもないノードにActiveスナップショットが残っていれば、片付いていない書き込みレイヤーがあるという意味です。snapshots usage はスナップショットキーごとのサイズとinode数を示すので、ディスク容量よりinodeが先に枯渇する状況もここで捕まえられます。content active はコミットされないままingestディレクトリに残っているダウンロードを示します。正常な状態ならpullの進行中だけ空でないはずです。
kubelet側の視野はcrictlで見ます。ctrがcontainerd全体を見る道具だとすれば、crictlはCRIインターフェイス越しにしか見ないため、kubeletが実際に何を知っているかにより近いものです。両者の出力が食い違うなら、その食い違い自体が診断情報になります。
crictl images # CRIが認識しているイメージ一覧
crictl images --digests --no-trunc # ダイジェストまで完全表示
crictl imagefsinfo # イメージファイルシステムの使用量
crictl rmi --prune # 使用中でないイメージを整理
crictl rmi には注意点が1つあります。cri-toolsのドキュメントは、CRI APIの制限のためタグでイメージを指定するとそのタグだけでなく当該イメージに付いたすべてのタグが一緒に削除されると明記しています。特定のタグだけを消して残りを保ちたい場合は、nerdctlやctrのようなランタイムネイティブのツールを使えというのがドキュメントの推奨です。--prune は使用中でないイメージをすべて消すため、次のロールアウトが同じイメージをすぐ再取得するノードではpullの嵐を招きます。
8. イメージの挙動を左右する設定キー
設定ファイルを開く前にバージョンを確認する必要があります。containerd 1.xが使っていたversion 2ではCRI関連の設定がすべて [plugins."io.containerd.grpc.v1.cri"] の下に一塊で入っていました。containerd 2.xのversion 3はこれをランタイム側とイメージ側に分割し、[plugins.'io.containerd.cri.v1.runtime'] と [plugins.'io.containerd.cri.v1.images'] に分けます。ブログやIssueからコピーした設定断片が効かない最も多い理由がこのバージョン差です。セクション名が合わないとcontainerdはそれを知らないプラグインの設定として無視し、タイプミスを知らせないままデフォルト値で動きます。
# containerd 1.x
version = 2
[plugins."io.containerd.grpc.v1.cri"]
snapshotter = "overlayfs"
discard_unpacked_layers = false
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
# containerd 2.x
version = 3
[plugins.'io.containerd.cri.v1.images']
snapshotter = "overlayfs"
discard_unpacked_layers = false
image_pull_progress_timeout = "5m0s"
[plugins.'io.containerd.cri.v1.images'.registry]
config_path = "/etc/containerd/certs.d"
snapshotter のデフォルト値は両バージョンともoverlayfsです。ドキュメントがコアとして分類するスナップショッターはoverlayfs(Docker/Moby のoverlay2に相当)、native(vfsドライバに相当)、blockfile、devmapper、btrfs、zfs、erofsであり、fuse-overlayfs、nydus、overlaybd、stargzはコア外のプラグインです。btrfsとzfsはプラグインルートが当該ファイルシステムでマウントされている必要があり、erofsはアクティブスナップショットのためにOverlayFSカーネルモジュールを要求するため、設定ファイルに名前を書くだけでは有効になりません。いま動いているバイナリが実際に何を持っているかは ctr plugins ls で確認するほうがドキュメントを漁るより速いです。
discard_unpacked_layers はデフォルト値がfalseで、ドキュメントの説明はレイヤーをスナップショッターへ正常にアンパックした後にGCがそのレイヤーをコンテンツストアから削除することを許可する、というものです。有効にすると圧縮された元のレイヤーを保持しなくなるため、ノードのディスク使用量が目に見えて減ります。その代わりコンテンツストアにblobがないので、そのノードからイメージを再びpushしたりexportしたりできなくなり、スナップショットを再利用できない状況になればレジストリから取り直す以外の道がありません。純粋なワーカーノードでは概ね得ですが、イメージを作ったり移したりするノードでは有効にしてはいけません。
image_pull_progress_timeout はversion 3のサンプル設定に5m0sとして現れ、ドキュメントの説明はイメージpull進行のタイムアウトです。名前が誤解を招きやすいので、pull全体の制限時間ではなく進行が止まった時間に対する制限として読むほうが安全です。併せて見るべき値は max_concurrent_downloads で、ドキュメントはイメージごとに同時ダウンロード数を制限すると説明しています。レジストリが遅いのではなく同時実行数が低いために時間がかかっている場合が実際に多くあります。
レジストリミラーは config_path で接続します。version 2のデフォルト値は /etc/containerd/certs.d:/etc/docker/certs.d、version 3のデフォルト値は空文字列なので、2.xへ上げる過程でミラー設定が突然無視されることがあります。パスを指定したら、そのディレクトリの下にレジストリホスト名と同じ名前のディレクトリを作り、その中にhosts.tomlを置きます。どの名前とも一致しないときのフォールバックとして _default ディレクトリを使えます。
# /etc/containerd/certs.d/docker.io/hosts.toml
server = "https://docker.io"
[host."https://mirror.example.com"]
capabilities = ["pull", "resolve"]
skip_verify = true
capabilities に入る値はpull、resolve、pushです。プライベートCAを使うなら ca、相互TLSなら client を同じhostブロックに書きます。server はこのレジストリホスト名前空間の既定サーバーであり、hostブロックはserverより先に試されます。
9. ワークドエグザンプル: 1回のpullを最後まで追う
前の4章をコマンドで再現してみます。ノードにSSHで入り、まず基準線を取ります。crictl imagefsinfo はイメージファイルシステムの使用量を返し、CRI APIの定義上はマウントポイント(fs_id.mountpoint)、イメージが使うバイト数(used_bytes)、イメージが使うinode数(inodes_used)が含まれます。出力形式はバージョンによって変わりうるので、正確なフィールドは使用中のバージョンのドキュメントで確認してください。
# 1) 基準線
crictl imagefsinfo
ctr -n k8s.io content ls | wc -l
ctr -n k8s.io snapshots ls | wc -l
# 2) pull
crictl pull registry.k8s.io/pause:3.10.2
# 3) 事後
crictl imagefsinfo
ctr -n k8s.io content ls
ctr -n k8s.io snapshots tree
crictl images | grep pause
pullが実際にネットワークを通ったなら順序はこうです。まずマニフェスト(またはインデックス)がコンテンツストアに入り、次にconfig JSONが、そしてレイヤーのblobが入ります。この3種類はすべて ctr -n k8s.io content ls にダイジェストとして現れ、サイズで見分けられます。数百バイトから数KBのものがマニフェストとconfig、数MB以上のものがレイヤーです。その後アンパックが起きて snapshots tree に新しいチェーンができ、最後にようやく crictl images にイメージが登場します。順序を知っていれば途中で止まった状態を切り分けられます。contentにはあるのにsnapshotsにないならアンパック段階で止まったのであり、両方にあるのに crictl images にないならメタデータ登録の前に落ちたということです。
すでにキャッシュされている場合と新しく取得した場合の見分け方は単純です。crictl imagefsinfo の使用バイト数がpullの前後でほとんど変わらず、content ls の行数もそのままなら、レジストリから受け取ったものがないという意味です。ダイジェストが既にあればcontainerdはダウンロードを飛ばすため、imagePullPolicyがAlwaysでもマニフェストだけ確認してレイヤーは再利用されます。逆に使用バイト数がレイヤーのサイズ分だけ増えたなら実際に取得したということです。pullが遅いという申告を受けたとき、この2つの数字を比べるだけでネットワークの問題かアンパックの問題かが分かれます。
10. 失敗事例と診断の順序
ディスクが埋まっていくのに何も消えない、というのが最も多い申告です。containerdのGCはcronスケジュールではなく io.containerd.gc.v1.scheduler プラグインの閾値で回ります。ドキュメントが明示するデフォルト値は pause_threshold = 0.02、deletion_threshold = 0、mutation_threshold = 100、schedule_delay = "0ms"、startup_delay = "100ms" であり、デフォルト設定ではスケジューラがデータベースを98%の時間ロックされていない状態に保とうとし、削除が発生していないかデータベース書き込みが100回たまる前なら自分自身をスケジュールしないと説明されています。つまりイメージを消す主体はGCではなくkubeletのイメージガベージコレクションか人間です。確認順序はノードのディスク使用量、crictl imagefsinfo、ctr -n k8s.io snapshots usage、そしてkubeletのイメージGC閾値です。
ノードがpullの途中で再起動すると、コミットされていないingestが残ります。コンテンツストアのコミットは原子的なのでblobsディレクトリは汚れませんが、ingestディレクトリの断片はそのまま容量を占めます。ctr -n k8s.io content active にpullが何も進行していないのにREFが残っていればこのケースです。進行中の書き込み操作を打ち切る操作が2.2で見たAbortであり、コンテンツストアの整理は ctr -n k8s.io content prune references で行います。実際に消す前に --dry フラグで対象を先に見るのが安全です。
3つ目はリースです。リースは作業中のリソースをGCから守るために作るもので、ctr leases create の有効期限のデフォルトは24時間、0を与えると期限なしです。クライアントがリースを作って消さないまま落ちると、どのイメージも参照していないblobが生き残り続けます。症状は crictl images には数個しかないのに content ls にはダイジェストが大量にある状態で、確認は ctr -n k8s.io leases ls で行います。CREATED ATが古いリースが見えたら、何がそれを作ったのかから探す必要があります。
4つ目は discard_unpacked_layers を有効にしたノードでイメージを再pushしたりtarへ書き出そうとするときです。スナップショットはあるが元のレイヤーblobがないので失敗し、設定を戻しても既に消えたblobは戻りません。5つ目はミラー設定が無視されるケースで、ファイルがcerts.d配下のレジストリホスト名ディレクトリのhosts.tomlというパスに正確にあるか、そして config_path が実際に設定されているかを順に見ます。2.xではデフォルト値が空なので、ファイルを置くだけでは何も起きません。最後は非常に大きなレイヤーでのpullタイムアウトです。ここではレジストリの往復遅延ではなく進行が止まるかどうかを見る必要があり、max_concurrent_downloads と image_pull_progress_timeout を併せて調整します。
11. この層を直接触らないほうがよい場合
マネージドノードグループでは、containerdの設定ファイルがノードのブートストラップ時に再生成されることが多くあります。手で直した /etc/containerd/config.toml はノードが入れ替わった瞬間に消え、さらに悪いことに一部のノードにだけ残って再現できない差分を作ります。設定を変える必要があるなら、ノードに直接入るのではなくノードグループのブートストラップスクリプトやローンチテンプレート、あるいはノードイメージそのものを直すべきです。
そして大半のイメージ関連の問題はこの層を触らずに解決します。pullが頻繁で遅いならimagePullPolicyをIfNotPresentに下げ、タグではなくダイジェストで固定するほうが良い手です。レジストリの帯域が問題なら、hosts.tomlにミラーを付けるかクラスタ内にpull-throughキャッシュを置くほうが、スナップショッターを替えるよりはるかに小さいリスクで同じ効果を出します。スナップショッターの変更や discard_unpacked_layers のようなスイッチは戻しにくくノードごとに状態がばらけやすいので、上の方法を試し尽くした後に最後に切るカードです。
12. 参考資料
- containerd CRIプラグイン設定: https://github.com/containerd/containerd/blob/main/docs/cri/config.md (2026-08-16 確認)
- containerdスナップショッター一覧: https://github.com/containerd/containerd/blob/main/docs/snapshotters/README.md (2026-08-16 確認)
- containerdレジストリホスト設定: https://github.com/containerd/containerd/blob/main/docs/hosts.md (2026-08-16 確認)
- containerdガベージコレクション: https://github.com/containerd/containerd/blob/main/docs/garbage-collection.md (2026-08-16 確認)
- crictl 利用ガイド: https://github.com/kubernetes-sigs/cri-tools/blob/master/docs/crictl.md (2026-08-16 確認)
13. まとめ
containerdのイメージ管理はContent Storeのコンテンツアドレス保存、Snapshotterのレイヤー管理、GCのリソースクリーンアップという3つの軸で構成されます。overlayfs SnapshotterのCopy-on-Writeメカニズムは高速なコンテナ起動と効率的なディスク使用を可能にし、LeaseベースのGC保護はイメージ操作の安全性を保証します。