📖

re:Invent 2023: AWSチームがKarpenterの威力を解説 - Kubernetes運用の革新

2023/11/28に公開

はじめに

海外の様々な講演を日本語記事に書き起こすことで、隠れた良質な情報をもっと身近なものに。そんなコンセプトで進める本企画で今回取り上げるプレゼンテーションはこちら!

📖 AWS re:Invent 2023 - Harness the power of Karpenter to scale, optimize & upgrade Kubernetes (CON331)

この動画では、AWSのプリンシパルソリューションアーキテクトRajdeep SahaとEKSのプリンシパルエンジニアEllis Tarnが、最新のKubernetesスケーラーKarpenterの魅力を解説します。Cluster Autoscalerを超える柔軟性、コスト最適化、多様なワークロードのサポート、そしてアップグレードの自動化など、Karpenterの革新的な機能を詳しく紹介。NP困難な問題に挑むbin-packingアルゴリズムの裏側や、Driftを活用したゼロタッチアップグレードの仕組みなど、Kubernetes運用の常識を覆す話題が満載です。
https://www.youtube.com/watch?v=lkg_9ETHeks
※ 動画から自動生成した記事になります。誤字脱字や誤った内容が記載される可能性がありますので、正確な情報は動画本編をご覧ください。

本編

Karpenterの紹介:最新のKubernetesスケーラー

Thumbnail 0

よし、始めましょう。このトークにお越しいただき、ありがとうございます。今回は、最新のKubernetesスケーラーであるKarpenterの力を活用して、Kubernetesをスケール、最適化、アップグレードする方法について説明します。始める前に一言。VenetianやCaesar Forumからいらっしゃった方がいれば、ご苦労様でした。私たち二人もVenetianから来ましたが、30分以上かかりました。

私はRajdeep Sahaと申します。AWSのプリンシパルソリューションアーキテクトとして5年ほど勤めており、お客様のKubernetesやサーバーレスへの導入をサポートしています。また、著書も出版しており、YouTuberとしても活動しています。では、共同発表者を紹介させていただきます。

ありがとうございます。Ellis Tarnです。EKSのプリンシパルエンジニアをしています。3年前にKarpenterプロジェクトを立ち上げました。素晴らしい、Karpenterの創設者がここにいらっしゃいます。ありがとう、Ellis。では、始めましょう。

Karpenterの基本機能と従来のCluster Autoscalerとの違い

Thumbnail 60

Thumbnail 70

Thumbnail 80

さて、私たちは何をしようとしているのでしょうか?基本的に、Cluster Autoscalerの役割は、1つのEC2インスタンスで実行されているアプリケーションを管理し、スケールアップする際に複数のEC2インスタンスに拡張することです。アプリケーションのトラフィックが増加すると、ポッドはHorizontal Pod Autoscalerを使用してスケールし、それらのポッドは実行中のEC2インスタンスにスケジュールされます。しかし、ポッドの数が増えると、既存のEC2インスタンスは容量に達します。その時点で、トラフィックが増え続けると、一部のポッドはペンディング状態になります。

Thumbnail 120

Thumbnail 130

以前のCluster Autoscalerバージョンでは、Auto Scaling Groupとやり取りしていました。異なるノードグループを設定し、それらをAuto Scaling Groupに結びつけてEC2インスタンスを作成する必要がありました。Karpenterはそのすべてを取り除きます。Karpenterはスケジューラーと直接やり取りします。ペンディング状態のポッドに対して、Cluster AutoscalerとAuto Scaling Groupのステップをスキップします。Karpenterはスケジューラーと通信し、EC2と直接やり取りして、EC2インスタンスをプロビジョニングします。

Thumbnail 160

Karpenter は EC2 API と直接やり取りするため、より高速で柔軟性が高く、Kubernetes ネイティブです。どのような EC2 インスタンスをプロビジョニングするか、AMI などを細かく指定できる大きな制御性があります。そして、それを行うのに 2 つの YAML ファイルを使用します:以前 Provisioner と呼ばれていた NodePool と、以前 AWSNodeTemplate と呼ばれていた EC2NodeClass です。

Thumbnail 170

スケーリングは、もちろん Karpenter の真骨頂ですが、Karpenter の素晴らしさはスケーリング以上のことができる点にあります。Karpenter はコスト最適化を支援し、機械学習や生成 AI を含む多様なワークロードをサポートし、さらにアップグレードやパッチ適用も支援します。このトークを通じて、それがどのように行われるかをお見せします。要するに、完全なデータプレーンの実装を支援するのです。

Karpenterの評価と実装:EC2インスタンスの選択とオーケストレーション

Thumbnail 200

Karpenter はオープンソースで、現在 CNCF プロジェクトになっています。つい数週間前、Karpenter は SIG Autoscaling に受け入れられました。さて、この時点で「よし、採用しよう」と考えているとしましょう。どんなステップを踏むべきでしょうか?一般的には、まず評価を行い、Karpenter が提供する機能を確認し、その後実装します。実装後は、Day 2 オペレーションについて考える必要があります。私たちのトークもこの流れに沿って進めていきます。まずは評価から始めましょう。

Thumbnail 240

Thumbnail 250

