💰

EKSのコンテナリソース最適化メモ

2024/12/12に公開

なにこれ

直近の業務にて、EKSで展開しているコンテナのリソース(CPU・メモリ)の最適化をおこなう機会がありました
技術的な裏付けがあまりできておらずほとんどが経験(と思い込み)に基づいた内容ではあるのですが、
今後見返して適宜認識を改められるように備忘メモとして遺したいと思います🐣

前提

  • Amazon EKSをセルフマネージド型ノードで運用している
    • ワーカーノードはオートスケーリンググループで管理
  • スポット・オンデマンドインスタンス、Fargateを併用している
    • ステートレスなコンテナ(Webアプリケーション)はスポットインスタンス
    • ステートフルなコンテナ(ジョブ管理サーバーなど)はオンデマンドインスタンス
    • バッチ処理はFargate
  • 監視ツールにはDatadogを利用
  • 運用し始めてから5年経つが、過去クラスタ全体でのリソース最適化をしたことが無い
    • メモリはrequestsとlimitsを同値で運用している
    • CPUはrequests < limitsの関係のものが多く、ところどころ未指定
    • OOMが生じたからなどの理由による局所的なスペックアップはやってきている

基本的な考え方

Kubernetesにおけるリソース調整の軸はおおよそ以下で、それぞれの増減によってトレードオフが生じる

  • コンテナ軸の調整
    • replicasの台数
      • 減らすと負荷⬆️、応答性⬇️、コスト⬇️
      • 増やすと負荷⬇️、応答性⬆️、コスト⬆️
    • CPUのrequestsの調整
      • 減らすと集積性⬆️、ノード起因スロットルリスク⬆️、コスト⬇️
      • 増やすと集積性⬇️、ノード起因スロットルリスク⬇️、コスト⬆️
    • CPUのlimitsの調整
      • 減らすとコンテナ起因スロットルリスク⬆️、コスト⬇️
      • 増やすとコンテナ起因スロットルリスク⬇️、コスト⬆️
    • メモリのrequests&limitsの調整
      • 減らすと集積性⬆️、OOMリスク⬆️、コスト⬇️
      • 増やすと集積性⬇️、OOMリスク⬇️、コスト⬆️
  • ノード軸の調整
    • インスタンスの台数
      • 減らすと負荷⬆️、コスト⬇️
      • 減らすと負荷⬇️、コスト⬆️
    • インスタンススペック(vCPU・メモリ)
      • 増やすと集積性⬆️、停止時リスク⬆️、コスト⬇️
      • 減らすと集積性⬇️、停止時リスク⬇️、コスト⬆️

上記の関係があったときに、リソース最適化とは

  • 元々のシステム品質を損なわない範囲で、
    • CPUスロットルが生じるとコンテナのLivenessProbe / ReadinessProbeの失敗率が上昇し、restartが生じる
      • 特にCPUは起動中と起動後でトレンドが異なる場合が多いため、起動後の状態に対して最適化しすぎると起動時の動作に支障するリスクが高まる
    • OOMが生じるとプロセスが強制終了させられ、restartが生じる
    • 集積性を高めすぎると、スポットインスタンスの中断やEC2障害発生時の安定性が損なわれる
      • 特に起動時はCPUやディスクなどの利用が一時的に大きくなりやすいので、limitsがついていないコンテナが特定ノードに集中するとノイジーネイバーになりやすい
  • コンテナのリソースを調整し
    • requestsを減らして、ノード当たりのコンテナの集積性をあげる
    • limitsを設定、ないし減らして、オーバーコミットによる他コンテナへの影響を最小化する
  • ノードのリソースを調整する
    • インスタンスタイプをコンテナの メモリ量 / vCPU数 に近い比率のものに変える
    • オートスケーリンググループによる自動調整や手動変更により、インスタンス台数を最小化する

流れ

世の事例を見ると、特に大規模環境においてはスペック適用が自動化されているケースもあったが、
今回は長期間最適化ができていなかったクラスタに対する初回の対応だったため、手動作業を基本に対応をおこなった

