大規模Kubernetesクラスタにおけるコンテナレジストリの高速化

こんにちは。サマーインターンシップ2023のKubernetes基盤開発コースに参加した、高橋 (TAK848) と花田 (hanapedia) です。

Necoチーム では現在コンテナレジストリの可用性を高めるため、コンテナレジストリミラーをクラスタ内にデプロイしています。 しかし、クラスタが数百台程度まで大きくなり、レジストリミラーからコンテナイメージを同時に大量にPullするようになりました。 そのため、イメージの取得に数十分もの時間がかかってしまう問題が起きました。

この問題に対処するため、Spegel というステートレスなレジストリミラーの動作検証を行い、PodのイメージのPullのタイミングを制御するカスタムコントローラー cat-gate を開発・検証しました。 その成果を紹介します。

課題

KubernetesではPodを起動する際、コンテナイメージを取得する必要があります。Podがアサインされたノードにそのコンテナイメージがない場合、upstreamのコンテナレジストリからイメージをPullします。Kubernetesを運用する上で重要な役割を担うコンテナレジストリですが、

  • 単一障害点 (Single Point of Failure; SPOF) になり得る
  • 多数のPodを起動した際に大量のトラフィックが発生する
  • サービスによってはrate limitなどが設けられている

などといった課題が存在します。

外部のContainer Registryとの通信が止まるとコンテナイメージがダウンロードできない

Necoにおける解決策

Necoチームでは現在、セルフホストコンテナレジストリをレジストリミラーとして用い、pull-through cacheとして利用することで上記の課題に対応しています。

blog.cybozu.io

こうすることで、一度Pullしたイメージがレジストリミラーにキャッシュされるため、以下のようなメリットが得られます。

  • upstreamの障害の影響を受けにくい(初回のPullのみ影響を受ける)
  • upstreamとの通信量を減らせる
  • イメージのPullにかかる時間を短縮できる

現在の課題

上の方法で、upstreamのレジストリに由来する問題は緩和されました。しかし、Kubernetesクラスタの規模が大きくなるにつれ、レジストリミラーならではの課題が浮き彫りになりました。

例えば、クラスタ内でDeploymentやDaemonSetを作成すると、同時に大量のイメージ取得が発生するため、レジストリミラーに負荷が集中し、イメージの取得に数十分かかるケースが出てきました。

いくつかの解決策を検討しましたが、以下の理由でNecoでの採用は見送りました。

  • DragonflyKraken などといった分散型コンテナレジストリを用いてセルフホストレジストリをスケールする
    • DragonflyはバックエンドとしてDBやキャッシュを利用するため、運用すべきコンポーネントが増える
    • Krakenはpull-through cacheをサポートしていない
  • IPFS をcontainerdのバックエンドに指定し、ノード間でコンテナイメージを共有する
    • コンテナイメージの取得方法やIPFSの運用が複雑になる

そこで、ステートレスかつKubernetesネイティブなSpegelというOSSのレジストリミラーに白羽の矢が立ちました。

Spegel

SpegelはスウェーデンのXenit社が主体となって開発している、ステートレスな分散レジストリミラーです。OCIに準拠していますが、現在はcontainerdのみに対応しています。

アーキテクチャ

Kubernetesクラスタの各ノードのcontainerdが保持しているイメージを共有することで、Spegel自体がステートを持たない設計になっています。

containerdがupstreamからダウンロードしたイメージのdigestをSpegelインスタンス間で共有する

各ノード上のSpegelは、自分のノードのcontainerdが持っているイメージのdigestをP2Pで他のノードに広告しています。

Podが新しく作成されたとき、必要なイメージのdigestが他のノードから広告されていれば、そのノードにコンテナイメージを取りに行きます。広告されていなければ、upstreamのレジストリに取りに行きます。 取得されたコンテナイメージはcontainerdのキャッシュに保持され、以降他のノードから利用できるようになります。

デプロイ構成

SpegelはDaemonSetとして各ノードにデプロイします。 ノード上のcontainerdと通信するため、以下の設定が必要です。

  • containerdのsocketをSpegelのPodにHostPathでマウントする
  • Spegelのエンドポイントをローカル、リモートのcontainerdに公開する

コンテナイメージ取得の流れ

また、containerdの設定を変更し、ミラーを使いたいコンテナイメージのレジストリを以下の順番で解決するようにします。

  1. 同一ノードのSpegel
  2. 別のノードのSpegel
  3. コンテナレジストリ (upstream)

詳細なインストール手順はSpegelのREADMEをご確認ください。

上記の構成でSpegelをNecoのクラスタにデプロイし、パフォーマンスを計測するため以下の検証を行いました。

PodをデプロイしたときのPull速度の比較

Spegelを導入していない場合と導入した場合で、イメージのPullにかかる時間を計測しました。

まずは、あるイメージのPodをデプロイし、完了後に同じイメージのPodを別のノードにデプロイしました。