コンテナはどのようにスケールするのでしょうか?スケールするには何が必要でしょうか?CPU、メモリ、ストレージ、ネットワーク、時には GPU が必要で、これらは EC2 上で動作します。私たちは簡単にはしていません。100 種類以上の EC2 インスタンスタイプがあり、インスタンスタイプによって、より高い CPU や、より高い CPU 対メモリ比などが得られます。これは大きな利点ですが、同時にインスタンスの選択とオーケストレーションを複雑にしています。Karpenter はこれらすべてを支援します。

Thumbnail 270

先ほど NodePool について話しましたね。これを、どのような EC2 インスタンスをプロビジョニングしたいかを定義できる YAML ファイルだと考えてください。ポッドが Pending 状態になり、スケジューラーが Karpenter と通信するたびに、この YAML ファイルを参照します。では、この YAML ファイルを見ていきましょう。このファイルでは、Karpenter にプロビジョニングさせたいインスタンスの種類を指定できます。例えば、インスタンスファミリーを C5、M5、R5 とし、インスタンスサイズを nano、micro、small 以外に指定できます。

これはほんの一例です。greater than(より大きい)、less than(より小さい)、その他さまざまな演算子も使えます。また、これらを完全にスキップすることもできます。これがKarpenterの強力な機能の1つです。Karpenterは、podの定義に基づいて最適なEC2インスタンスタイプを自動的に選択します。このNodePoolが提供できるEC2インスタンスの数を制限することもできます。下部にあるように、このNodePoolはCPUコア数が100に達するまでEC2を提供し続けます。同様に、EC2をプロビジョニングするアベイラビリティーゾーンを指定することもできますし、これもスキップすれば自動的に最適なアベイラビリティーゾーンを選択します。

Thumbnail 360

次に、x86 EC2 かGravitonを指定したり、SpotやOn-Demandなどの購入オプションを指定したりできます。これは、SpotとOn-Demandインスタンスを混在させたい場合に非常に便利な機能です。単に「Spot, on-demand」と指定するだけで、Karpenterは常にSpotを優先しようとします。さらに、SpotがOn-Demandより高価な場合、代わりにOn-Demandインスタンスをプロビジョニングするほど賢いのです。もちろん、これらすべてをスキップすることもできます。私たちは、すぐに使えるようにしたかったので、このSpotとOn-Demandの部分をスキップすると、何も指定されていない場合はOn-Demandをプロビジョニングします。

Karpenterのスケジューリング制約とSpot中断ハンドリング

Thumbnail 400

最初のスライドで、KarpenterはKubernetesネイティブだと言いましたね。それはどういう意味でしょうか? Kubernetesのワークロードが特定のアベイラビリティーゾーンや特定のインスタンスタイプ、Spot、On-Demandなどで実行する必要がある場合があります。それをどうやって実現するのでしょうか? Node Selector、Node Affinity、TaintsとTolerations、Topology Spreadなどのメカニズムを使います。Karpenterは、podのスケジューリング制約と連携して動作します。例を挙げて理解してみましょう。きっとこの部分をよく使うことになると思います。

Thumbnail 440

Thumbnail 460

Thumbnail 480

Thumbnail 500

左側にNodePoolがあります。このNodePoolは、プロビジョニングするEC2インスタンスがSpotかOn-Demand、x86かGravitonでなければならないと指定しています。そして、このNodePoolがプロビジョニングするノードには、これらのラベルが追加されます。 つまり、このNodePoolがSpotとAMD64インスタンスをプロビジョニングすると、そのノードにkarpenter.sh/capacity-type: SpotとKarpenter.io/architecture: amd64というラベルが追加されます。これは、pod定義ファイルで これらを使ってpodをスケジュールできるということです。右側のpod定義ファイルで、このpodをSpotインスタンスでスケジュールする必要があると指定すると、KarpenterはSpotインスタンスをプロビジョニングしてこのpodをスケジュールします。

Thumbnail 520

さらに、Karpenterは自動的にノードに追加される多くの既知のラベルをサポートしています。これらのパラメータをすべて使用してノードをスケジュールできます。これにより、ワークロードのスケジューリングに比類のない柔軟性が得られます。ユーザー定義のTaints、Annotations、Labelsはどうでしょうか? Karpenterはこれらもサポートしています。左側には、起動するすべてのノードにteam: team-aというラベルを付けるNodePoolの例があります。右側では、そのラベルを使用してこのNodePoolでプロビジョニングされたpodをスケジュールできます。これは、3つのチームがある場合、team-one、team-two、team-threeというラベルを付け、pod定義でそれぞれスケジュールするという良い方法です。

Thumbnail 560

Thumbnail 570

Thumbnail 580

Spotの中断について説明しましょう。Spotは素晴らしいですね。NodePoolsはオンデマンドとSpotの混合で構成できますが、ここで驚くべきことがあります。Karpenterには組み込みのSpot中断ハンドラーがあるのです。Spot中断を捕捉して他の追加ソフトウェアを使用する必要はありません。KarpenterはAmazon EventBridgeイベントを通じて中断通知を追跡します。Karpenterをインストールする際に、このEventBridgeの名前を指定すれば、あとはKarpenterが全てを処理します。2分間の中断警告を捕捉し、Spotインスタンスをスピンアップします。ノード終了ハンドラーを使用する必要はありません。また、先ほど述べたように、Spotがオンデマンドと比較して高価になりすぎた場合、Karpenterは賢くオンデマンドインスタンスをスピンアップします。

