1. 「このノードだけ」image pullが失敗した日
K3sには、クラスタ全体で自動的にイメージを共有し合う組み込みレジストリミラーという機能がある。エッジ拠点が増えて、新しい工場にもう1台K3sエージェントノードを追加することになったとき、この機能のおかげで設定は不要だと思い込んでいた。手順は既存ノードと同じ、k3s agentコマンドでクラスタに参加させるだけ。ところが数分後、そのノードにスケジュールされたPodだけがImagePullBackOffで止まった。

既存ノードでは同じイメージが問題なくpullできている。おかしいのは新規ノードだけ。最初に疑ったのはネットワーク疎通だったが、pingもDNSも正常。原因はもっと地味な場所、K3sの組み込みレジストリミラーの仕様そのものにあった。
K3sはv1.27以降、SpegelというステートレスなP2P型OCIレジストリミラーを標準で同梱している。「組み込み」だから新しいノードを足せば自動的に恩恵を受けられる、と思い込んでいたのが最初のつまずきだった。
2. K3sの組み込みレジストリミラー(Spegel)はそもそも何をしているか
Spegelは、クラスタ内のどのノードが既に持っているイメージでも、他のノードからP2Pで直接取得できるようにする仕組みだ。有効化はserverノードで--embedded-registryフラグを指定するか、設定ファイルにembedded-registry: trueを書くだけで済む。

ただし、この手軽さの裏にはネットワーク要件がある。K3s公式ドキュメントによれば、全ノードは内部IPアドレス経由でTCPポート5001(P2P配布)と6443(ノードごとのローカルOCIレジストリ)に相互到達できなければならない。到達できない場合は外部レジストリへフォールバックするが、レイテンシが増える。
レジストリAPIへのアクセスにはクラスタのCAが署名したクライアント証明書が必須で、組み込みレジストリ自体は読み取り専用だ。SUSEの公式ドキュメントも、この分散レジストリが「クラスタメンバー間で対等な信頼」を前提にしていると明記している。つまり、ミラー対象としてregistries.yamlに登録したレジストリの認証済みイメージは、検証なしで全ノードからアクセスできる設計だ。
K3sの運用をフルマネージドで任せられるKuboのようなサービスであれば、こうしたネットワーク要件やTLS設定の初期検証はプラットフォーム側で担保されるが、自前運用ではここを手動で確認する必要がある。
3. 原因①: registries.yamlは「全ノード必須」という仕様の見落とし
調査を進めると、最初の原因が見えてきた。K3sはノードの起動時に/etc/rancher/k3s/registries.yamlの有無を確認し、存在すればcontainerdの設定に反映する。K3s公式のプライベートレジストリ設定ドキュメントには、この設定ファイルがプライベートレジストリから画像をpullする全ノード(serverとagentの両方)に配置されている必要があると明記されている。

新しいエッジノードのプロビジョニング手順書には、OSセットアップとK3sエージェントのインストールは書かれていたが、registries.yamlをコピーするステップが抜けていた。既存ノードはクラスタ構築時に手動でファイルを配置していたため問題が表面化せず、ノード追加作業のたびに「暗黙の前提」として忘れられていた。
さらに厄介なのは、設定ファイルを後から配置しても各ノードでK3sを再起動しない限り反映されないという点だ。ドリフトに気づかないまま再起動せずに放置すれば、永遠に直らない。
4. 原因②: ワイルドカードミラーとcontainerd 2.0の退行バグ
原因①を直してもまだ不安定だった理由は、registries.yamlのミラー設定にワイルドカード"*":を使っていたことにある。これは全レジストリを分散ミラーの対象にする便利な書き方だが、k3s-io/k3sのIssue #11857で報告されている通り、containerd 2.0へ移行したK3s v1.32.2+k3s1以降で、ミラーにイメージが無い場合のフォールバックが正しく働かずnot foundエラーでpullが失敗する退行バグが存在した。

このバグはv1.31.7-rc1・v1.32.3-rc2で修正済みだが、ワイルドカード設定とregistries.yaml未配布という2つの問題が重なったことで、原因の切り分けが一気に難しくなっていた。回避策は、ワイルドカードを使わず対象レジストリを明示的に列挙すること。もう一つ、Issue #10246では--bind-addressをデフォルトの127.0.0.1以外に変更している環境で組み込みレジストリへのpullが失敗する既知の不具合も報告されており、カスタムネットワーク構成を取っている場合は合わせて確認する価値がある。
5. 原因③: ノード間の認証情報ドリフトはサイレントに失敗する
registries.yamlを配布し、ワイルドカードも整理したあとに最後まで残ったのが、プライベートレジストリの認証情報そのものの食い違いだった。registries.yamlのconfigsセクションにはbasic認証やTLS証明書(cert_file・key_file・ca_file)を設定できるが、ノードごとにこの値がわずかに異なると、エラーメッセージが曖昧なままサイレントに失敗する。

実際にOneUptimeの運用レポートでも、ノードが異なるタイミングでプロビジョニングされるとイメージバンドルや認証情報が同期されず「設定ドリフト」が起きると指摘されている。認証失敗はネットワーク疎通の失敗ほど分かりやすいログを残さないため、トラブルシューティングの優先順位としては後回しにされがちだが、エアギャップ環境の運用では最初に疑うべきポイントの一つだ。
6. バニラKubernetesとの違い、そしてエアギャップ運用の正しい配布手順
ここで改めて確認しておきたいのは、Spegelのような組み込みP2Pレジストリミラーはバニラの標準Kubernetesには存在しないという点だ。標準Kubernetes自体はレジストリ機能を持たず、kubeletが設定されたレジストリから直接pullするだけなので、同等の仕組みを作るにはHarborのようなプルスルーキャッシュを別途構築し、自前で運用する必要がある。RKE2の公式ドキュメントでも同じSpegelベースの仕組みが採用されており、K3s系ディストリビューション共通の設計思想であることが分かる。

今回の教訓を踏まえると、エアギャップ/エッジ環境でノードを追加する際の正しい配布手順は次のようになる。
- 配布を自動化する: Ansibleやcloud-initの構成テンプレートに
registries.yamlを含め、手動コピーの手順を無くす - ワイルドカードを避ける:
"*":ではなく対象レジストリを明示的に列挙し、containerdのバージョン依存の挙動変化を避ける - 事前ヘルスチェックを入れる: ノード追加のプレイブックに、ポート5001・6443への到達性と認証情報の一致を確認するステップを組み込む
こうした配布・検証の自動化は、エッジ拠点が増えるほど運用負荷が跳ね上がる部分でもある。Kubo On-Premiseなら、Air-Gapped環境での完全自社管理を前提に、こうしたレジストリ配布や証明書管理の運用をフルマネージドで引き受けられる設計になっている。
まとめ
「組み込み機能だから自動的に全ノードで効くはず」という思い込みが、今回のインシデントの出発点だった。実際には、K3sの組み込みレジストリミラー(Spegel)は便利な一方で、registries.yamlの全ノード配布・ワイルドカード設定の既知バグ・ノード間の認証情報ドリフトという3つの落とし穴を併せ持つ。
エッジ拠点を増やすたびに同じ調査を繰り返すのは現実的ではない。データ主権やAir-Gapped要件を抱えながらエッジ×Kubernetesの運用を広げていくなら、レジストリ配布や証明書管理をプラットフォーム側に任せられるKubo On-Premiseの構成を検討してみてほしい。導入の相談はお問い合わせから受け付けている。