⚖️

EKS にスポットインスタンスを導入し、Descheduler でオンデマンド:スポットの 1:1 配置を自動制御した話

に公開

概要

Amazon EC2 スポットインスタンス は最大 90% という大きなコスト削減メリットを持つ一方で、突然のインスタンス中断(Spot Interruption) というリスクがあります。
可用性を維持しながらスポットインスタンスを取り入れるには「どの Pod をスポットインスタンスに載せるか」「スポットインスタンスとオンデマンドインスタンスの比率をどう維持するか」「偏ったらどう直すか」まで設計に組み込む必要があります。

本記事では弊社で実践した以下のアプローチを紹介します。

  • 安全に停止できる Pod のみスポットインスタンスにスケジューリング
  • スポット / オンデマンド を 1:1 の比率で起動
  • スポット / オンデマンド の起動比率が崩れた際に descheduler で自律的に修復

前提条件

本記事では以下のバージョンを前提としています。

スポットインスタンス専用のノードグループを作成する

既存のノードグループを複製し、スポットインスタンス専用のノードグループを作成します。
弊社では Terraform で管理していたので、既存のノードグループに対して下記 Argument の追加を行いスポットインスタンスに最適化を行いました。

  • capacity_type"SPOT" に指定
  • スポットインスタンスに乗せることを明示的に許可した Pod 以外は配置されないように taint を設定
taint {
  key    = "spotOnly"
  value  = "true"
  effect = "NO_SCHEDULE"
}
  • 同じサイズの vCPU とメモリリソースを持つインスタンスタイプを複数指定できるように設定
    • インスタンスタイプが増えても自動で追従されるように以下の通り動的に取得する仕組みを構築した
# vCPU: 2, memory: 8GB, CPU アーキテクチャ: x86_64, スポットインスタンス対応
# 上記の条件を満たすインスタンス情報を取得
data "aws_ec2_instance_types" "spot" {
  filter {
    name   = "vcpu-info.default-vcpus"
    values = ["2"]
  }

  filter {
    name   = "memory-info.size-in-mib"
    values = ["8192"]
  }

  filter {
    name   = "processor-info.supported-architecture"
    values = ["x86_64"]
  }

  filter {
    name   = "supported-usage-class"
    values = ["spot"]
  }
}

resource "aws_eks_node_group" "spot_node" {
  ~略~
  instance_types = sort(data.aws_ec2_instance_types.spot.instance_types)
}

同じサイズの vCPU とメモリリソースを持つインスタンスタイプを複数指定できるように設定

補足として、こちらの対応は1つのインスタンスだけを指定していても動作するためスポットインスタンス稼働には必須ではないですが、スポットインスタンスにはキャパシティ-の概念があり、キャパシティ-が余っていない場合はスポットインスタンスが起動できないというリスクがあります。
スポットインスタンスとオンデマンドインスタンスの違い
そのリスクを極限まで減らすために、vCPU やメモリの条件が同一で実用可能なインスタンスタイプ全てを起動対象とした経緯があります。
また、EKS のマネージドノードグループの公式ドキュメントにもマネージドノードグループで Cluster Autoscaler を使用している場合、同じサイズの vCPU とメモリリソースを持つインスタンスタイプを柔軟に組み合わせて使用することが推奨されています。

中断に耐えられる Pod だけスポットインスタンスに配置する

スポットインスタンスの中断に耐えられない Pod がスポットインスタンスに乗らないように、スポットインスタンス専用のノードグループ側で taint 設定を行ったことで、tolerations を設定していない Pod はスポットインスタンスに配置されない様になっています。
ここから中断に耐えられる Pod を選定し、スポットインスタンスに配置するように設定していきます。
スポットインスタンスが中断されるまでの猶予時間は2分となっているので、terminationGracePeriodSeconds が2分より短いかつ、中断されても影響がないステートレスな Pod を対象とすることにしました。

なお、今回の構成では EKS マネージドノードグループを利用しているため、
スポットノードが中断するリスクが高い場合には Amazon EC2 Spot Capacity Rebalancing が有効化されます。
これにより、スポット中断通知やキャパシティリバランス通知を受けた際に、
Amazon EKS が対象ノードを適切にドレーンおよび再調整し、Pod を他ノードへ退避させることで、
アプリケーションの中断を最小限に抑えられるようになっています(参照)。