初回のデプロイ時はどのノードにもイメージが無いので、upstreamからイメージを取得します。 2回目のデプロイの際は、通常は自分のノードにイメージがないためupstreamからイメージを取得しますが、Spegelを導入すると最初のノードにPullされたイメージをSpegel経由で取得するため、高速化されます。

以下のテーブルにイメージのダウンロードにかかった時間をまとめました。 計測は6ノードの環境(以下、クラスタA)と77ノードの環境(クラスタB)で行いました。

クラスタ Spegelの有無 1ノード目 [s] 2ノード目 [s]
A(6ノード) なし 7.92 7.42
A(6ノード) あり 49.26 5.33
B(77ノード) なし 9.73 7.93
B(77ノード) あり 49.75 36.44

upstreamからイメージをPullした場合は、7-10秒ほどでダウンロードが完了しています。 Spegelを導入した場合、2ノード目でのPullは、クラスタAでは5.3秒で、upstreamから取得するより速くなりました。

クラスタBでは、2ノード目でのPullは1ノード目に比べて若干速くなっているものの、Spegelが導入されていない場合よりPullに時間がかかるようになりました。 ただし、十分な時間を空けてから以降のPodをデプロイするとクラスタAと同じような速度になることを別途確認しました。 他のノードでダウンロードしたイメージが利用可能になるまでに多少の時間がかかるようです。

また、いずれのクラスタでもSpegelを導入すると、初回のPullに数十秒ほどかかるようになりました。 動作を確認すると、レジストリミラーへの問い合わせ中にタイムアウトが発生していることが分かりました。 ただし、2つ目以降のPodの起動が速くなるため、同一のイメージを繰り返し使った場合、全体としてイメージの取得は速くなります。

DaemonSetをデプロイしたときのPull速度の比較

次に、より実際のアプリケーションをデプロイした場合に近いパフォーマンスを計測するため、DaemonSetをデプロイしました。

Spegelを導入していない場合と導入した場合それぞれで、DaemonSetの初回デプロイ時と、1台ずつローリングアップデートした時の所要時間を比較しました。

クラスタA(6ノード)

各PodのイメージのPullにかかった時間は以下の通りです。

起動した順番 条件1 条件2 条件3 条件4
Spegelなし
初回デプロイ
Spegelなし
アップデート
Spegelあり
初回デプロイ
Spegelあり
アップデート
1 7.33 8.04 48.31 49.27
2 7.16 7.43 47.76 5.28
3 7.24 8.07 48.80 5.35
4 7.47 7.08 48.62 5.35
5 7.56 7.04 48.62 5.32
6 7.52 7.05 48.36 5.28

ワークロードリソースを最初にデプロイする時は、全てのPodが同時に起動し、イメージのPullを開始します。 そのため、Spegelの有無に関わらず、各ノードそれぞれがupstreamからイメージをPullします。

ローリングアップデート時も、Spegelが無い時は各ノードからイメージのPullが行われます。それぞれが約7秒かかり、全体で約50秒かかりました。

一方Spegel導入時は、1回目のPullはCache Hitせずupstreamから取得していて、約49秒かかりました。 2台目以降は1台目がダウンロードしたイメージがCache Hitし、毎回5秒ほどでPullができています。 upstreamからの取得が1度初回に行われ、ここでは時間はかかりますが、2回目以降はクラスタ内のノードから高速に取得ができています。

クラスタB(77ノード)

クラスタB(77ノード)では、DaemonSetのマニフェストを設定してから全てのPodがReadyになるまでの時間を計測しました。

Spegel の有無 初回デプロイ [s] ローリングアップデート [s]
なし 27.62 839.52
あり 66.64 916.87

各Podのコンテナイメージをダウンロードしている時刻

初回デプロイ時にイメージをPullする時間は、Spegelを導入しない場合、Podの数が1つの場合(約8秒)より時間がかかって約28秒となりました。 これは77ノードからupstreamに同時にイメージのPullをリクエストしているため、通信が遅くなっていると考えられます。

Spegel導入してローリングアップデートを行った場合、Pod単体での検証と同様に、最初のPod数個分のイメージのPullで時間がかかりました。 ただし、後続のPodはキャッシュからイメージを取得するためSpegelなしの場合(約7秒)より速く、それぞれ5秒ほどで完了し合計900秒程度かかりました。

また、本筋とはあまり関係がありませんが、イメージをノードにダウンロードするのにかかる時間が47秒 > 36秒 > 15 秒…と段階的に下がっていく現象も確認できました。

Spegel の導入で見えた課題

実際にクラスタを運用する際は、DeploymentやDaemonSetなどで複数のPodをまとめてデプロイしたり、更新したりすることが多いと思います。 ただ、上記で見た通り、大規模クラスタでSpegelを利用するには以下のような課題があることが分かりました。

  1. DaemonSetなどを使って大量のPodを同時にデプロイするとキャッシュが効かない
  2. レジストリミラーへの問い合わせのタイムアウト待ちに時間がかかり、ローリングアップデートが遅くなってしまう場合がある