以下の順序でやっていく

  • 監視
  • コンテナレベルの最適化
  • 監視
  • ノードレベルの最適化
  • 監視

監視

以下について、ダッシュボード等を使って観測可能にする
環境への変更適用前後などで適宜状態を観測できるとよい

メトリクスはDatadogの場合なので、他の監視基盤ならそれに倣う

  • コンテナ
    • 死活
      • restartsの数 kubernetes.containers.restarts
      • evictionの数 kubernetes.kubelet.evictions
    • メモリ量 / vCPU数
      • 割当量ベース kubernetes.memory.requests / kubernetes.cpu.requests
      • 使用量ベース kubernetes.memory.usage / kubernetes.cpu.usage.total
    • CPU
      • requests / limitsの値
      • 使用量 kubernetes.cpu.usage.total
      • CPUスロットルの発生状況
        • 不足CPU時間量 kubernetes.cpu.cfs.throttled.seconds
        • 発生割合 (kubernetes.cpu.cfs.throttled.periods / kubernetes.cpu.cfs.periods) * 100
    • メモリ
      • requests / limitsの値
      • 使用量 kubernetes.memory.usage / 使用率 kubernetes.memory.usage_pct
      • OOMの発生状況
        • kubernetes.containers.last_state.terminatedreason:oomkilled に絞って
        • Datadog eventsから -status: info 以外でイベントを見ておくとよい
    • ディスク
      • ephemeral-storageのlimitsの値
      • ディスク使用量 kubernetes.ephemeral_storage.usage
      • ディスク読み込みバイト数 kubernetes.io.read_bytes
      • ディスク書き込みバイト数 kubernetes.io.write_bytes
    • ネットワークI/O
      • 送信
        • バイト数 kubernetes.network.tx_dropped
        • パケットドロップ数 kubernetes.network.tx_dropped
        • エラー数 kubernetes.network.tx_errors
      • 受信
        • バイト数 kubernetes.network.rx_dropped
        • パケットドロップ数 kubernetes.network.rx_dropped
        • エラー数 kubernetes.network.rx_errors
  • ワーカーノード
    • コンテナで使ったメトリクスを適宜 instance-type host などの単位で集約する
    • 死活
      • 台数とステータス kubernetes_state.node.status
    • CPU
      • EC2の使用率 100 - system.cpu.idle
    • メモリ
      • EC2の使用率 1 - system.mem.pct_usable
  • コスト
    • Cost Explorerで EC2 インスタンス (Elastic Compute Cloud - Compute)使用タイプ 別などで見る
      • 特にオートスケーリンググループ使ってるならタグでフィルタできるはず
    • CostUsageReportでEKS単位のコストが見れる ので、個別に深堀したい場合はそちらも活用できる
      • コストがかかっているのがどのnamespaceのリソースか
      • ジョブとアプリケーションでどのようなコスト比率になっているか
  • アプリケーション(普段やってる監視も併せて見るくらいの意味)
    • リクエスト量
    • エラー率
    • レイテンシ

https://aws.amazon.com/jp/about-aws/whats-new/2024/04/aws-split-cost-allocation-data-amazon-eks/

コンテナレベルの最適化

まず、各コンテナに対して新たにどのようなrequests / limitsを指定するか決める必要がある
CPU・メモリの利用状況を一定期間分抽出し、その中のmaxやp99の値に対して一定の余裕率を設けた値を指定するのがよい

例えば、名前空間配下のコンテナに対して、 VPAOffモードで自動作成してリソースの推奨事項を提示してくれる Goldilocks というOSSがある
推奨値が安定するまで適用から1週間程度待つ必要があるので、使う際は留意する
値は非公式のAPIや、 kubectl get vpa -A -ojson のような形で取得できる

https://github.com/FairwindsOps/goldilocks

https://kubernetes.io/ja/docs/concepts/workloads/autoscaling/#scaling-workloads-vertically

あるいは、Datadogなどの監視ツールは各種言語でAPI Clientを提供している場合もあるので、これらを使って一括でデータ取得するとよい

https://github.com/DataDog/datadog-api-client-python