taint が設定されているスポットインスタンス専用のノードグループに対象の Pod をスケジューリングさせるには、対象の Pod に tolerations を設定をする必要があります。
本記事の taint に対応した tolerations は以下となります。

tolerations:
- key: "spotOnly"
  operator: "Equal"
  value: "true"
  effect: "NoSchedule"

この設定によって対象の Pod は既存のオンデマンド専用のノードグループとスポットインスタンス専用のノードグループのどちらにも配置される様になります。


topologySpreadConstraints を活用してスポット / オンデマンド を 1:1 で均等配置

安全に中断できる Pod のみをスポットインスタンスで稼働させる対象としたとはいえ、いきなり全てスポットインスタンスに乗せるのはリスクがありそうなのと、2024年に購入した Compute Savings Plans が適用されず無駄になってしまうことを考慮して、スポットインスタンスに配置する対象の Pod のスポット / オンデマンドの比率が 1:1 になるように制御することにしました。
まず、topologySpreadConstraints を対象の Pod に設定して比率を 1:1 に制御することを試みました。

設定は以下の通りです。

topologySpreadConstraints:
- maxSkew: 1
  topologyKey: eks.amazonaws.com/capacityType
  whenUnsatisfiable: ScheduleAnyway
  labelSelector:
    matchLabels:
      app.kubernetes.io/name: sample-app

以下がパラメータの解説になります。

設定 効果
maxSkew: 1 スポットとオンデマンドの差を1以上にしない
topologyKey: eks.amazonaws.com/capacityType capacityType の値("SPOT", "ON_DEMAND")を基準に分散する
whenUnsatisfiable: ScheduleAnyway スポット / オンデマンドの偏りが maxSkew の値を違反してもスケーリングできることを優先する
labelSelector 指定した label を持つ Pod を対象にする

しかし、この設定を行なっても ScheduleAnyway が設定されているせいなのかスポットとオンデマンドの比率は 1:1 になりませんでした。


Cluster Autoscaler の balance オプションを活用してスポット / オンデマンド を 1:1 で均等配置

topologySpreadConstraints のみでは制御しきれなかったので、balance-similar-node-groups というオプションを使うことで解決できるか試みます。
balance-similar-node-groups を設定することによって、Cluster Autoscaler がスケールアウト/スケールインを決めるとき、1つのノードグループに過度に偏らないように調整してくれます。
similar と名のつく通り、スケール対象のノードグループで似ているものを同一グループとみなし、そのグループ間でノードが偏らないようにしてくれるようです。

似ていると判定されるためには下記条件を全て満たす必要があるようです(参照)。

  • すべてのリソースにおいて 同じキャパシティ(総容量)であること
  • 各リソースの Allocatable(割り当て可能量)の差が互いに 5% 以内であること(この閾値は複数の要因により変動するため、多少の余裕を見込むのが望ましい)
  • 各リソースの "Free"(Allocatable から DaemonSet と kube-proxy が使用する分を引いた残り) の差が互いに 5% 以内であること(こちらも要因により変動するため、多少の余裕を持たせるのが望ましい)
  • "全く同じラベルセット" を持つこと(ただし zone と hostname ラベルは例外とする)

今回の要件として、スポットノード側は様々なインスタンスタイプが起動する仕様になっていることから、 "全く同じラベルセット" を持つこと を満たすのが難しそうです。
理由は下記の様なインスタンスタイプ毎のラベル情報が自動で付与されてしまうためです。
beta.kubernetes.io/instance-type: t3.large

そこで、Cluster Autoscaler 1.25 から追加された balancing-label というオプションを利用します。
balancing-label で指定した label がある場合、balance-similar-node-groups のデフォルト動作をスキップして似ているノードかどうかを判定できます。
Cluster Autoscaler は helm chart でインストールしているので、下記の通り values.yaml を設定します。
memory と cpu がラベルで一致していれば似ているノードとみなす設定にしています。

cluster-autoscaler:
  extraArgs:
    balance-similar-node-groups: "true"
    balancing-label_1: "node.kubernetes.io/memory"
    balancing-label_2: "node.kubernetes.io/cpu"