NodePoolの戦略と重み付けプロビジョニング

Thumbnail 610

ここで理解しておくべきことは、ペンディング状態のポッドがあり、そのポッドにはスケジューリング要件があり、そしてNodePoolがあるということです。NodePoolにも制約があり、これらが相互に作用して、EC2ノードがスケジュールされるか、プロビジョニングされるのです。

Thumbnail 640

NodePoolを定義するには、さまざまな戦略があります。組織全体で単一のNodePoolを使用することができます。Graviton、x86、その他さまざまなものを混在させ、すべてのアプリケーションがその単一のNodePoolを使用します。真ん中の戦略は、マルチテナント環境でよく使われています。チームAにはこのNodePool、チームBにはこのNodePoolというように割り当てることができます。その理由として、チームAがGPUを使用する必要がある場合や、セキュリティ上の分離が必要な場合、異なるAMIを使用する場合、ノイジーネイバーによるテナント分離が必要な場合などが考えられます。

Thumbnail 680

Karpenterでは、CPUやメモリの使用量を制限できることを覚えていますか?あるプロジェクトが多くのハードウェアを使用する必要があるが、特定のCPUやメモリに制限したい場合、この戦略を使用できます。最後の戦略は非常に興味深いものです。これは重み付けプロビジョニング戦略です。ノードのスケジューリング要件が複数のNodePoolと重複する場合、NodePoolに優先順位を割り当てることができ、優先度の高いNodePoolが優先して考慮されます。

Thumbnail 710

例を挙げて説明しましょう。ここに2つのNodePoolがあります。左側は、コンピューティング節約プランや予約インスタンスに登録していて、インスタンスカテゴリCとR、インスタンスCPUが16コアまたは32コア、ハイパーバイザーとNitroがあるとします。下部の重みを見てください。重み60は常に60未満のものよりも優先されます。つまり、ポッドがペンディング状態になり、新しいEC2をスケジュールする必要がある場合、左側のNodePoolが常に優先されます。CPUが1000コアまたはメモリが1000ギガバイトに達するまで、EC2のプロビジョニングを続けます。

Thumbnail 780

コンピューティングのセービングプランや予約インスタンスをすべて使い切った後、他のNodePoolが起動します。そして、他のインスタンスタイプが立ち上がります。これは、Spotインスタンスとオンデマンドインスタンスの比率や予約インスタンスなどを実装する素晴らしい方法です。次に、コスト最適化について話しましょう。これは私のお気に入りの機能の一つです。時間が経つにつれて、クラスターはこのような状態になるかもしれません。4つの残留インスタンスが実行されています。最初のインスタンスは素晴らしく、うまくbin-packingされています。残りの3つはそれほどでもなく、多くの未使用ノードがあります。

Karpenterによるコスト最適化とbin-packing

Thumbnail 830

Karpenterを使えば、NodePoolにこの「未使用時の統合ポリシー」を設定するだけで、Karpenterが自動的にポッドをbin-packingし、他のEC2インスタンスを削除します。それだけでなく、十分にインテリジェントです。例えば、この場合、最後の2つのEC2インスタンスがm5.xlargeだとします。これら2つのポッドを1つのm5.xlargeに統合しても、まだ無駄が生じます。そこでKarpenterは、それら2つのm5.xlargeを削除し、m5.largeをプロビジョニングしてノードをbin-packingします。つまり、より適切なワーカーノードの選択とコスト削減が可能になります。

Thumbnail 850

これまで話した機能をすべて組み合わせると、KarpenterはCluster Autoscaler、node groups、node termination handlers、deschedulerの機能を統合しています。もはや、これらの異なるスタックを維持する必要はありません。単一の一貫性のあるソフトウェアスタックを作成するので、オーバーヘッドが減少します。では、これらはどのように内部で機能しているのでしょうか?この魔法はどのように起こるのでしょうか?それについて話すために、Ellisを招待したいと思います。

Thumbnail 890

ありがとう、Raj。Karpenterが何をするのかについて説明しましたが、次はそれがどのように機能するかについて少し詳しく説明します。Karpenterが最も一般的に行うことの一つであるノードの起動について深く掘り下げていきます。このワークフローには4つの主要な要素があります:スケジューリング、バッチング、bin-packing、そして最後に起動の決定です。Karpenterはkube-schedulerと連携して動作します。Karpenterを後半部分、kube-schedulerを前半部分と考えることができます。

Thumbnail 920

Kube-schedulerは既存のキャパシティの割り当てを担当し、Karpenterは既存のキャパシティに空きがなくなった時に新しいキャパシティを起動します。

Karpenterの内部動作:スケジューリングからノード起動まで

スケジューリングには、考慮すべき多くの要因があります。Kubernetesには、基盤となるコンピュートでコンテナやプロセスを実行するための豊富なスケジューリング言語があります。kube-schedulerは、ポッドとノードを見て、リソース要求、tolerations、node selectors、topology spreadsなどの一連の設定を考慮します。これらは、kube-schedulerとKarpenterのアルゴリズムの両方で考慮される必要があります。Karpenterは、起動の決定を行う際に制約が満たされていることを確認するために、これらの概念をメモリ内に保持します。