クエリの一例を示す
これを一定期間(例えば現在から過去2週間)を対象に対して発行した後、コンテナ毎の推移からmaxや99パーセンタイルなどの値を選択し、余裕率を付与した値を推奨値としてコンテナに設定する
どのくらいの期間を対象とすべきかは、ピークトラフィックが発生するシステムかどうかなど、扱っているシステムの特性に合わせて考える必要がある

# 10分毎のCPU平均値を取得
avg:kubernetes.cpu.usage.total{cluster_name:xxxxx} by {kube_namespace,kube_deployment,kube_stateful_set,kube_daemon_set,kube_container_name,kube_job}.rollup(avg, 600)

# 10分毎のメモリ最大値を取得
max:kubernetes.memory.usage{cluster_name:xxxxx} by {kube_namespace,kube_deployment,kube_stateful_set,kube_daemon_set,kube_container_name,kube_job}.rollup(max, 600)

ただし、上述した値はあくまで計測された瞬間の値のため、ビジネス上重要なコンポーネントについては個別に調査・検討して適切なリソーススペックを設定したほうがよい
Goldilocks(VPA)とDatadogから取得した推奨値が大きく乖離するケースもあった

また、OSSのソフトウェアを構成している場合はそれらの方でシステム要件が定められている場合もあるので、一概に利用量から算出できない場合も多い
例えばRundeck であれば 2CPU & 8GB RAM が最小要件のため、これを下回った値を設定しないように運用する必要がある

https://docs.rundeck.com/docs/administration/install/system-requirements.html

前述した 特定のリソースに対して一定のスペックを設定する必要がある という情報をリソースに残すための方法のひとつとして、conftestが活用できる

https://zenn.dev/yktakaha4/articles/policy_check_with_conftest

Rundeckを所定のスペックで配備していることを、 annotations に記録しているとき、

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: rundeck
  namespace: rundeck
  labels:
    app: rundeck
  annotations:
    # Server Profile(Minimum) にて以下を要求している
    # - 2 CPUs per instance
    # - 8GB RAM (4GB JVM Heap)
    # https://docs.rundeck.com/docs/administration/install/system-requirements.html
    "repo-name/explicit-cpu-request-rundeck": "2000m"
    "repo-name/explicit-cpu-limit-rundeck": "2000m"
    "repo-name/explicit-memory-request-rundeck": "8000M"
    "repo-name/explicit-memory-limit-rundeck": "8000M"
(後略)

以下のポリシーによって、コンテナのrequests / limitsが明示されたスペックと異なっている場合エラーにさせることができる
一例としてrequests.cpuに対するRegoポリシーを記載する

package pod

target_resource_types := {"DaemonSet", "Deployment", "StatefulSet", "Job", "CronJob", "ReplicaSet", "ReplicationController"}

deny_explicit_resource_spec_not_set_cpu_request[msg] {
	target_resource_types[input.kind]
	container := input.spec.template.spec.containers[_]

	explicit_resource_spec := get_explicit_resource_spec("cpu-request", container.name, input)

	explicit_resource_spec
	not container.resources.requests.cpu

	msg := sprintf("%s: コンテナ %s のCPU要求が明示されたスペック %s に対して未指定です", [input.metadata.name, container.name, explicit_resource_spec])
}

deny_explicit_resource_spec_not_match_cpu_request[msg] {
	target_resource_types[input.kind]
	container := input.spec.template.spec.containers[_]

	container_resouce_spec := container.resources.requests.cpu
	explicit_resource_spec := get_explicit_resource_spec("cpu-request", container.name, input)

	container_resouce_spec != explicit_resource_spec

	msg := sprintf("%s: コンテナ %s のCPU要求 %s が明示されたスペック %s と一致しません", [input.metadata.name, container.name, container_resouce_spec, explicit_resource_spec])
}

get_explicit_resource_spec(resource_kind, container_name, resource) = explicit_resource_spec {
	annotations := resource.metadata.annotations
	resource_spec_key := sprintf("repo-name/explicit-%s-%s", [resource_kind, container_name])
	explicit_resource_spec := annotations[resource_spec_key]
}

