はじめまして。freee の SRE チームに所属している nkgw (Twitter) です。
普段はエンジニアリングマネージャーをしつつ、開発チームの新規プロダクトリリースサポートをやっています。
我々のチームは大部分のプロダクトのコンピューティングリソース (CPU / Memory など) を Amazon Elastic Kubernetes Service (EKS) で実行できるようにインフラ基盤移行 (EC2 → EKS) を進めてきました。
移行プロジェクトの大部分は 2021 年 7 月に無事終わったのですが、移行スケジュールを最優先としたため割り当てている各リソースはかなり保守的 & 過剰でした。
(移行後の性能劣化が怖かったため、EC2 時代と比較し、1.5 倍のバッファを積むなど... etc)
その結果、
去年と比較して、コストが倍以上に跳ね上がり、それが維持された状態*1
のまま数ヶ月が推移していました...
昨今の円安も影響しててこのままではヤバい!とのことで急遽対応を行い、なんとか EC2 でサービスを運用していたレベルまでに低減させることが出来ました。 今回はこの増大したコストをどうやって低減させていったのかを詳しくお話したいと思います。
その前に...
詳細な対策の前に、弊社ではどのように EKS を活用していて、どこの部分がコストに響いてくるのかということを簡単に整理しつつお話したいと思います。
AWS レイヤー
イメージはこんな感じです。
詳細は後述しますが、弊社は原則リクエスト数を基に Pod を増やすという戦略をとっており、Horizontal Pod Autoscaler (HPA) が Pod 数を増減させます。 ただし、HPA は名前の通り、Pod 増減が責務なので、Worker Node を動的にスケールアウト/スケールインさせるために Cluster Autoscaler を導入しています。 この Cluster Autoscaler が、Pod を起動させる Worker Node が足りないと感知すると、自動的に Worker Node を追加してくれます。また、空きリソースが多い Worker Node が居れば停止してくれます。 ちなみに EKS において Worker Node は Autoscaling Group に属する EC2 インスタンスのことを指します。
また、AWS によれば、EKS に関するコストの内訳は下記であるとのことです。
作成した Amazon EKS クラスターごとに、1 時間あたり 0.10 USD の料金が発生します。Kubernetes の名前空間と IAM セキュリティポリシーを活用すると、EKS クラスター 1 つで複数のアプリケーションを実行することができます。Amazon Elastic Compute Cloud (Amazon EC2) または AWS Fargate を使用して AWS で EKS を実行し、AWS Outposts を使用してオンプレミスで EKS を実行できます。 Amazon EC2 (Amazon EKS 管理対象ノードグループを含む) をご使用の場合、Kubernetes ワーカーノードを実行するために作成した AWS リソース [EC2 インスタンスや Amazon Elastic Block Store (EBS) ボリュームなど] の料金をお支払いいただきます。実際に使用した分に対してのみ料金が発生します。最低料金や前払いの義務はありません。詳細な料金情報については、Amazon EC2 の料金ページをご覧ください。
つまり、EKS (Kubernetes) においては、Controll Plane と Worker Plane が存在しますが、Controll Plane に掛かる費用というのは極僅かで、支配的なコストは コンピューティングリソースを多量に要求する Worker Plane となり、「Worker Node 数の適正化」及び「最適なインスタンスタイプの選定」が非常に大事であるということがわかります。
Kubernetes レイヤー
次に Kubernetes レイヤーにおけるコストポイントはどうでしょう。同じ様にイメージ図に落とし込んで見ました。
構成としては envoy が front に存在し、その後段に各アプリケーション Pod が存在する構成となっております。 そしてここでネックとなってくるのが、HPA。envoy は Datadog に指標となるメトリクス upstream_rq_total を Datadog に投げて、HPA は Datadog から external metrics として取得し、Deployment を通じ Pod のスケールアウト/スケールインを行います。 つまり Pod 数をどれだけ増やすのか?というパラメータが 「Worker Node 数」を決める要素に繋がり、結果コストに響いてくる形になります。
コンテナレイヤー
最後に Pod 内のコンテナアーキテクチャについても見てみましょう。
サイドカーに nginx コンテナを携えた、Ruby アプリケーションコンテナ*2です。
このレイヤーでネックになってくるのは、コンテナ内でフォークされたアプリケーションサーバのプロセス数です。このプロセス数で、必要となるコンピューティングリソースが決定されます。結果、「Worker Node 数」及び「インスタンスタイプ」の決定要素に繋がります。 また、現状はいくつのプロセス数で処理を行っており、バッファとなるプロセス数はいくつなのかという理解を整理しておくことは、以降のリソースの最適化の際に非常に有用です。
コスト最適化のため、freee が採用した具体的なアプローチとは...
弊社におけるコスト最適化とは 適正なインスタンスタイプの選定
と、Worker Node 数の最適化
でした。
なので、
- Worker Node の CPU / Memory 量をいくつにすると一番コストパフォーマンスが良いのか
- Worker Node 1 台あたりにはどれくらいの CPU / Memory を割り当てた コンテナ (≒ アプリケーションプロセス) をどれくらい積み込んだら、コストパフォーマンスが良いのか
の二点に焦点を当て、チューニングを行ってきましたのでご紹介したいと思います。
適切な Worker Node のインスタンスタイプ選定
EC2 → EKS の移行プロジェクトの初期にて負荷試験を行った時の結果から、通例的に EKS クラスタの Worker Node は CPU コアが他のインスタンスタイプよりも性能が良い c5 系を採用してきました。
しかし、インフラ基盤が EC2 だった時代は m5 系や r5 系などといったインスタンスタイプを採用していたにも関わらず、プロダクトのレスポンスタイムが許容内であるといった過去のデータがあったため、 本当に c5 系が適正なのかという点を見直すことにしました。現在の pod に割り当てられている CPU コア数 / Memory 総量ベースで換算し、1 node 当たりは 60 - 70 % のリソース消費効率と定義してインスタンスタイプを選定し直すと、m5.4xlarge*3 が最適タイプであるという結果になりました。
インスタンスタイプ | vCPU | Memory | hourly cost |
---|---|---|---|
c5.9xlarge | 36 vCPU | 72.0 GiB | $1.53 |
m5.4xlarge | 16 vCPU | 64.0 GiB | $0.768 |
ref. Amazon EC2 Instance Comparison
唯一の問題は適用方法です。Production で行えるような負荷試験環境はこの時点ではまだ存在せず、かといって Staging 環境で負荷試験をするにしても環境差分により有用なデータが取れないと判断したため、canary リリースにて徐々に入れ替えていくという手法をとりました。具体的には、インスタンスタイプの違う Worker Node Group を2つ用意し、インスタンス数の比率を徐々に変更していきました。 結果、m5 系にすることで Worker Node 1 台当たりの Pod 集積率が上がり、レスポンスタイムの悪化もなく入れ替えることが出来ました。
Worker Node 当たりの Pod 積載数の向上
前提条件
弊社において、Worker Node 数を適正にするということは HPA と割り当てたリソース量を調整し、1 Worker Node 当たりの Pod 積載数を上げる ということと同義です。 しかし、弊社の EKS 環境において Pod 集積率を上げるためには乗り越えなければならない前提が 2 つ存在します。
- 安定的な DNS look up の実現
- Readiness Probe のタイムアウト値調整
1. 安定的な DNS look up の実現
EC2 インスタンスには Route 53 Resolver へのパケット数をネットワークインターフェイス (ENI) 当たり 1024 packet / sec で制限されています。*4
具体的には、RDS や ALB などのリソースに貼られている Route53 CNAME の名前解決に使われる DNS パケットが該当します。 全体的に Worker Node 数を下げると、EKS クラスタ全体の ENI 数も減り、この Quota に引っかかり名前解決が不安定になってしまいます。
そのため、弊社では Nodelocal DNS Cache を導入し、各 Worker Node 内で Cache させて、Upstream への lookup 回数を可能な限り少なくさせる方針を取りました。 これにより、まず Worker Node 内に存在する DNS Cache を利用するようになり、クラスタ全体の ENI 数を減らしても、名前解決を安定させることができるようになりました。
2. Readiness Probe のタイムアウト値調整
Readiness Probe とは、health check が落ちたらアプリケーション自体は終了させずに Kubernetes Service の Endpoint から対象の Pod を除外し、ルーティングさせなくするような仕組みです。 この Readiness Probe 自体は設定していたのですが、タイムアウト値が明記してなかったため、デフォルト値である 1 秒が適用されていました。
readinessProbe: failureThreshold: 3 httpGet: path: /healthz port: 3000 periodSeconds: 10 successThreshold: 1 timeoutSeconds: 1
1 秒以上のリクエストが 3 回立て続けに来るとその Pod の Readiness Probe が失敗して、ルーティングから外れてしまいます。確認したところ、実際に 1 秒以上のリクエストは結構な頻度で飛んできていることがわかりました。 これでは、全体的な Pod 数を減らしてしまうと、Probe に応答するプロセスも減り、結果 Readiness Probe がコケてルーティングから外れるということが起こってしまいます。 インフラ基盤が EC2 時代は nginx のタイムアウトを 60 秒としていた実績を基に、 Readiness Probe タイムアウト値を 65 秒 としました。
readinessProbe: failureThreshold: 3 httpGet: path: /healthz port: 3000 periodSeconds: 10 successThreshold: 1 timeoutSeconds: 65
Pod 数の調整と割当リソースの最適化
これでようやく リソース調整や Pod 数調整に着手できるようになりました。 具体的には下記を実施しています。
- 各 Pod リソース (CPU/Memory) の Limit/Request 調整
- リクエスト数からの適正な pod 量の算出
1. 各 Pod リソース (CPU/Memory) の Limit/Request 調整
Kubernetes の機能で Pod に割り当てるリソースの制限方法は RequestsとLimits が存在します。 Requests が Pod が最低限必要とする CPU とメモリ容量を指し、Pod は指定した容量のリソースがある Worker Node でスケジューリングされます。一方、Limits は Pod に割り当てられる最大容量となり、起動後に利用できるリソースをコントロールできます。 これらのパラメータを調整し、1 Worker Node 当たりの利用状況を確認し、 60 - 70 % 程度になるように調整を行いました。
また、特に非同期処理を行う Pod は複数の Pod で 同時にバーストすることが少ない ということがトレンドで分かってきました。Pod 起動時に最大にリソースを使い、その後は、50 % 程度の usage を推移しているような状況です。 これではリソースがもったいないということで、Requests は Limits の 50 - 75 % 程度に押さえることで、1 Worker Node 当たりに非同期処理を行う Pod を詰め込みました。
2. リクエスト数からの適正な pod 量の算出
前述の通り、HPAのトリガーとなるメトリクスには envoy の upstream_rq_total でスケールする方針ですが、1 リクエスト滞留したらすぐスケールアウトするようになっていました。 Pod レベルで見たらその方針は間違っていないのですが、コンテナ内のアプリケーションプロセスレベルで見ると過剰です。 なぜなら、1 Pod 内で複数のプロセスが待ち受けており、仮に突発的に一つのプロセスの Latency が跳ねたとしても、バッファプロセスが処理を継続してくれます。
ではリクエスト数に対してどのようなスケーリングポリシーを定義していったのかを記載します。
利用する HPA パラメータは targetAverageValue を採用しました。targetAverageValue とは対象となるメトリクスに対して、確保する Pod 数を決定するパラメータになります。
もう少し具体的なケースを上げると、envoy を通るリクエストの総数が 400 [req/sec] だったとします。アプリケーションプロセスは 4 プロセス (常時稼働 2 プロセス、バッファは 2 プロセス) の場合、2 プロセスで 400 [req/sec] を捌き切ってほしいため、200 Pod 必要となり、targetAverageValue = 400 [req / sec] / 200 [pod] = 2
と算出できます。
上記の考え方を基に、各プロダクトの 1 Pod 内のプロセス数の見直し、実リクエスト数に対して、必要となる Pod 数の是正を行いました。
減らない Worker Node に対する ClusterAutoscaler の起動オプション追加
ここまでの対応で、インスタンスタイプと割当リソース、必要となる Pod 数それぞれを適正化しましたが、何故か明らかに Pod がほとんど載っていない Worker Node がいくつか退役してくれないということに気づきました。
改めて、ClusterAutoscaler の FAQ を確認すると、What types of pods can prevent CA from removing a node?
の中に下記のような記述があることに気づきました。
- PodDisruptionBudget(PDB) により制限されている Pod - kube-system の Pod - PDB が設定されていない Pod - コントローラ(e.g., ReplicaSet, Job, StatefullSet, ...)によって管理されていない Pod - LocalStorage を持っている Pod - 様々な制約(e.g., NodeAfinity, NodeSelector, ...)によって移動できる Node がない Pod - "cluster-autoscaler.kubernetes.io/safe-to-evict": "false" annotation が設定されている Pod
kube-system の Pod の内、PDB が設定されていない Pod
CoreDNS が該当します。
CoreDNS とは Kubernetes のバージョン v1.12 以降のデフォルト且つ推奨されている DNS サーバです。CoreDNS がダウンするとクラスタ全体がダウンする非常に重要なコンポーネントのため、EKS クラスタを立てると CoreDNS も含めて、AWS 側構築してくれます。 しかし PDB だけは例外で、個別で設定を追加する必要があることがわかりました。 それに気づかず、CoreDNS が載っている Worker Node だけいつまでも退役しないという事象が起こっていました。
❯ k get pdb -n kube-system NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE cluster-autoscaler-aws-cluster-autoscaler N/A 1 1 52d
対策は簡単で、PDB を個別に追加することです。
LocalStorage を持っている Pod
ここで言う LocalStorage とは hostPathや EmptyDir のことを指しています。
そのため、volumes に hostPath
や emptyPath
を使っている Pod が載っている Worker Node もいつまでも退役しません。要件的にこれらを使わないようにすることも出来ないため、オプションに skip-nodes-with-local-storage false
を追加し、LocalStorageの利用があったとしても ClusterAutoscaler が退役させる対象とする設定を追加しました。
❯ k get deploy -n kube-system cluster-autoscaler-aws-cluster-autoscaler -o json | jq -r ".spec.template.spec.containers[].command" [ "./cluster-autoscaler", "--cloud-provider=aws", "--namespace=kube-system", "--node-group-auto-discovery=asg:tag=k8s.io/cluster-autoscaler/enabled,k8s.io/cluster-autoscaler/sandbox-tmnkgw-1", "--logtostderr=false", "--max-graceful-termination-sec=86400", "--scale-down-utilization-threshold=0.7", "--scan-interval=300s", "--skip-nodes-with-local-storage=false", <<< これ "--stderrthreshold=INFO", "--v=2" ]
実際の効果は如何ほどだったのか?
今回記載した以外には production 環境での spotinstance の利用だったり、全 storage タイプを gp2 から gp3 に入れ替えるとかも検討に上がりましたが、コストパフォーマンスが悪く、アプリケーションを含めた大掛かりな修正が必要になったり、別対応で入れ替え予定だったりしたので今回は見送りました。 まだまだ取り組める余地はありますが、無事当初の目標であった 2021年 5 月水準まで落とすことが出来ました !🎉
この話は一緒に改善に取り組んだ ITストラテジー の miry さんと一緒に、5/20 (金) 19:00 から行われる freee Tech Night でもお話する予定です。よかったら参加してみてください!
さいごに
いかがでしたでしょうか。EKS 環境下におけるコスト増加の要因の分析と解決のためのアプローチについて記載してみました。 実はまだこの記事に記載した以外のことでノウハウや今後改善に向けて取り組んでいきたい点などあります。 freee Tech Night アフタートークやカジュアル面談などでぶっちゃけた話をしたいと思いますので、興味がある方はぜひお気軽にご連絡ください。
*1:倍増したコストの中には、EKS 移行だけではなく 他の機能リリースやリクエスト増加に伴う自然増加などに関連するコスト増が含まれる点を補足させていただきます。
*2:図上では、アプリケーションサーバは unicorn ですが、プロダクトに依っては passenger を採用しているプロダクトもあります。
*3:コストパフォーマンスだけに着目すると正確には m5.8xlarge が最適なのですが、1 台への集積率が高く、万が一 AWS 障害に巻き込まれた場合に影響を受ける Pod 数が倍になるという観点で m5.4xlarge を採用しました
*4:https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/vpc-dns.html#vpc-dns-limits