kube-schedulerも同様のシミュレーションを行います。要求を調べ、ポッドが必要とするCPUやメモリなどのリソース量を決定します。tolerationsを見て、どのノードが利用可能か、例えばポッドがGPUやSpotインスタンスで実行できるかどうかを判断します。また、ARMやAMDなど、必要なアーキテクチャや、ポッドを実行すべきチームのコンピュートも考慮します。これらの制約を満たす容量がない場合、ポッドはunschedulableとマークされ、これを私たちはpendingと呼びます。

Thumbnail 1000

pendingポッドのリストができたら、いつ起動をトリガーするかを決定する必要があります。各ポッドに対して即座に起動すると非効率的で、1ノードに1ポッドという結果になってしまいます。そこで、容量を素早く起動することと効果的なbin-packingのバランスを取るために、expanding windowアルゴリズムを使用します。このトレードオフを行うために、1秒と10秒という値を選択しました。

Thumbnail 1050

Thumbnail 1060

Thumbnail 1070

具体的な仕組みは次のとおりです。T=0でクラスターにポッドが現れ、pendingとマークされた場合、T=1まで待ちます。この1秒間のウィンドウ内に他のポッドが現れなければ、それをバッチとみなし、フラッシュして起動アルゴリズムに進みます。 より複雑なケースでは、ポッドの後に0.5秒以内に別のポッドのセットが続くかもしれません。 最終的にアイドル期間が得られたら、そのバッチを取り、フラッシュして、より効率的なbin-packを実現します。

Thumbnail 1090

Thumbnail 1100

そのバッチの後、別のポッドが入ってきて他に何も見えない場合、別のバッチをフラッシュします。 その後、4つの新しいポッドが入ってきたら、さらに別のバッチをフラッシュします。このオンラインとオフラインのbin-packingのプロセスは、コンピューターサイエンスの古典的な分野であり、Karpenterのヒューリスティクスで広範に考慮されています。

ポッドが途切れることなく連続的に流れ込む場合、例えば数千のノードと数十万のポッドを持つビジーなクラスターでは、ある時点で行動を起こす必要があります。私たちは拡張ウィンドウを使用し、10秒待ってそのバッチを取得し、その後すぐに残りのポッドで別のバッチを取得します。

Thumbnail 1140

Thumbnail 1160

Thumbnail 1170

ポッドを取得したら、どのインスタンスタイプを起動するかを決定する必要があります。まず、EC2のdescribe instances APIを使用してインスタンスタイプを発見し、利用可能なCPUとメモリの全体像を把握します。 次に、最もコスト効率の良いオプションを最適化するために、インスタンスタイプをコスト順にソートします。 最後に、セット理論を使用してポッドの制約を互いに、そしてNodePoolの定義と比較・モデル化することで、要件の交差を行います。

このプロセスには、アーキテクチャ、オペレーティングシステム、サイズ(インスタンスで利用可能なCPUとメモリ)など、さまざまな次元の大規模な集合交差が含まれます。スケジューリングとビンパッキングのコンポーネントは、同じプロセスの2つの部分として協力し、Karpenterで効率的かつコスト効果の高いノードの起動を確保します。

Thumbnail 1220

私たちは、再利用、拡大、または作成という戦略に従います。 これらのポッドがあり、仮想ノード(そのポッドセットに対して起動すると予想されるノード)を作成します。実際には、そのノードに利用可能なインスタンスタイプの完全なリストを維持し、このソート順のいずれかのインスタンスタイプがこの仮想ノードに使用できると判断します。

Thumbnail 1240

最初のポッドを追加し、さらにポッドを追加していくと、 制約が変化し、利用可能なインスタンスタイプも変わります。それらのポッドにインスタンスタイプの制約がある場合、突然そのノードに使用できなくなるインスタンスタイプが出てくるかもしれません。CPUとメモリの要件が大きくなりすぎて、そのノードで利用可能なインスタンスタイプのリストからインスタンスタイプを削除しなければならなくなるかもしれません。保留中のすべてのインスタンスを確認し、最低コストを確保しながら、可能な限り大きなインスタンスを作成しようとします。

私たちが進めていく中で、仮想ノードを再利用してポッドを追加したり、小さなインスタンスタイプを利用可能なセットから削除して仮想ノードを拡張したり、あるいはスケジューリングの制約が無効な場合(例えば、一緒に実行できない anti-affinity を持つ2つのポッド)には新しい仮想ノードを作成したりすることができます。考慮すべき複雑な要因は他にもたくさんあります。例えば、ボリュームトポロジーを考える必要があります。ポッドが EBS ボリュームを必要とし、そのEBSボリュームが1つのゾーンでしか利用できない場合があります。そのポッドを再利用する際には、ボリュームトポロジーの制約を考慮して条件を絞り込む必要があります。

また、インスタンスで利用可能なボリューム数、そのインスタンスタイプにマウントできるボリューム数、ホストポート、利用可能な ENI の数、DaemonSet の構成なども考慮する必要があります。これらを考えると、状況はあっという間に複雑になります。最後に覚えておくべきことは、私たちが実際には利用率の最適化を目指しているわけではないということです。これは、特に Spot を使用している場合に、Karpenter の動作を観察している人々からよく質問されることです。利用率は確かに重要で、予約状況は Karpenter が正しく機能していることを示す良い指標ですが、私たちが本当に注目しているのは、結局のところ純粋なコストなのです。時には、低い利用率であっても、このコンピューティングのコストが私たちの関心事であるため、そのような状況になることもあります。