推奨スペックの算出については、環境に配備しているコンポーネントの数にもよるが、スクリプト等を用いて半自動化するのが望ましい

ノードレベルの最適化

コンテナのリソース最適化を環境に適用した直後に注意すべき観点は以下になる

  • クラスタを壊してないか軸
    • 監視 の節で説明した観点で、再起動が頻発するPodが生じていないかチェックする。比較的起きそうなことは以下
      • スペック下げすぎorコンテナの集積性が上がったことでコンテナ当たりに使えるCPUが不足 > CPUスロットル発生orノードのCPU使用率張り付き > livenessProbeが死ぬ > 再起動が増える
      • スペック下げすぎでメモリ不足 > OOMが発生 > 再起動が増える
        • 特にkube-systemのような調整してないコンテナも、一部メモリのlimitsが付いておらず適用されている場合があるため、落ちてるPod毎に思い当たる問題が無いか見ていった方がいい
      • コンテナの集積性が上がったことでコンテナ当たりに使えるエフェメラルストレージが減り、ディスク枯渇 > evict発生 > 再起動が増える
  • クラスタを最適化できているか軸
    • 適用前後で以下が変化する
      • クラスタ全体で合計したCPU RequestsとMemory Requestsが下がる
        • 下がらないならそもそも適用値をミスっているかも
      • インスタンスタイプ毎の{CPU、メモリ}割当率があがる
        • これが上がるほどノードの利用状況が改善している(≒集積性があがっている)ことになる
        • CPU / メモリの片方が上がり、もう片方が変わらないor下がるようなこともあり得る
          • その場合は、メモリ / CPUコア数比率が利用しているEC2インスタンスタイプとコンテナで乖離しているということになる
      • メモリ / CPUコア数比率が変わる
        • この比率に対して妥当なインスタンスタイプを選んでいくことになる

Cluster Autoscalerなどによるオートスケーリングをおこなっている場合、コンテナレベルの最適化だけでもワーカーノードが自動的に削減されてコストが下がる可能性があるが、
前述したメモリ / CPUコア数比率の変化に伴い、環境に対して適切なインスタンスタイプが変わっている可能性があるため、引き続きワーカーノードの最適化をおこなう

以下の図は、あるワーカーノードに対して配備されたコンテナの メモリ使用量 / CPU使用量 の合計を示している
対応前は 4GB / 1vCPU だったのが最適化後に 8GB / 1vCPU になっているため、インスタンスは 32GB / 4vCPU のようなスペックのものを選定すると、集積性が高まりコストを下げやすいことが予想される


スペック変更前後で観測された比率のイメージ

ただし、単にこの基準だけでインスタンスを選定すると、CPUをオーバーコミットしている場合はスロットリング発生のリスクが高まる
標準的なワークロードに適しているとされるm系インスタンスが 4GB / 1vCPU の比率になっていることも加味して、過度な比率のインスタンスタイプを選ばないようにした方がよいものと思われる

その他、インスタンスタイプの選定にあたっては必要な前提知識が多いため、事前に特性を把握しておいた方が無難かもしれない

https://www.youtube.com/watch?v=6zLr3LF9GYA

インスタンスタイプの選定が出来たら、検証環境に実際に適用してアプリケーションを動かしてみる
以下の観点を確認するとよい

  • デプロイに伴う起動時の初期化処理や特定のアプリケーション操作など、平常時よりリソースを多く消費した際にPodのRestartが生じないか
    • 特にlimitsがついていないケースにおいては、高負荷になっているPodだけでなく、同一ワーカーノードに乗っている別コンポーネントの動作に支障する可能性があるので注視する
    • Datadog Agentなど監視に関わるコンポーネントに支障する場合もあるので、 kubectl get pod -A -owide などで直接クラスタ状態を確認したほうがいいかも

また、この時 32GB / 8core * 2台 よりも 64GB / 16core * 1台 のような構成にした方がコストは安くなるように感じられるが、スポットインスタンスでアプリケーションを運用している場合、1台で運用するとスポットインスタンスの中断やAZ障害などに伴うサービスダウンのリスクが高まる
これについては、コンテナ最適化後のリソース使用量やエラー状況を見ながら改善していく必要がある