balancing-label を機能させるために、オンデマンドインスタンス専用のノードグループとスポットインスタンス専用のノードグループに下記ラベルを付与しておきます。

resource "aws_eks_node_group" "spot_node" {
  ~略~
  labels = {
    "node.kubernetes.io/cpu"    = "2"
    "node.kubernetes.io/memory" = "8"
  }
}

結果として、スポット / オンデマンドの比率は大体 1:1 程度になりました。

運用で崩れる 1:1 比率を Descheduler で自動修復する

topologySpreadConstraints や balancing-label の導入によって平常時は 1:1 に近い比率で安定する様になりましたが、ノードの入れ替えによって 1:1 が大きく崩れることが発覚しました。
ノードの入れ替えは下記で発生するので、運用上回避することは出来ないです。

  • Cluster Autoscaler によるスケールイン、スケールアウト
  • デプロイによる Pod 入れ替えに伴うノード入れ替え
  • スポットインスタンス終了によるノード入れ替え

このように、kubernetes には運用や時間経過によって最適ではなくなった Pod 配置を再配置によって最適化する機能が備わっていないため、そのニーズを満たしてくれそうな Descheduler を導入することにしました。

Descheduler 導入

Descheduler とは、Kubernetes クラスタ内の Pod の偏りや非効率な配置を「再配置(リスケジュール)」によって自動で直すコンポーネントです。
Helm Chart に対応しているので、既に Helm を活用している弊社では Helm Chart 使ってインストールすることにしました。
CronJob か Deployment のどちらかで動作させることができますが、CronJob で5分間隔で Job Pod を作成して使い捨てに出来る方が扱いやすそうだったので CronJob を選択しました。

Descheduler は以下のようなルールをもとに邪魔な Pod だけを Evict させ、再スケジューリングを促します。

名前 説明
RemoveDuplicates 同一 Pod のレプリカをノード間に分散させる
LowNodeUtilization 使用率が低いノードから Pod を退避させ、リソース効率を高めるために配置を再調整する
HighNodeUtilization ノードのリソース使用率が均等になるよう Pod の配置を調整する
RemovePodsViolatingInterPodAntiAffinity Pod Anti-Affinity ルールに違反している Pod を Evict(退去)する
RemovePodsViolatingNodeAffinity Node Affinity ルールに違反している Pod を Evict する
RemovePodsViolatingNodeTaints ノードの Taint に適合していない Pod を Evict する
RemovePodsViolatingTopologySpreadConstraint TopologySpreadConstraints ルールに違反している Pod を Evict する
RemovePodsHavingTooManyRestarts 再起動回数が多すぎる Pod を Evict する
PodLifeTime 指定した一定の稼働時間(Age)を超えた Pod を Evict する
RemoveFailedPods 失敗状態(特定の終了理由や exit code)の Pod を Evict する

今回は TopologySpreadConstraints に違反している Pod を再配置することが目的なので、 RemovePodsViolatingTopologySpreadConstraint を設定することにしました。

RemovePodsViolatingTopologySpreadConstraint のパラメータは以下のようにしました。

設定 効果
topologyBalanceNodeFit: false Evict 前にその Pod が他ノードへ再スケジューリング可能かを確認しない
constraints: DoNotSchedule, ScheduleAnyway DoNotSchedule, ScheduleAnyway のどちらの違反も Evict 対象とする

設定の理由は以下の通りです。

  • topologyBalanceNodeFit: false
    • 後述の nodeFit の設定に揃えたため
  • constraints
    • DoNotSchedule,ScheduleAnyway を両方指定しないと TopologySpreadConstraint の下設定が ScheduleAnyway の場合に Evict 対象にならないため

また、個別のポリシー以外に DefaultEvictor という共通のポリシーもあるので、こちらも合わせて設定します。
DefaultEvictor にも様々な設定がありますが、今回は下記を指定しました。

設定 効果
evictLocalStoragePods: true Local Storage を利用している Pod も Evict 対象とする
ignorePodsWithoutPDB: true PodDisruptionBudget を持たない Pod は Evict 対象から除外する
nodeFit: false Evict 前にその Pod が他ノードへ再スケジューリング可能かを確認しない