KarpenterのDay 2オペレーションとAMI管理

Thumbnail 1350

bin-packing がいかに難しいかという点をもう少し掘り下げてみましょう。これは NP 完全問題です。私たちには完璧に解決する望みはありません。そのため、常に複雑化している発見的手法を使用しています。今私が具体的な手法を説明したとしても、数週間後には変わっているかもしれません。考慮すべき次元は数十にも及び、それによって問題空間はさらに拡大します。先ほど kube-scheduler と同様のシミュレーションを行っていると言いましたが、kube-scheduler は非常に似た探索問題を解いています。既知のノードセットの中からどのノードを使用できるかを探索しているのです。

私たちは実際には探索を行っていません。なぜなら、私たちの問題空間があまりにも大きいからです。新しいノードを一から作成したり、再利用したり、拡張したりできます。そのため、同じ制約の下で探索問題ではなく生成問題を解いているのです。まだ解決されていない疑問もあり、判断を下さなければならない場合もあります。例えば、特定のアベイラビリティーゾーンで実行するという優先事項がある場合、私たちはそれを尊重しようとしますが、そのゾーンにキャパシティがない場合はどうすればよいでしょうか?プロビジョニングを停止して優先事項を無視するか、その優先事項で起動を試みて、不可能な場合は優先事項なしで起動するか、といった選択肢があります。

優先事項にはさまざまな種類があり、お客様の皆様には優先事項の扱い方について活発な議論に参加していただいています。これは常に進化しており、その議論は bin-packing の複雑さにも影響を与えています。最後に注意すべき点は、Kubernetes が非常に動的だということです。ある時点でクラスターのスナップショットを取得して決定を下すことができたとしても、その決定を実行してインスタンスを起動する頃には、クラスターの状況が変わっているかもしれません。例えば、最初にあった5つのポッドの1つが削除されたり、新しいポッドが追加されたり、あるいは Spot によってノードが中断されたりする可能性があります。

Thumbnail 1480

Thumbnail 1490

この変更の問題やNP完全問題において、完璧を期待することはできません。そのため、できるだけ迅速かつ継続的にヒューリスティックベースの決定を行うよう努めています。時間をかけて最適化と問題解決を続けることで、完璧ではないものの、常に改善されていく状態を目指しています。 最後に、起動の決定についてです。先ほど、仮想ノードに利用可能なインスタンスタイプのセットがあると言及しました。特にSpotを使用する場合、20種類のインスタンスタイプの柔軟性を目標としています。 これは、Spotの利用不可や、GPUなどの特殊なインスタンスタイプや小規模リージョンでのICE(Insufficient Capacity Exception)を回避するための魔法のような工夫です。時にはSpotの容量が単に利用できないこともあります。

Karpenterが多様なインスタンスタイプを維持できる能力により、EC2 Fleet APIと対話することができます。「これらが全てのオプションです。オンデマンドの場合はこの割り当て戦略で1つを選んでください」と伝え、Fleetは最低価格を使用し、これらの制約を満たすEC2上で利用可能な容量を選択します。Spotの割り当てには少し注意点があります。Spotの場合、単に最低価格を選ぶと、ほとんど利用できない非常に小さな容量プールを選んでしまい、中断のリスクが高くなることがあります。そこで、価格容量最適化戦略を使用します。これはEC2 Fleet APIの機能で、「最も安いインスタンスタイプを選んでください。ただし、中断されそうなものは選ばないでください」と指示できます。このようにバランスを取り、Fleet APIにその判断を任せています。

Thumbnail 1590

では、KarpenterのDay 2オペレーションとオンボーディングについて、Rajに引き継ぎます。ありがとう、Ellis。さて、ここまでで皆さんは「Karpenterは素晴らしい。どうやって導入できるんだろう?」と考えていることでしょう。私たちは簡単に導入できるようにしました。AWS public ECRからKarpenter Helmチャートをインストールでき、KarpenterはEC2ノードでも動作します。 ただし、Karpenter自身が管理するノードでKarpenterを実行しないでください。2ノードのノードグループで実行するか、Fargateで実行することをお勧めします。現在、多くの方がCluster Autoscalerを使用していて、Karpenterに移行したいと思っているでしょう。そこで、Cluster AutoscalerからKarpenterへの移行方法をステップバイステップで説明したガイドを用意しています。多くのお客様がカスタムAMIをAMIパイプラインで使用していますが、朗報です。パイプラインのほとんどを再利用できます。パイプラインの最後のステップで、新しいAMIをASGのlaunch templateに書き込む代わりに、EC2NodeClass(以前はAWSNodeTemplateと呼ばれていました)に書き込みます。

Karpenterのドリフト機能とAMIアップグレード

Thumbnail 1640

Thumbnail 1670

