EKSでKarpenterに入学してみた 〜 Fargateを卒業する理由と方法 〜

はじめに

こんにちは!コンテナ基盤グループの楠本です。

前回の記事EKSコンテナ移行のトラブル事例:FargateにおけるAZ間通信遅延の解消 - MonotaRO Tech Blogを投稿してから半年以上も経ちました。(時が流れるのは早い…

前回はSREグループコンテナ化推進チームとしてでしたが、今回は挨拶の通りコンテナ基盤グループとしての投稿です。

元々ECエンジニアリング部門のSREグループとして活動していましたが、今年初めに組織編成があり、プラットフォームエンジニアリング部門の1グループとして活動しています。

今回は組織とのミスマッチからEKS on FargateからEKS on EC2へ切り替えた話をご紹介します。

見どころは

  • EKS on Fargateとプラットフォームエンジニアリングとの相性
  • EKS on EC2へ移行する際に検討したこと
  • 移行して得られたこと

の3点です。

最初にEKS on Fargateを選択した理由

私が入社する前の選択だったため、当時の資料や関わった人から聞いた内容となります。

半年強のEKS PoCを実施し、特に”EKSの利用により、現状よりスケーラビリティは向上するか”が意識されていたようです。
「現状より」というのはVMで稼働しているアプリケーションよりも という意味になります。
EKSだけでなく、CI/CDやクラウド権限管理など幅広く検証されていたためPoC期間が長くなっていました。

VM→コンテナという移行の中でVMのスケーラビリティよりも向上しているかどうかという観点が強く、 on Fargateでも、on EC2でもどちらでも満たせる という内容の結果でした。

コンテナ化という取り組みが、1つのプロジェクトとして動いており、複数の組織の人たちが集まって実行している組織体だったため、クラスタをどう管理していくかについて考えられた結果、マシンリソースの管理を行わずに済むFargateがより優位になったようです。

EKS on Fargateを運用してわかったこと

on Fargateを運用して大きく2つの課題が出てきました。

サイドカーコンテナ

1つ目の課題はサイドカーコンテナです。
ログやオブザーバビリティなどメインコンテナとは違う役割をもったアプリケーションをサイドカーコンテナとして配置する必要があることです。手法としては一般的なものでサイドカーコンテナが悪いというわけではありません。
サイドカーコンテナがあることによって、以下の懸念があります。

  • マニフェストが肥大化する
  • 必要リソースが多くなる

少ないアプリケーション運用ではあまり問題にはならないのですが、利用するアプリケーションが増えていくにつれてこの2つが目に余るようになってきました。

  • マニフェストが肥大化する → マニフェストを配布したあとメンテしづらくなる
  • 必要リソースが多くなる → コストの増加

マニフェストについては、以下のようにサイドカーコンテナが並び肥大化していました。
(見やすいように一部のみ抜粋しています、それでも多い…)

    spec:
      containers:
        - name: main-container
          image: monotaro-application-image
          imagePullPolicy: Always
          ports:
            - containerPort: 8080
          livenessProbe:
            httpGet:
              path: /healthz/live
              port: 8080
          readinessProbe:
            httpGet:
              path: /healthz/ready
              port: 8080
          lifecycle:
            preStop:
              exec:
                command: [ "sh", "-c", "sleep 80;" ]
        - name: datadog-agent
          image: datadog-image
          ports:
            - containerPort: 4317
              name: otlp
          startupProbe:
            tcpSocket:
              host: 127.0.0.1
              port: 53
          livenessProbe:
            httpGet:
              path: /health
              port: 5555
          lifecycle:
            preStop:
              exec:
                command: [ "sh", "-c", "sleep 90;" ]
          env:
            - name: DD_ENV
              valueFrom:
                fieldRef:
                  fieldPath: metadata.labels['tags.datadoghq.com/env']
            ...他にもDD(Datadog)のENVが並ぶ
        - name: fluent-bit
          image: fluent-bit-image
          startupProbe:
            tcpSocket:
              host: 127.0.0.1
              port: 53
          livenessProbe:
            httpGet:
              path: /
              port: 2020
          readinessProbe:
            httpGet:
              path: /api/v1/health
              port: 2020
          lifecycle:
            preStop:
              exec:
                command: [ "sh", "-c", "sleep 90;" ]
        - name: local-unbound-dns
          image: unbound-dns-image
          ports:
            - containerPort: 53
              protocol: UDP
            - containerPort: 53
              protocol: TCP
          livenessProbe:
            tcpSocket:
              host: 127.0.0.1
              port: 53
          readinessProbe:
            tcpSocket:
              host: 127.0.0.1
              port: 53
          lifecycle:
            preStop:
              exec:
                command: [ "sh", "-c", "sleep 95;" ]
      dnsPolicy: "None"
      dnsConfig:
        options:
          - name: ndots
            value: "1"

AZ間通信での遅延対策のため、Unbound DNSをサイドカーとして持たせているため、各コンテナ間のProbe設定が複雑化したことも肥大化の要因でもあります。

コスト面では、VM時代よりもむしろ増えてしまい、早急に対応が必要になってしまいました。

  • HPAのCPU Utilizationのしきい値調整を行い、Podの利用効率を向上
  • KEDAを使って利用時間帯のスケール調整を行い、利用率が低い時間帯はPod数を減らすように調整

などを行って、稼働するPod数を減らすことでコスト増を抑え込みました。
とはいえサービス品質が低下しないようにPod数を抑えることには限界があります。

組織体制とのギャップ

2つ目の課題は組織体制とのギャップです。
元々、システムのモダナイズという取り組みとして、あるアプリケーションをVM→コンテナ化を実施するプロジェクトで集まった組織体制でした。

そのため、組織横断で利用する「基盤」という意識でコンテナ化してきたわけではなく、アプリケーションをどうやればコンテナ化できるのかという意識で動いてきたため、各アプリケーションに対して個別最適して動く体制でした。
基本的にはコンテナ化プロジェクトを実施して得られた知見を流用し、他のアプリケーションでも同じように横展開していくという想定でした。

そうして実際に運用をはじめてみたところ、新しいプロダクトへ導入するにつれて以下のような課題が顕在化してきました。

  • サイドカーコンテナのマニフェストのメンテナンスのしづらさ
  • アプリケーション開発者側への余計な認知負荷
  • Pod数が多くなるとサイドカーコンテナ分も余分に必要なためリソースコストの増加という問題

そこから、SREグループの1チームとしてまとまり、さらにコンテナ基盤グループというコンテナ実行環境を専門に管理する組織体が作られたことで、「このままマニフェストを増やし続けるのか」「アプリケーション開発者の負担をなくすにはどうするのか」「さらに最適化することができないか」が議論されはじめ、組織体制に合わせてマインドの変化が起こり、「基盤」という意識でこれまでの動き方を見直すことにしました。
また、今年はじめからプラットフォームエンジニアリング部門として活動をスタートし、部門の名のとおり、プラットフォームエンジニアリング観点で、利用者であるアプリケーション開発者へ認知負荷・管理負担を強いるべきではないという流れも後押しになったと思います。

EKS on EC2へ移行しモダナイズを加速させる

「基盤」としていかにアプリケーション開発者に負担を強いることなくモダナイズを後押しできるかという流れから、認知負荷の軽減、またリソース効率・コスト最適化という観点でもまずはEKS on EC2を再度検討をはじめました。
検討した項目は主に2つです。

  • ノードの管理
  • サイドカーコンテナのDaemonset化

ノードの管理

EKS on EC2でノードを管理する上で考えられる選択肢は2つです。

  • Cluster Autoscaler + Autoscaling Group
  • Karpenter

この2つの選択肢でどうするかという議論をしたわけではなく、2023年 夏の Amazon EKS 祭り!というAWS主催のイベントにてKarpenterについて事前に知見を得られていたことが大きく、Karpenterを使ってEKS on EC2化できないかというところから検討をはじめました。コンテナ化のPoCプロジェクトの段階ですでにCluster Autoscaler + Autoscaling Groupが検証されており、そういった事前の知見もあったということもありますし、MonotaROではEKSとは別にGKEの運用もあり、そちらではコンテンツ毎にNodePoolを作ってマシンタイプを指定していたので、GKEの運用を通してコミュニケーションコスト懸念やEC2インスタンスの管理懸念を持っていたことから、Cluster Autoscaler + Autoscaling Groupは検討せず、Karpenterに絞って検討を進めました。

まだKarpenterは生まれて間もないものだったため、バグを踏む覚悟もしていました。
ただ予想に反して、検証を進めていくと「なかなか良い!使えるね」という声がチーム内からも聞こえてきました。

余談ですが、Karpenterが先日(2024/08/16)にv1.0.0がリリースされましたね!
Karpenter 1.0 がローンチされました | Amazon Web Services ブログ

細かなところですが、検討当初は `v1alpha5/Provisioner` でしたが、途中のバージョンから `v1beta1/NodePool` にかわりました。
APIバージョンの変更もありましたが、そういった変更も取り込みつつ進めていきました。
検証結果としては、Podがスケジューリングされるスピードも数分前後と早く、
インスタンスタイプの指定も柔軟で、Karpenterで問題なく運用できることがわかりました。

インスタンスファミリーの設定

      requirements:
        - key: "karpenter.k8s.aws/instance-category"
          operator: In
          values: ["c", "m", "r"]
        - key: "karpenter.k8s.aws/instance-cpu"
          operator: In
          values: ["4", "8", "16", "32"]
        - key: "karpenter.k8s.aws/instance-hypervisor"
          operator: In
          values: ["nitro"]
        - key: "karpenter.k8s.aws/instance-generation"
          operator: Gt
          values: ["5"]
        - key: "topology.kubernetes.io/zone"
          operator: In
          values: ["ap-northeast-1a", "ap-northeast-1c"]
        - key: "kubernetes.io/arch"
          operator: In
          values: ["amd64"]
        - key: "karpenter.sh/capacity-type"
          operator: In
          values: ["spot", "on-demand"]
        - key: kubernetes.io/os
          operator: In
          values: ["linux"]

1点、Karpenterで注意しておく点ですが、Spot Instanceを利用する場合です。
"karpenter.sh/capacity-type" で “spot” を入れておくことでSpot Instanceを活用してくれるのですがSpot Instanceのノードにreplicasが1台しかないPodが乗っていた場合にダウンタイムが発生します。

Karpenter自体の管理のベストプラクティスが公開されていますので詳細は以下をご確認ください。
Karpenter - EKS Best Practices Guides
ベストプラクティスにも書かれているのですが、Karpenter自体のコントローラはFargateで管理が推奨されています。
そのため、私達もすべてEC2ノードで動かしているわけではなくKarpenterはFargateノードで起動するように設定しています。

サイドカーコンテナのDaemonset化

もう1つ、サイドカーコンテナたちをDaemonsetとして管理することもあわせて検討を進めました。

Daemonset化が必要となったコンテナは3つです。

  • Datadog
  • Fluentbit
  • Unbound

メリットとしてはこれらをサイドカーコンテナからDaemonsetとして基盤側で集約して管理することで
各アプリケーションからサイドカーコンテナに関わるマニフェストが排除でき、アプリケーションの管理者はメインのコンテナ以外は意識する必要がなくなります。

ただデメリットとして、各コンテナをDaemonsetに集約することで各アプリケーションPodで処理していたものがDaemonsetのPodに集中するため、そのPodが正常稼働できなかった場合に、影響範囲が大きくなります。

Podのreplicasやスペックはもちろん負荷試験などを行いながら調整していきますが、気付きづらいDaemonsetのPodを扱う上での注意点は以下です。

  • 新しいノードが作成された場合に、アプリケーションのPodよりも先にスケジュールされるようにする
  • ノードの終了時にアプリケーションのPodよりも後に終了するようにする。

これらはノードの開始・終了に伴うログやメトリクスなどの欠損を防ぐために行う対応となります。

1つ目:新しいノードが作成された場合に、アプリケーションのPodよりも先にスケジュールされるようにする

これは`priorityClassName`で対応しています。
Kubernetesでは、2つの汎用的なクラスを用意してくれていて、 `system-cluster-critical` と `system-node-critical` があります。

これをDaemonsetで配置されるPodに対して `system-node-critical` を使って、アプリケーションのPodよりも先に配置されるように指定しています。

Daemonsetのみ優先するだけであればpriorityClassNameのみで済むのですが、
私達には先述したとおり、複数のDaemonsetがあるため、Daemonset間の順番にも意識する必要がありました。

改めて、以下3つとなりますが、そのうち `Unbound` これはNode内のDNSの役割となり、Datadog, Fluentbitが先に実行されてしまった場合、DNSエラーとなってしまいます。

  • Datadog
  • Fluentbit
  • Unbound ←これが一番

加えてですが、Unboundを再考して、NodeLocalDNSへ変更できないかを検討しました。
結論を先にいいますと、NodeLocalDNS(サイドカーUnbound)という構成で一旦変更を行いました。
この部分は今後改善を行いサイドカーUnboundは無くすよう計画しています。
NodeLocalDNSについて検証を行った詳細はかなりのボリュームなのでこちらの詳細はまた機会あれば別記事として出すかもしれません…。

話を戻します。
Daemonset化するうえで、以下のDaemonsetのうちノード内のDNS問い合わせを受けるNodeLocalDNS(Unbound)が先に起動してほしいという状態になりました。

  • Datadog
  • Fluentbit
  • NodeLocalDNS(Unbound) ←これが一番

priorityClassNameに加えてさらに順番を制御できるようなオプションがあればよいのですが、そういったオプションはありません。
そこで、Init Cotainersを利用して、名前解決ができてから起動するように調整しています。
こういったDaemonset間の依存関係を無くせるように改善を検討しています。

2つ目:ノードの終了時にアプリケーションのPodよりも後に終了するようにする

ノードの終了時、ノード内にいるPodが順次Evictされていきますが、これもDaemonsetのPodはアプリケーションのPodよりも後に終了するようにしておく必要があります。
1つ目の逆で、先にDaemonsetのPodがいなくなってしまった場合、ログやメトリクス、さらにはDNSがなくなりアプリケーションのエラーに繋がるためです。

先ほどのpriorityClassNameによって `system-node-critical` がDaemonsetに設定されているため、アプリケーションPodよりも後にEvictされます。

また開始と同じく、Daemonset間の終了タイミングを調整が必要になりますが、ここはpreStopで対応を行いました。

      containers:
        - name: node-local-dns
          image: registory-image:tag
          lifecycle:
            preStop:
              exec:
                command:
                  - /bin/sh
                  - -c
                  - sleep 120

余談ですが、Kuberentes 1.29のバージョンであれば上記のようなexec.commandを使った書き方ではなく

      containers:
        - name: node-local-dns
          image: registory-image:tag
          lifecycle:
            preStop:
              sleep:
                seconds: 120

のようにsleepアクションを記述することができています(リファクタせねば!)

ノードの管理 と サイドカーコンテナのDaemonset化の検証を行い、実現が見えてきたので、実際に稼働しているクラスタに適用していく方向で進めることができました。
ここまで述べたもの以外にも、実際にDaemonsetに切り替えて運用したところトラブルが発生しました…。
それは別記事で紹介していますので合わせてご覧ください。

tech-blog.monotaro.com

EKS on EC2への切り替えとその後得られたこと

切り替え方法

切り替えについては、クラスタアップグレードと合わせて実施しました。
既存のクラスタはfargate-profileを設定しているので、新しいクラスタではfargate-profileを設定せずという具合です。
既存のクラスタでfargate-profileの設定を外すという方法もありますが、切り戻しに時間がかかります。
LBに対して2つのクラスタのTargetGroupを用意してTargetGroupBindingしておくことで、LBの流量調整で切り替えが行えますし、問題があった場合の切り戻しもスムーズです。

余談ですが、fargate-profileを複数設定しようとした場合、適用されるfargate-profileは、プロファイル名でソートされた英数字で決定されるというところに注意する必要があります。
起動時にどの Pods が AWS Fargate を使用するのかを定義する - Amazon EKS

また、アプリケーション側のマニフェストもFargateノードで動く用とEC2ノードで動く用の2パターンが必要になります。
helmでマニフェストを管理しているため、両方を用意した上でhelmの変数を使ってクラスタによって変えるようにしました。
マニフェストは一時的に二重管理になるので、なるべく短期間で切り替えを終わらせることが望まれます。

fargate-profileでkarpenterのnamespaceで絞り、加えてDaemonsetのPodはFargateノードにスケジュールされないように、以下のaffinityを設定しています。

      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: eks.amazonaws.com/compute-type
                    operator: NotIn
                    values:
                      - fargate

すべてのPodがEC2ノードで管理されたクラスタにて起動していることが確認できたら、古い方のクラスタの廃棄・Fargateノード向けのマニフェストの廃棄を行いました。差分がなければ安心して廃棄ができるのも、IaCの良いところですね。

切り替え後の変化

EC2ノードに切り替えてから、半年以上経過しましたが、安定して運用できています。
切り替え後得られたこととしては以下です。

  • サイドカーマニフェストが無くなったことによるリソース効率の向上
  • サイドカーマニフェストが無くなったことによるアプリケーション開発者の認知負荷軽減
  • Spot Instance活用によるコスト削減
  • Podのスケールアウトの速度向上
    • これによってHPAのしきい値をより柔軟に調整できるようになった
  • インスタンスタイプの変化によってアプリケーションパフォーマンスの向上
  • EC2ノードの管理負担の削減

コスト効果や認知負荷など狙った部分もありましたが、それだけでなくスケールアウトやアプリケーションパフォーマンスの向上が得られたことは大きな収穫でした。
また、それらの効果を得ながらも、基盤側のEC2ノードの管理負担もあまり発生していません。

私達はKarpenterのNodePoolのマニフェストで以下のように、世代を指定するようにしています。
AWSで新しいインスタンスタイプが導入されても適切なタイプを選択して適用してくれます。
以下は、第5世代以上のタイプを選択するようにしています。詳細は公式をご確認ください。

        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values:
            - "5"

より新しいインスタンスほど、CPU/Memoryが高性能なのでアプリケーションパフォーマンスの向上に繋がったようです。

スケールアウトは、アプリケーション次第なので参考値として捉えていただきたいのですが、Fargateでは100 - 120秒程度かかっていました。ノードの割り当て・メインコンテナの起動・サイドカーコンテナの起動など処理があり、サイドカーコンテナに引っ張られてメインコンテナも遅くなっている状況でした。
EC2ノードに変更し、ノードの空きがあれば10数秒で立ち上がってきました、またノードが不足していた場合でも100秒程度で起動する速さで、アプリケーション側の急なトラフィック増にも対応しやすくなりました。

AWSアカウントコストの移り変わりはこのような感じです。
わかりづらいグラフかと思われますが、プロダクトAのコンテナ移行対応のとき、Fargateノードで開始したことからグッとコストが上昇しました。EC2ノードへ移行を行ったことで減少を見せ始め、プロダクトBのコンテナ移行対応でコストが上昇するような流れとなっています。プロダクトBのVM稼働状況から、プロダクトAと同程度からそれ以上の上げ幅になるところ、1/3から1/4程度まで抑え込むことができました。
※具体的な金額は伏せております。
※アカウントコストのため、EKSやEC2以外のコストも含まれています。

基盤の改善を行ってきたおかげか、基盤の利用が社内的にも促進され、稼働するPod数の推移も順調に増加してきています。
今年中には1日あたり10,000に到達する想定です。(常時でも2,000のPodが稼働しています)

まとめ

今回はEKS on FargateからEKS on EC2への移行について、当時どういった背景でそれを選択したのかや、実施して得られた結果などをご紹介しました。現在はプラットフォームエンジニアリング部門の1グループとして社内のアプリケーション開発者へより会社の価値提供に早く・効率的につなげるような基盤整備に取り組んでいます。
基盤のエコシステムの整備状況や、取り組んでいく中での躓いてきた課題は他にも多くあるので、今後もブログやその他発信の場にてお伝えしていければと考えています。

当記事を通じてMonotaROに興味を持っていただいた方は、カジュアル面談もやっていますのでぜひご連絡ください。 チームの採用募集も合わせてご覧ください。