こんにちは。noteでSREを担当していますfukudaです。
今日は弊社のAWS EKS環境(Kubernetes)にKarpenterを導入した際の気付きやTipsをシェアしたいと思います。
環境や運用方針によって最適な設定は変わるかと思いますが、特にこれから導入予定の方に少しでもお役に立てれば幸いです。
※ betaになった0.32.0以降はNodeClass, NodePoolというリソース名ですが、導入時はProvisioner, AWSNodeTemplateだったので、0.32.0以降のバージョンを使う場合は読み替えてください。その他の部分に関しても導入時のv0.29.2の情報ですので、最新版と異なる点もあるかと思いますがご了承ください。
導入時の注意点
Karpenterの導入は公式のドキュメントに沿って実施すれば導入自体は簡単です。弊社ではhelmで管理しており、特にハマりポイントはなかったので、この記事では導入に関しては詳しく説明しません。
1点注意したほうが良いと思ったのは、SecurityGroupの指定を名前指定
でやるように書いてあるのですが、個人的にはあまりオススメしません。なぜかというと、ここでいう名前
とはSecurityGroup作成時に指定した名前ではなく、Nameタグ
を参照しているので、うっかり書き換えたり消したりすると事故に繋がります。運用方法によっては名前の方が便利なこともあるかもしれませんが、弊社の環境では特にメリットを見いだせなかったので、全てID
で指定するようにしました。
具体的にはAWSNodeTemplateのspec.securityGroupSelector
の指定をName
からaws-ids
に変更してSecurityGroupのID
を記述します。
spec:
securityGroupSelector:
aws-ids: sg-xxxxxxxxxxxxxxxxx,sg-xxxxxxxxxxxxxxxxx
あと細かい点ですが、導入したv0.29.2ではIAMのマニフェストが若干間違っていたので注意して下さい。KarpenterのPodのログを確認すればすぐ気づくことができる程度です。
(ちょっとどこだったか思い出せず、、、もしかしたら最新バージョンでは直っているかもですが未確認ですmm)
provisionerはどのような粒度で作成すればよいか?
最初に悩むのがprovisionerをどのような粒度で作成するかです。
Karpenterのprovisionerは設定の柔軟性が高いゆえに最初の迷いどころではないかと思います。
もちろん、後からの変更は出来ますし、Pod側の指定も簡単に変えることができるので、それほど深刻に考える必要はないですが、実運用をイメージして最適な粒度を検討してから作り始めるほうが手戻りが少ないと思います。
弊社の環境ではKarpenter導入前はNodeGroupを使ってノードの管理を行っており、api(バックエンド), web(フロントエンド), worker(sidekiq)といったアプリケーション単位でNodeGroupを分けていました。インスタンスファミリーやサイズなどもう少し大きな粒度で分ける方法も検討しましたが、NodeGroupと同じ粒度にする方がチーム内での認知負荷も低くく、実際運用を始めてみても丁度いい粒度だと思っており、ここに関しては良い判断ができたと思っています。
Consolidation機能は便利だが環境によっては注意が必要
ノードのリソースに余剰がある時にPodを積極的に移動して、ノード数を減らしてくれるconsolidation機能はとても便利ですが、有効化する前に考慮しておかないといけない点がいくつかあると思いました。
-
バッチ処理のようなステートフルな用途では有効化しない
-
社内向けの開発環境や管理ツールなどで1 Podしか起動していないアプリケーションは、Podの移動の際に断が発生してしまう
-
これは、通常のデプロイであればdeplpoymentのrolling update strategyを適切に設定していれば、新しいPodが先に起動し、正常に起動してから旧Podがterminateされるので問題ないと思います
strategy: rollingUpdate: maxSurge: 1 maxUnavailable: 0 type: RollingUpdate
-
ですが、consolidation機能によってPodが移動する際にはstrategyの設定は関係なくまずPodがterminateされて、0 Podになったことをトリガーとして新しいPodが起動するので断が発生してしまいます
-
-
topologySpreadConstraintsの設定があると、Podの移動が正常に行われず各ノードに余剰リソースがある状態で稼働し続けてしまう事象がありました。弊社の環境ではインスタンスサイズの固定とresource.requestsを適切に設定することでPodの配置が偏ることを回避できたので、一旦topologySpreadConstraintsの設定を削除することでこの問題を回避する判断をしました。最新バージョンで解消されている可能性もありますが、もし、topologySpreadConstraintsの設定を併用したい場合はご注意ください
Prometheus用のメトリクスは便利なので必ず設定しましょう
Karpenterは標準でPrometheusフォーマットのメトリクスを出力することができ、下記のようなメトリクスを収集することができます。
リソース量の変化の推移が意図した通りかどうかを確認するのに便利なので、導入時点から設定を有効化することをオススメします。弊社ではEKSクラスタ上の様々なメトリクスをPrometheusで収集しているので、既存のPrometheusにこのメトリクスをscrapingさせるようにしました。
Prometheusへの設定方法だけではなく、Grafanaのダッシュボードもすでに用意されており、手順書のAdd optional monitoring with Grafana
で書かれているリンク先のマニフェストファイルの内容を元に設定すれば、ほとんど手間をかけずに監視設定をすることができます。(これは本当にありがたい)
eks-node-viewerをインストールしよう
eks-node-viewerというコマンドラインツールがあります。
上記のReadmeに"It was originally developed as an internal tool at AWS for demonstrating consolidation with Karpenter. "
と書いてある通り、元々はAWSの内部ツールだったようですが、現在ではOSSのツールとして独立したレポジトリで提供されています。Karpenter管理下ではないNodeGroup管理下のノードの情報も取得できますが、特にKarpenterを使う際は動的にノードやPodの割当が変わるため、現状を把握するためのツールとしてとても便利なので、導入作業の前にインストールしておくことをオススメします。
こんな感じで表示できます。ちなみに表示されるcpu/memoryはkubectl topで見れるような実使用量ではなく、ノードのAllocatable resource
とそのノード上で起動している全Podのrequestsの総量
です。
2 nodes (15924m/31780m) 50.1% cpu ████████████████████░░░░░░░░░░░░░░░░░░░░ $1.360/hour | $992.800/month
47396Mi/58655824Ki 82.7% memory █████████████████████████████████░░░░░░░
25 pods (2 pending 23 running 23 bound)
ip-100-69-193-64.ap-northeast-1.compute.internal cpu ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 6% (14 pods) c6i.4xlarge/$0.6800 On-Demand - Ready 6d20h
memory ███████████████████████░░░░░░░░░░░░ 67%
ip-100-69-191-149.ap-northeast-1.compute.internal cpu █████████████████████████████████░░ 94% (9 pods) c6i.4xlarge/$0.6800 On-Demand - Ready 21h
memory ███████████████████████████████████ 99%
現在のノードとリソース割当の分布もひと目で分かりますし、リアルタイムに表示されるのでノードの入れ替えの挙動確認などにも便利です。
表示できる項目もオプションで柔軟に切り替えられるのですが、個人的にはデフォルトの表示はちょっと不足していると感じる上に、表示内容を切り替えることもあまりないので、shellに下記オプションをつけたものをfunctionとして登録してもっぱらこれを使っています。
-resources cpu,memory --extra-labels eks-node-viewer/node-age
バッチ等にはdo-not-evictを付け忘れないようにする
弊社では以前から特に処理時間の長いバッチに関してはFargateを活用しているので、今回の導入に際してはバッチ用のノードはKarpenter化していません。ですが、もしバッチ処理のようなステートフルで、基本的に処理終了までPodの再起動は避けたいものに関しては、do-not-evictというアノテーションをつけるのが大事だと思います。これにより、そのPodが稼働しているノードが削除対象になったとしても、Podが終了するまでその処理が保留されるので、Podが意図せず再起動してしまう状況を避けることができます。
metadata:
annotations:
karpenter.sh/do-not-evict: "true"
注意点としては、私が検証した際には再現しませんでしたが、上記アノテーションをつけたにも関わらずevictされてしまう事象がissueに報告されています。
v0.31.1で解消されているようですが、念の為よく検証する必要があると思います。
Pod側のresource設定を見直しましょう
元々、Kubernetesのマニフェストで、resourcesの設定を実使用量に合わせて設定する事はとても大事ではありますが、設定が漏れてもNodeGroupで充分なノード数が確保されていて、topologySpreadConstraints等が設定されていれば、どこかのノードに偏ってリソースを食い潰してしまうような実害は発生せず、あまり気にしていないまたは気づいていない場合もあるかもしれません。(弊社でも一部でありました)
しかし、Karpenterでは各Podのresourcesの設定量によって起動するノードを動的に変動させるため、設定が漏れていたり実使用量との乖離が大きいと、必要なリソース量が確保できずに問題が起きる可能性が高くなります。
特に、helmで入れてるものはデフォ値がそのまま設定されていて、実使用量との乖離が大きかったり、設定自体がなかったりすることも散見されるので、特に既存のクラスタのノードをKarpenter化するのであれば、まず最初にresource設定の総点検
をやることをオススメします。「まず最初に」というのは結果的にわかったことで、弊社ではKarpenterの導入過程で設定漏れや実使用量との大きな乖離を見つけて、その都度修正していたので、Karpenterの導入作業が中断して作業効率が良くありませんでした。
加えて、resources.requestsをキリの良い設定にするのも大事です。例えば、1つのノードで4つのPodを動かすことを想定している時に4つ目のPodを起動させるためのリソースが、ほんのちょっとだけ足りない場合にKarpenterは問答無用で1台ノードを追加してしまいます。この辺の挙動確認にも前述のeks-node-viewer
がとても便利です。
ノードの更新手順も確認しておきましょう
NodeGroupではeksctlコマンドやAWSのマネージメントコンソールでアップデートをする手段が用意されていましたが、Karpenterには同様の機能がありません。そもそも、起動時にEKSのコントロールプレーンのバージョンに合わせた最新AMIが自動で採用されるので、同じProvisioner配下のノードでも、起動のタイミングによってマイナーバージョンの異なるノードが混在する状態になるのがNodeGroupとの大きな違いの1つです。このため、クラスタのバージョンアップ時などに、明示的に全てのノードを差し替えたい時は、別の手段が必要となります。
具体的に弊社では下記手順で実施しています。
-
Karpenter管理下のノードの一覧を取得
kubectl get node -l karpenter.sh/provisioner-name -o yaml | yq '.items[].metadata.name'`
-
上記ノードを全てcordon
-
Karpenterを使うように設定しているDeploymentのネームスペースと名前の一覧を取得
kubectl get deployments.apps -Ao yaml \ | yq '.items[] | select(.spec.template.spec.nodeSelector.["karpenter.sh/provisioner-name"] != null) | .metadata | [.namespace,.name] | @csv' \ | sort \ | uniq
-
各deploymentを
kubectl rollout restart
コマンドでローリングアップデート -
StatefulSetも同様に実施
こうして既存のノードをcordonした上で、Karpenterを使うように設定している全てのリソースをローリングアップデートすることで、新規のノードが立ち上がり、新たなPodがその新ノード上で立ち上がってサービスインした後、古いノードが自動的に終了され、結果的にノードの差し替えが完了する形になります。
provisionerの設定はできるだけ固定値を指定する
当初はインスタンスファミリーの指定と、large以下のインスタンスサイズを起動させないだけの設定にしていました。具体的には下記のような感じです。
spec:
requirements:
:
- key: karpenter.k8s.aws/instance-family
operator: In
values: ["c6i"]
- key: karpenter.k8s.aws/instance-size
operator: NotIn
values: [nano, micro, small, medium, large]
:
しかし、この設定を本番に投入した所、本番ではNode数/Pod数ともに多いため、32xLargeのような巨大なインスタンスに多数のPodが収容されてしまう事象が発生したため、結局インスタンスサイズは決め打ちにするように方針を変更しました。
- key: karpenter.k8s.aws/instance-size
operator: In
values: [4xlarge]
これは巨大なインスタンスに大量のPodが詰め込まれてしまうと、そのノードに障害が起きた時に多くのPodが疎通できなくなるので可用性が下がってしまいます。また、NodeGroupではインスタンスファミリーとサイズは固定なので、どんなサイズのノードにいくつのPodが稼働しているのかが変動してしまうと、運用上扱いづらいように思ったので固定としました。
用途によってはインスタンスサイズが変動できた方が良いこともあると思いますし、spotをフル活用するのであれば、インスタンスファミリーの選択肢にも幅を持たせないといけないと思います。
ちなみに、どれくらいのインスタンスサイズにいくつのPodを収容すると効率が良いかは、EC2からEKSに移設した際に負荷試験を実施した上で決定しており、provisionerの設定もそれを踏襲したものとなっています。
デプロイ時のRolling update strategyを見直そう
NodeGroupで管理していた時は、デプロイ時にノードの増減や入れ替わりは発生しない前提だったので、下記のように少しずつ入れ替わるように設定していました。(PDBは設定していません)
strategy:
rollingUpdate:
maxSurge: 2
maxUnavailable: 2
type: RollingUpdate
しかし、これだと起動に時間がかかるPodの場合、デプロイが完走するまでに非常に時間がかかります。
苦肉の策として、ローリングアップデート前にAutoScalingGroupで一時的に台数を増やして、完了後に戻すというやり方で回避していましたが、新しく起動したPodが起動しているノードのみを残してスケールダウンさせなければいけないので、残したいノードにscale-in protectionを有効化した上でスケールダウンするなど地味に手間がかかっていました。
ですが、Karpenterの特性を活かせば、運用がとてもシンプルかつ効率的になります。
具体的には下記のとおりです。
-
Deploymentのstrategy.rollingUpdate下記のように設定
strategy: rollingUpdate: maxSurge: 100% maxUnavailable: 0 type: RollingUpdate
-
新しいDeploymentのマニフェストをapply
-
新しいPodがPending状態で起動する
-
新しいノードが起動してそこで新しいPodが起動する
-
新しいPodが正常にサービスインしたら古いPodがterminateされる
-
空になったノードはKarpenterがよしなに終了してくれる
この際、注意しなければいけないのが、Provisionerのリミット値
です。
当然ながら一時的に倍のリソース量が必要になるので、ある程度余裕をもった値を設定しないとデプロイ処理に支障が出てしまいます。例えば、何かしらの新機能に起因して高負荷となっている状況で、切り戻しのリリースをするようなシチュエーションを考慮するとある程度大きめのリミット値にする必要があるかと思います。
なお、Karpenterではノード起動時に当該バージョンの最新のAMIを使うため、このようにリリースの際にノードが全て入れ替わるようにすると、AMIのマイナーバージョンアップも出来て一石二鳥です。
ステートレスなアプリケーション用途のprovisionerにはTTLを設定しよう
Karpenterはノードを起動する際に最新のAMIを取得するので、TTLを設定することでNodeGroupを使っている時にありがちな、いつまでも古いAMIを使い続けてしまう状況を解消できます。特にNodeGroupでの管理の場合、NodeGroupの数が増えてくると地味に運用工数が増えてきて辛くなってきます。
そこで便利なのがprovisionerのttlSecondsUntilExpired
という設定項目で、これを設定するとノードの賞味期限を設定できます。(デフォルトでは無期限)
どれくらいの長さにするかは決めの問題なので特に指標とかは無いのですが、弊社では一旦1週間で設定しています。
spec:
ttlSecondsUntilExpired: 604800
特に7日に意味は無いのですが、前述の通り、基本的にはリリース時にノードが入れ替わるようにしているので、実運用では7日経過することはあまりないものの、一応最大でも1週間経ったら入れ替えておこうかな?ぐらいの判断です。
例外としてttlSecondsUntilExpired
を設定していないprovisionerもあります。具体例をあげると、プラグインの都合でECKを用いた自前運用をしているElasticsearch用のprovisionerです。Elasticsearchを自前で運用されたことがある方はわかると思いますが、オンラインでのノードの入れ替えは、安全に作業するために非常に時間がかかります。そのため、無駄にノードの入れ替えを発生させたくないためttlSecondsUntilExpired
は設定していません。
ちなみに、ノードの縮退はデフォルトでは1台ずつ実施されるので、賞味期限切れのノードが複数台発生しても一気に台数が減ることはありません。
enablePodENIの設定に気をつけて
PodにSecurityGroupを紐付ける場合や、resource requestsとしてENIを指定する必要がある場合などに、Amazon VPC CNI アドオンに下記設定を入れている環境も多いと思います。
Amazon VPC CNI アドオンを有効にして Pods のネットワークインターフェイスを管理するには、aws-node DaemonSet で ENABLE_POD_ENI 変数を true に設定します。
この設定をtrueにしている場合は、Karpenterでも同様の設定が必要になるので忘れずに設定しましょう。
この設定はデフォルトでfalseなので、忘れると後々ハマります(ハマりました
# -- If true then instances that support pod ENI will report a vpc.amazonaws.com/pod-eni resource
enablePodENI: true
requestsとlimitsの設定は違っていてもいいと思う
Karpenterのインスタンス選択の際の計算にはrequests
のみが使用されますが、以前AWSのセミナーで「べスプラとしてはrequestsとlimitsを同じに設定する」と推奨されていましたが、個人的にはケースバイケースで異なる値でも良いのではと思います。特にリソース使用量がの増減幅が大きく、Max値に合わせてrequestsを設定してしまうとリソースの使用効率が悪くなってしまう場合は、下記のような設定でも良いのではと思います。
- requests: 平常時の使用量 +α
- limits: 最大使用量前後
CPUの瞬間的な高負荷は多少許容できる場合もあるかと思いますが、Kubernetesのノードはswap領域が無効になっているので、メモリはあまり攻めすぎないほうが良いとは思います。また、処理の内容によっては極力安全なリソース量を確保したいというケースもあるかと思います。
ですので、総合的に見て柔軟に設定して良いと個人的には思います。
不定期に大きなリソース量が必要な処理にも便利
弊社では昨年リリースした「インポート・エクスポート機能」の実装にArgo Workflowsを使用しています。このような大きめのリソースが不定期かつ一時的に必要になるようなワークロードに対してもKarpenterは非常に相性が良いと思います。
ユーザーからのリクエストに応じて、Argo Workflowsが設定されているワークフローに基づいて必要なPodを動的に起動させますが、この機能をリリースした時点ではまだKarpenterは導入していなかったので、当初はNodeGroupで管理しているノード上で実行していました。そのため、リクエストされるタイミングが重なると待ち時間が長くなってしまう問題があり、一方でリクエストが無い時間帯も多く、リソース利用効率の悪さも問題でした。
しかし、Karpenterの導入で、不定期なPod数の増減にKarpenterは完全に追従して動的に必要なノードを確保し、処理が終わって他のワークフローがない場合は0台まで削減してくれるので、低工数で完全なゼロスケールが実現できました。
HPAを用いた負荷ベースのオートスケールがとてもシンプルになる
Kubernetesで負荷ベースでPodを増減させることはHorizontalPodAutoscalerで簡単にできますが、それに合わせてNodeGroupのノードを増減させることは手間でした。Karpenterを導入すると、Podの増減に追従してノードを自動で増減させるので、「ノードの台数」を管理する必要がなくなり、負荷ベースのオートスケールがとてもシンプルになります。
今後はKEDAを導入して、CPUやメモリ以外のメトリクスをトリガーにしたスケールなども対応できるようにし、運用の自動化をさらに進めていきたいと考えています。
最後に
長文お読み頂きありがとうございましたmm
Karpenterを導入しながら書いたメモを再編成したらこんなに長くなってしまいました。。。
Karpenterに限らず、導入前に色々と調べて策を練っても、いざ本番環境に導入してみると色々と課題に気づくことが多々あるかと思います。今回はそういった「実際にやってみて分かったコト」がこれから導入しようと思っている方のお役に立つかなと思って記事にしました。
一旦、リソースの割当てが定まれば、あとはPod数の増減だけを気にするだけでノードの管理から開放されるのは非常に素晴らしいです。Karpenterの開発に携わっているエンジニアの方々に感謝いたします。
ちなみに余談ですが、弊社のSRE-TではterraformでIaCを行っているのですが、Karpenterの公式ドキュメントにCloudFormationのソースコードがあり、それを社内で活用しているchatGPTにterraformへの変換をさせた所、多少おまじないは必要なものの、多少の手直しで使えそうなものが出てきて楽でした。こういった分かりやすい用途かつただただめんどくさい作業には便利ですね。
(もちろん、planの結果の精査やチーム内でのコーディングスタイルに合わせるために多少の修正は必要です)
この記事はnote株式会社 Advent Calendar 2023の20日目の記事として書きましたが、他にも様々な分野に関する記事を書いていますので、ご興味があるものがあればお読み頂ければ幸いです。
noteエンジニアアドベントカレンダー
https://qiita.com/advent-calendar/2023/note
12日の記事には、チームメンバーのvaru3が先日CNDT2023に登壇した際の記事があります。
https://zenn.dev/varu3/articles/8e502fc41240b7
また、弊社のEKS移行関連の過去の記事もこちらにあります。
https://engineerteam.note.jp/n/nd4ef85f85cc0
https://note.jp/n/nc0e69bb544f2
さらにnoteの技術記事が読みたい方はこちらへ
https://engineerteam.note.jp/