Karpenterにオンボードしたら、 ログやメトリクスをどのように観察すればよいでしょうか?Karpenterは結局のところ、podとして実行されています。そのため、kubectl logs deployments/karpenterなどのコマンドを実行するだけでログを確認できます。KarpenterはPrometheusメトリクスも出力します。PrometheusとGrafanaをインストールすれば、podの起動レイテンシー、ノードの使用率、その他多くのメトリクスを監視できます。 さて、ここからDay 2オペレーションについて説明します。お客様が直面する大きな課題の1つは、Kubernetesのバージョンがアップグレードされたり、AWSが新しいAMIをリリースしたりするたびに、それらのAMIを常に変更してアップグレードをトリガーする必要があることです。Karpenterは、Driftと呼ばれる概念を使ってこの問題を解決します。

まずDriftについて理解し、その後実際の動作を見てみましょう。NodePoolで、例えばインスタンスタイプがm5.largeだとします。このNodePoolがプロビジョニングするすべてのEC2インスタンスはm5.largeになります。しばらくして、m5.largeがワークロードに最適ではないと気づき、c5.largeに切り替えたいと思ったとします。この時点で、実行中のインスタンスはm5.largeですが、NodePoolのCRD(カスタムリソース定義)はc5.largeを指定しています。これがDriftです。Driftは、CRDの値とマシンの実際の状態の不一致であり、これが発生すると、Karpenterは自動的に調整します。既存のm5.largeノードをcordonし、drainして、podを新しいc5.largeインスタンスにスケジュールします。DriftはKarpenterのグローバル設定configmapで有効にできます。バージョン0.32以降では自動的に有効になります。

Thumbnail 1770

では、これを実際に見てみましょう。NodePoolについて多く話しましたが、EC2NodeClassと呼ばれる別のYAMLコンポーネントもあります。NodePoolでは、どのようなEC2を使うか、どのアベイラビリティーゾーンを使うか、x86かGravitonかを制御しました。名前が示すように、EC2NodeClassはEC2により特化しています。EC2を起動するのに適したサブネット、EC2にアタッチする必要のあるセキュリティグループ、EC2に使用するAMIを定義します。さまざまなAMIをサポートしており、ユーザーデータの実行やEBSの定義なども可能です。

Thumbnail 1810

Thumbnail 1820

さて、AMIが変更された場合のKarpenterの動作について見てみましょう。多くのユーザーにとってデフォルトのAMIであるAMIファミリー2を使用しているとします。KarpenterはAWS Systems Manager パラメータストアを継続的にモニタリングしています。ここには、Amazon EKS最適化AMIの名前とIDがすべて公開されています。Karpenterは、実行中のKubernetesバージョン(この場合は1.26)に対応する最新のAMIを特定し、そのAMIでEC2インスタンスを作成します。

Thumbnail 1880

AWSが同じEKSバージョンに対して新しいセキュリティパッチを含む新しいAMIをリリースすると、AWSはパラメータストアを更新します。これにより、実行中のAMIと一致しない新しい最新のAMIが作成され、ドリフトが発生します。Karpenterは最新のAMIを取得し、ローリングデプロイメント方式でワーカーAMIを更新します。更新されたAMIで新しいEC2インスタンスを作成し、古いノードをドレインして、ワークロードを移行します。

Thumbnail 1900

Thumbnail 1920

Kubernetesバージョンのアップグレードについてはどうでしょうか?EKSコントロールプレーンを、例えばバージョン1.26から1.27にアップデートすると、Karpenterは賢くパラメータストアをチェックします。バージョン1.27の最新AMIを見つけ、それに応じてワーカープレーンノードをアップグレードします。ご覧のとおり、このプロセスはゼロタッチで安全です。パイプラインの実行、リサイクルコマンド、その他の手動介入は必要ありません。Karpenterは常に、最新のセキュリティパッチがすべて適用された最新のEKS最適化AMIを使用します。

Thumbnail 1940

カスタムAMIについて気になる方もいるかもしれません。KarpenterはカスタムAMIもサポートしています。EC2NodeClassのAMIセレクターフィールドを使用してAMIを選択できます。タグ、名前、アカウントID、またはAMIのIDを使って選択できます。複数のAMIが条件を満たす場合は、最新のAMIが使用されます。AMIが選択されていない場合、ノードはプロビジョニングされません。EC2NodeClassに対してkubectl getコマンドを実行すると、発見されたAMIや定義した条件を満たすAMIを確認できます。これは本番環境に導入する前のテストに適した方法です。

Thumbnail 2020

カスタムAMIのシナリオを見てみましょう。 EC2NodeClassでは、AMI ID 1、2、3を使用しています。このKarpenter NodePoolとEC2NodeClassによってプロビジョニングされるすべてのEC2インスタンスは、AMI-123を使用します。ここで、新しいAMI ID、AMI-456を追加したとします。新しくプロビジョニングされるEC2インスタンスはAMI-456を使用しますが、古いノードはドリフトしません。ドリフトは、CRDの値とマシンの間にミスマッチがある場合に発生することを覚えておいてください。AMI-123はまだ存在するので、技術的にはドリフトしていません。

Thumbnail 2050

Thumbnail 2060

すべてのEC2ノードを新しいAMI、AMI-456に移行したい場合、 単にAMI-123を削除するだけです。その時点でドリフトが発生し、Karpenterは自動的に古いノードを新しいAMIでアップグレードします。Karpenterは登録解除されたAMIも尊重します。

Thumbnail 2080