加えて、リソース最適化に先行する対応としてコンポーネントが分散配置されるように適宜設定をおこなうことが望ましい

https://creators.oisixradaichi.co.jp/entry/2023/01/12/150101

https://cstoku.dev/posts/2018/k8sdojo-18/

インスタンス選定にあたって確認すべき内容を示す

AWSのオートスケーリンググループの画面をReadOnly権限で開き、 インスタンスタイプの要件インスタンス属性を指定する にて調べるとわかりやすい


インスタンスタイプの要件画面

構成要素ごとの注意点を示す

  • vCPU
    • コア数が並列で動作できるコンテナの最大数になるため、4コア以上はあった方がいいように思われる
      • CPUをrequests < limitsの大小関係にして設定している場合、コア数の絶対量が少ないとCPUが枯渇しやすい
  • メモリ
    • インスタンスあたりのメモリを増やしすぎて集積性をあげすぎるとスポットインスタンスの中断が発生した際の停止リスクが高くなる
      • 新しいスポットインスタンスが立ち上がるのに数分はかかるため
    • 一方、メモリを減らしすぎると、DaemonSetなどのノードごとに立ち上がるリソースが全体に占める割合が増加し、コスト効率が悪くなる
      • 監視ツールにてインスタンスタイプ毎のCPU割当量やインスタンスタイプ毎のメモリ割当量を確認して、
    • セルフマネージド型ノードグループをcluster-autoscalerを使ってスケールアウト・インして運用している場合、オートスケーリンググループの インスタンスの重み は設定できない(同一値を指定する必要がある)ため、メモリサイズはインスタンスタイプ間で同一のものにしておくのが望ましい
      • インスタンスタイプ間で異なる値を付与すると、cluster-autoscalerが認識している台数と実際の起動台数が乖離して事故を起こす
      • CPUもずれが無いのが本来望ましいが、そうすると後述するスポットプール数20以上の要件が満たせなくなるため、requests / limitsを明示しているケースが多くクラスタの必要総量が見積もりやすいメモリ側を固定したほうがベターのように考えている(が正解はよくわからない)
        • オートスケーリンググループを料金キャパシティ最適化戦略にて動作させている場合は、同一メモリでCPUサイズの大小があった場合は基本的にコア数の少ない安いものから使ってくれるはず
  • インスタンスタイプ数(≒スポットプール数)
    • こちら の事例で語られているが、20未満だと停止リスクが高いと一般に言われているため、 7 インスタンスタイプ * 3 az = 21 スポットプール 以上を目指したい
      • ステートレスなサーバー群をすべてスポットインスタンスで構成するために、DeNA様では最終的に使用するスポットプールを20プールまで引き上げました。これは例えばある時点で、北バージニアリージョンの5つのアベイラビリティゾーンに対して、c5.2xlarge, c5.4xlarge, c5d.4xlarge, c5.9xlargeの4種類のインスタンスタイプを定義している状態を指し、このときのプール数は 5 x 4 = 20プールと計算されます。

https://aws.amazon.com/jp/blogs/news/how-dena-succesfully-applied-ec2-spot-on-production-and-reference-architecture-using-containers/

インスタンスタイプの見立てをしたら、料金表から1台当たりのコストを確認する
前提として、スポットインスタンスは価格がスペックに比例しない & 短期間で更新されるのであくまで参考情報になる


料金表

現状立ち上がっているインスタンスのスペック&台数と、今後立ち上がる予定のインスタンススペック&台数の比較で、対応後の想定額を算出できる

参考

https://www.datadoghq.com/ja/blog/kubernetes-cpu-requests-limits/

https://docs.aws.amazon.com/ja_jp/eks/latest/best-practices/cost-opt-compute.html

https://developers.freee.co.jp/entry/Approach-to-increasing-cost-of-computing-resources-in-EKS-environment

https://speakerdeck.com/sanposhiho/merukariniokerupuratutohuomuzhu-dao-nokubernetesrisosuzui-shi-hua-tosokonisheng-mareta-noke-neng-xing

Discussion