設定の理由は以下の通りです。

  • evictLocalStoragePods
    • 永続データのためではないローカルストレージを持つ Pod が今回の対象だったので有効化
  • ignorePodsWithoutPDB
    • 万が一 PDB の設定が漏れている Pod があった場合に危険なため有効化
  • nodeFit
    • 最初は有効化していたが、ignoring pod for eviction as it does not fit on any other node が多発して Evict が殆ど発生しない現象に対応するため無効化

最終的に descheduler の values.yaml は以下のようになりました。
descheduler のバージョンは 0.33.0 を前提としています。

descheduler:
  kind: CronJob
  schedule: "*/5 * * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 1
  startingDeadlineSeconds: 300
  podSecurityContext:
    runAsNonRoot: true
    seccompProfile:
      type: RuntimeDefault
  resources:
    requests:
      cpu: 500m
      memory: 256Mi
    limits:
      cpu: "500m"
      memory: 512Mi
  deschedulerPolicy:
    profiles:
    - name: default
      pluginConfig:
      - name: DefaultEvictor
        args:
          nodeFit: false
          evictLocalStoragePods: true
          ignorePodsWithoutPDB: true
      - name: RemovePodsViolatingTopologySpreadConstraint
        args:
          topologyBalanceNodeFit: false
          constraints:
          - DoNotSchedule
          - ScheduleAnyway
      plugins:
        balance:
          enabled:
          - RemovePodsViolatingTopologySpreadConstraint

補足ですが、descheduler の最新版である 0.34.0 からは DefaultEvictor への evictLocalStoragePodsignorePodsWithoutPDB の設定方法が変更されていて、下記のように podProtections を使うのが推奨されています。

profiles:
  - name: ProfileName
    pluginConfig:
    - name: "DefaultEvictor"
      args:
        podProtections:
          defaultDisabled:
            #- "PodsWithLocalStorage"
            #- "SystemCriticalPods"
            #- "DaemonSetPods"
            #- "FailedBarePods"
          extraEnabled:
            #- "PodsWithPVC"
            #- "PodsWithoutPDB"
            #- "PodsWithResourceClaims"

上記サンプルポリシーの全体はこちら
0.33.0 の場合 podProtections は未実装でエラーが出るので、本記事の内容を参考にしてください。

結果として、descheduler の導入によって TopologySpreadConstraint 違反な Pod が5分おきに Evict されるようになったため、ノードの入れ替えによる スポット / オンデマンド の配置ズレが発生しても 1:1 になるように制御されるようになりました。

導入によるコスト削減効果について

スポットインスタンスはその時のインスタンスタイプによって価格が変動するので、削減額は当時の参考値ですが、以下の通りそれなりのコスト削減効果が得られました。

項目 結果
スポットインスタンス 移行台数 35台中 10台を Spot 化
コスト削減 年間 約3,000 USD(Compute Savings Plans 比)
可用性 スポットインスタンス導入前と同等

スポットの適用範囲を更に増やしていくことでより大きなコストカットが実現できそうです。

まとめ

EKS でスポットインスタンスを本番投入するには、「スポットインスタンスを使う仕組み」だけでなく「スポットインスタンスを制御し続ける仕組み」まで設計する必要があります。

本記事では次の 3 つで コスト最適化 / 可用性維持 / 運用自動化 を同時に実現しました

  1. 中断耐性のある Pod の選別とスポットインスタンスへの配置
  • terminationGracePeriodSeconds が2分より短いかつ、中断されても影響がないステートレスな Pod を選別
  • 対象の Pod に toleration を付与することで taint が付与されたスポットインスタンスへの配置を実現
  1. スポット / オンデマンド の 1:1 バランス維持
  • topologySpreadConstraints で Pod の配置比率を 1:1 にバランシング
  • Cluster Autoscaler の balancing-label でノードレベルの偏りも抑制
  1. 運用で崩れた 1:1 配置の自動修復
  • Kubernetes には 再配置による最適化の仕組みがないので、descheduler で topologySpreadConstraints の違反を検知 → Evict → 再スケジュール し、偏りを自律回復

これにより、スポットインスタンスを導入して大幅にコスト削減しつつも、可用性を損なうことなく、運用負荷も低いスポットインスタンス運用を実現できました。

Social PLUS Tech Blog

Discussion