ドリフトによってデータプレーンのアップグレードが簡単でゼロタッチになる方法と、Karpenterがビンパッキングによってコスト削減を実現する方法について説明しました。 大手エンタープライズ顧客のSentinelOneは、1つのクラスターに数千のノードを持っていますが、ノードの自動アップグレードのためにKarpenter Driftを採用し、consolidationを有効にしました。これは単なるフラグ設定だけで済むことを覚えておいてください。これにより、コンテナは使用率の低いインスタンスにビンパッキングされ始め、Karpenterは新しい適切なサイズのインスタンスをプロビジョニングしました。結果として、追加の管理オーバーヘッドなしで約50%のコスト削減を実現できました。これはすべて、この自動ドリフト機能のおかげです。

Karpenterのノード中断と終了プロセス

では、これらがどのように内部で動作しているのでしょうか?詳細な説明のために、Ellisを再び舞台に招きたいと思います。

Thumbnail 2140

ありがとうございます。先ほどノードの起動について説明しましたが、これはKarpenterの責任の約半分です。残りの半分は、ノードの中断またはノードの終了と呼ばれるものです。中断とは、ノードを削除する原因となるクラスター内のあらゆるイベントのことです。これは明らかに非常に怖い行動です。 ポッドを利用可能にするためにそれらのノードが必要だったのに、ノードを取り除くことでクラスターにリスクをもたらす可能性があります。

では、それに対してどのように保護するのでしょうか?私たちはPod Disruption Budget (PDB) に頼っています。これは不可欠です。Karpenterやその他のCluster Autoscalerを使用する場合は、必ずこれを設定する必要があります。そうしないと、アプリケーションが保護されず、許容範囲を超えて早く中断される可能性があります。PDBを使用すると、一度に中断できるポッドの数を指定でき、中断の速度を制御できます。非常に重要なポッドについては、Karpenterは「do not disrupt」というアノテーションをサポートしています。これを設定すると、そのアノテーションを持つポッドのためにノードを中断しようとしません。ノード自体にこのアノテーションを付けて、Karpenterがそのノードに触れないようにすることもできます。

Karpenterには複数のタイプの中断があり、ユースケースが増えるにつれて増えていますが、それらについて推論するための標準的なアルゴリズムがあります。これらはすべてノードの中断のタイプです。ドリフトと統合、Spotインスタンスの中断、EC2ヘルスイベント、そしてノードが一定期間だけ存在するように指定できる「expiration」と呼ばれる機能について話しました。これらを自発的な中断と非自発的な中断の2つのタイプに分類しています。自発的な中断は、すぐに対応する必要はないものの、アクションを取りたいものです。選択の余地がある中断です。

Spotインスタンスの中断やEC2ヘルスイベントのような非自発的な中断の場合は、少し異なるアルゴリズムを使用します。より迅速に対応しようとします。ノードがなくなることがわかっているので、できるだけ早くそれらのポッドを移行するためにできることをすべて行います。ただし、非自発的な場合、定義したPDBが違反される可能性があります。一般的に、中断を最小限に抑えようとします。中断にはさまざまな副作用があります。新しいノードがオンラインになるたび、新しいポッドがオンラインになるたびに、キャッシュを再構築する必要があります。多くのDNSコールが発生します。そのため、一般的にクラスター内の中断を最小限に抑えようとしますが、これらの自発的および非自発的なケースに基づいて可能な場合にアクションを取ります。

Thumbnail 2260

Thumbnail 2270

Thumbnail 2280

Thumbnail 2290

標準的な中断ワークフローがあります。まず最初に、候補を特定します。これらの方法については後で詳しく説明します。 次に、代替インスタンスを起動します。中断を最小限に抑えたいので、インスタンスを終了する前に代替キャパシティが利用可能であることを確認します。これらの代替インスタンスが起動されたら、 先ほど特定した候補をドレインします。Pod Disruption Budgetを尊重しながら、Evict APIを使用してポッドを移動させます。そして最後に、 これらの候補がドレインされたら、それらを終了します。

Thumbnail 2300

どのようにしてどのノードが候補であるかを特定するのでしょうか? ドリフトには2つのタイプがあります。1つの方法はかなり簡単です:仕様のハッシュを取ることができます。テイントやラベルなどの項目については、フィールドごとに行い、指定された内容のハッシュと、そのノード上のハッシュを計算します。単純に文字列を比較して、仕様がノードの起動時から変更されたかどうかを判断し、そのノードをドリフトしたものとして指名することができます。

Rajが先ほど言及したAMIの検出のように、ハッシュできないドリフトの種類が他にもあります。これはKubernetesの設定外で定義されており、検出する必要があります。Kubernetesバージョンの検出、オムニセレクターの検出など、Karpenterには様々な検出メカニズムがあります。そのため、定期的にポーリングを行い、これらのセットを比較して検出されたドリフトを確認します。

Thumbnail 2350

また、有効期限の計算も非常に簡単です。ご覧のように、ノードには作成タイムスタンプがあります。指定した期間とこのタイムスタンプを比較するだけです。ノードが古すぎる場合、それは候補となります。中断に関しては、EC2の通知を購読しています。EC2はSpot中断イベントやEC2インスタンスのヘルスイベントについて通知を送ります。これらが発生した際に、非自発的な中断をトリガーし、候補をノミネートすることができます。

Thumbnail 2380