我々はこの2つの課題に対し、

  1. Podのスケジューリングのタイミングを調整するカスタムコントローラの作成
  2. Spegelのパラメータ調整

という2つの手段で対策を行いました。 以下で内容を説明していきます。

cat-gate

cat-gateは、Kubernetes 1.26で導入された Pod Scheduling Readiness という機能を使って、Podのスケジューリングのタイミングを調整するカスタムコントローラです。

Pod Scheduling Readiness

通常、Podは作成直後にkube-schedulerによってノードにスケジュールされ、利用するコンテナイメージのダウンロードが始まります。 しかし、以下のように schedulingGates を指定することでノードへのスケジュールを保留させることができます。

apiVersion: v1
kind: Pod
metadata:
  name: sample
spec:
  schedulingGates:
  - name: my-scheduling-gate
  containers:
  - name: ubuntu
    image: ubuntu:22.04

設計

cat-gate は、以下の方法でPodのスケジューリングのタイミングを調整します。

  1. MutatingWebhook で全てのPodに以下の処理を行う
    1. scheduling gateの付与
    2. 必要なイメージの一覧のハッシュ値を計算し、Podのannotationに付与
  2. Podの Reconciler で以下の処理を行う
    1. Reconcilerに入ってきたPodと同じハッシュ値を持つPodの一覧を作成し、「コンテナイメージをダウンロードしているPodの数 (X)」と「コンテナイメージを持っているノードの数 (Y)」を計算する。ただしYは「コンテナのステータスがRunningまたはTerminatedになっているPodが乗っているノードの数」で代用する
    2. X < Y * 2 であれば、Reconcile対象のPodのscheduling gateを外す

Node 1-4に起動中のPod(と必要なイメージ)があり、Node 5-12にイメージをダウンロードしている様子

この設計により、たとえば4台のノードがコンテナイメージを持っていれば、Pod 8個までscheduling gateを外すことができます。 各ノードは平均して2並列までしかイメージをリクエストされないため、特定のレジストリミラーのインスタンスに負荷が集中する問題を回避できます。

mirror-resolve-timeoutの設定

先述の実験で各種ログを確認したところ、Spegelが他のノードのインスタンスからの応答を長時間待っているために最初のPodのデプロイが遅くなることが分かりました。 Necoのクラスタのノードは高速なネットワークで相互接続されているため、Spegelの --mirror-resolve-timeout をデフォルトの5秒から2秒に切り詰めました。

再測定

クラスタB(77ノード)にcat-gateをデプロイし、--mirror-resolve-timeout を設定して再度測定を行いました。 結果を表にまとめると以下のようになりました。全てのデプロイが完了するまでに要した時間は105秒ほどでした。

起動した順番 Pull の開始時刻 [s] Pull の所要時間 [s]
1 0.00 25.72
2-3(計2台) 23.50 20.25
4-6(計3台) 45.00 19.39
7-9(計3台) 53.00 19.32
10-27(計18台) 61.28 6.21
28-77(計50台) 73.28 7.08

イメージをPullする経過をガントチャートで視覚的に表すと以下のようになりました。

cat-gate導入時のPodのイメージのダウンロード時刻

最初の数台は、Spegel経由でのPullに多少時間がかかっているものの、後半に進むにつれてまとめてコンテナイメージを受け渡しできるようになり、デプロイが高速化されました。 ノード数を増やしても所要時間は対数でしか増えず、イメージのPullはごく少ない回数で済むという二重の恩恵を得ることができました。

ローリングアップデートの挙動は大きな変更がないため検証を省きますが、初回のダウンロードにかかる時間が改善されるため、更新が完了するまでの時間が短縮されます。

おわりに

本記事では、ステートレスな分散レジストリミラーであるSpegelと、Podのスケジューリングのタイミングを調整するcat-gateを紹介し、実際のクラスタに適用した際のパフォーマンスを解説しました。 今回はテスト用のイメージにのみ設定を行ったため、今後はクラスタ内のなるべく多くのイメージに対してレジストリミラーを設定し、動作を確認していきます。

今回のインターンでは、最初はKubernetesの基礎から深い内容、コンテナランタイム、Kubebuilderについてなど、様々なテーマでハンズオンや勉強会がありました。 それらをモブプログラミングでアウトプットしていく形式で、とてもやりやすかったですし、知識の定着や実践的な検証・深掘りがたくさんできました。 その上で、コントローラーを実装し、OSSとして公開できて、非常に充実した3週間でした! このままの勢いで、自宅にKubernetesクラスタを組んでみたいな、と思っています。

一方で、今回の期間で解決できなかった部分もありました。 Spegelを介した際の初回のイメージPullにかかる時間の長さや、ノード数が増えた際に、段階的にイメージPullにかかる時間が変わっていく現象などの原因は詳しく解明することができませんでした。 なので今後、Spegelのソースコードを読んでみようと思います。