最後に、統合ですが、 これはかなり難しく、深く掘り下げていきます。統合は自発的なアクションです。コストを下げたいのですが、可用性とのトレードオフがあります。ポッドを中断せずに、クラスターをできるだけ圧縮するにはどうすればよいでしょうか?これもまた、NP困難な問題の一つで、かなり複雑です。Karpenterが行うすべての決定について、いくつかの例を挙げて説明してみます。コードでは、実際にすべてのコードを再利用しています。シミュレーションアルゴリズム全体が、いくつかの調整を加えて統合のために再利用されています。

Karpenterの統合アルゴリズムと今後の展望

ここでも同じ問題があります。ポッドの制約とNodePoolの制約を考慮しながら最適化を行っています。

Thumbnail 2460

この例を見てみましょう。このノードは統合されています。各ボックスがノードで、その中にポッドがあります。これは空なので、完全に簡単な統合の決定です。しかし、これが最良の統合でしょうか?あるいは、もっと良い言い方をすれば、より良い統合の決定はあるでしょうか?安価に発見できる、もっと良い方法はないでしょうか?これらはどうでしょうか?うまくパッキングされているように見えますが、クラスター全体の文脈では、左側に容量があるため、これらも統合できるかもしれません。左上はどうでしょうか? 実は、左上も統合できることがわかりました。そのポッドの1つを右に、もう1つのポッドを下のノードに移動することができます。

Thumbnail 2480

もちろん、これは大幅な単純化です。ここでは多くのポッドの制約が働いています。繰り返しになりますが、ボリュームトポロジー、ホストポート、これらすべてがそのシミュレーションアルゴリズムの一部です。この場合、右側の3つのノードを統合し、それらのポッドを左側の既存の容量に移動することができました。この決定はかなり複雑なヒューリスティックです。考慮すべき点がたくさんあります。まず、ポッドの中断を最小限に抑えたいと考えています。中断予算があるとはいえ、その最小限で運用するのは本当に好ましくありません。そのため、各アクションで中断されるポッドの数を最小限に抑えるよう努めています。

また、古いノードを優先するようにしています。非常に若いノードを終了させると、「フラッピング」と呼ばれる現象が発生する可能性があります。これは、ノードが終了し、新しいノードが起動され、すぐにそのノードが再び終了するというものです。その結果、特定のアプリケーションが他のアプリケーションよりも頻繁に中断されることになります。クラスターの状態が変化するにつれて、不運なアプリケーションが何度も中断される可能性があります。そこで、この問題に対抗するためにノードの年齢を使用しています。これは明らかに大規模な計算には非常にコストがかかります。私たちはベストを尽くしていますが、これらはすべてヒューリスティックであり、また常に進化し続けています。

Thumbnail 2550

別のケースを見てみましょう。これははるかに割り当てが少ないクラスターです。この場合、大量の容量を終了させ、すべてのポッドを既存のノードに移動することができました。これを「Nから0への統合」と呼んでいます。Nから0とは、Nノードを取り、実際に代替品を起動する必要がないことを意味します。先ほど説明した標準的なアルゴリズムに戻ると、標準的な中断アルゴリズムでは、候補は実際には代替品であり、これは単に空のリストです。つまり、代替品を起動する必要がなく、すぐに退避を開始できます。

Thumbnail 2590

Thumbnail 2610

このケースを考えてみましょう:5つのノードと5つのポッドがありますが、ノードのいずれかを終了させても、既存の容量には実際には収まりません。上部のノードを終了させると、4つのポッドをスケジュールでき、1つのポッドが残ります。Nから0への統合はここでは機能しませんが、「Nから1への統合」と呼ぶものを行うことができます。この場合、単に1対1でしたが、EC2インスタンスタイプのコストを見ることで、このインスタンスタイプを縮小すれば安くなることを特定できました。また、同じ移動の一部として、クラスター内の他の利用可能な容量を同時に再利用することもできました。

Thumbnail 2630

Thumbnail 2650

こちらは別の例です。これも「Nから1への統合」ですが、2つのノードを取り、これらのノードの両方を置き換えられることを特定しています。数千のノードと数十万のポッドについて推論する場合、この決定の複雑さは想像できるでしょう。ご覧の通り、私たちはKarpenterにとてもワクワクしています。多くの開発を行ってきました。しかし、このプロジェクトについて私が大好きなことの1つは、それがいかに急速に進化し続けているかということです。コミュニティの関与のレベル、コミュニティの貢献、GitHubで切られるissue - Karpenterがアクティブなオンラインコミュニティを持っていることが大好きです。

Thumbnail 2680

ぜひ私たちの活動に参加してください。Slackで隔週開催されるワーキンググループに参加するのもよいですし、GitHubでイシューを立てて、私たちがあなたのニーズを満たせていない点や、ドキュメントの改善案を教えていただくのもありがたいです。以上で発表を終わります。ご清聴ありがとうございました。 最後に改めてお願いですが、アンケートにぜひご協力ください。私たちの活動に大変役立ちます。


※ こちらの記事は Amazon Bedrock を様々なタスクで利用することで全て自動で作成しています。
※ どこかの機会で記事作成の試行錯誤についても記事化する予定ですが、直近技術的な部分でご興味がある場合はTwitterの方にDMください。

Discussion