電通総研 テックブログ

電通総研が運営する技術ブログ

EKSにてワークロードの中断を極限に減らしスポットインスタンスを運用するためには

はじめに

こんにちは!2年目にもかかわらずフレックス制度を存分に活用して、普段8:30 – 16:30で業務をしているクラウドイノベーションセンターの石井大樹です。

Amazon EKS(以下EKS)にて、スポットインスタンスは利用されていますか?
スポットインスタンスは安いけど、いきなりシャットダウンしちゃうから、使い勝手が悪いんでしょう?など、漠然とした不安から利用をためらっていたりはしないでしょうか。
そのぼんやりとした不安を払しょくし、スポットインスタンス利用を検討する足がかりになれば、と思い今回は記事にまとめさせていただきました。

前提

この記事の対象とする読者の方

スポットインスタンスについて

スポットインスタンスは、通常のオンデマンドインスタンスに比べて最大90%の割引で利用できるオプションです。
しかし、オンデマンドインスタンスと異なり、AWS側のリソースキャパシティの都合により、インスタンスの利用が中断される可能性があります。

2つのシグナル

先ほどスポットインスタンスは、「AWS側の都合により中断される可能性がある」とお話しいたしました。しかし、実際には2つのシグナルが発出された後に中断が実行されます。このシグナルを利用することで、スポットインスタンスの中断の影響を最小限に収めることができます。

2分前中断シグナル

スポットインスタンスの終了の2分前に通知されるものです。この通知を受け取って2分後に、スポットインスタンスは終了します。

再調整推奨シグナル

対象スポットインスタンスの中断可能性の高まりが検知された場合、2分前シグナルを受け取る前に通知されるシグナルです。このシグナルを受け取ったタイミングで必要な処理が行われるように事前に準備することで、スポットインスタンスを安定して安全に運用できます。

Amazon EC2 AutoScalingグループのCapacity Rebalancingが有効化されている場合、再調整推奨シグナルを受け取ると、Amazon EC2 AutoScalingは、代替となる新しいインスタンスの起動を試みます。その 新しいインスタンスが起動した後、既存のインスタンスはシャットダウンされます。

なお、EKSノードグループのAutoScalingグループでCapacity Rebalancingを有効化したい場合は、後述するマネージド型ノードグループを利用することで自動的に有効化されます。セルフマネージド型ノードグループで利用をする際は、手動で設定をする必要があります。
また、セルフマネージド型ノードグループにて、再調整推奨シグナルを受け、後述するGraceful Shutdownなどのアクションの実行をするには、AWS Node Termination Handlerなどの導入が必要です。セルフマネージド型ノードグループには、 シグナルをキャッチしてノード上のPodの安全な待避を行う仕組みがないためです。

※注意
この再調整推奨シグナルは2分前中断シグナルよりも前に通知されるということを保証していません。予期せぬリソースの需要がAWS側で生まれた場合は、2分前中断シグナルと同時に通知を受け取り、必要な対処ができずにワークロードが中断される可能性があります。

再調整推奨シグナルについて

スポットインスタンスのリソース確保

せっかくスポットインスタンスの利用を開始したのにも関わらず、スポットインスタンスのリソースが十分に確保できずにワークロードを中断してしまっては元も子もありません。この章では、スポットインスタンスを利用しながら十分なリソースを確保し続ける方法をこちらでご紹介します。

マネージド型ノードグループの利用

EKSでは、マネージド型ノードグループを利用することにより、様々な恩恵を開発者は受けることができます。この恩恵はスポットインスタンスの利用時も例外ではありません。これを利用することにより、EKSでスポットインスタンスを利用する際にAWSがベストプラクティスとして定めた内容を自動的に設定してくれます。
以下が対象の設定内容の一部分です。

  • 配分戦略をcapacity-optimizedにする。
    • これにより、中断確率の低いスポットインスタンスが配分されます。
  • Capacity Rebalancingを有効化。
    • これにより、再調整推奨シグナルを受けたときに、EKSは自動的に中断可能性の低いスポットキャパシティプールからスポットインスタンスを起動し、そのノードが準備完了になったら、既存のスポットインスタンスのドレインをしてくれます。

Mixed Instance Policyの利用

複数のEC2インスタンスタイプをノードグループで利用できるようにすることで、スポットインスタンスを利用できる確率が高まります。利用候補となるインスタンスタイプが増えるためです。しかし、注意することが1点あります。それは、vCPU・メモリが同等のインスタンスタイプであるべきということです。
例えば、 CPU:2vCPU, メモリ:4GiBc5a.largeを利用する際は、CPU:2vCPU, 、メモリ:4GiBc5d.large等と利用されるべきということです。
vCPU・メモリが異なるインスタンスタイプを混在させると、Cluster Autoscalerが正常にリソースの計算ができなくなり、意図しないスケールにつながってしまいます。

Cluster Autoscalerを有効にする。

Cluster AutoscalerはKubernetesクラスタを自動的に調整してくれる非常に有効なエコシステムです。このエコシステムは導入のみでも十分な効果を発揮しますが、以下に紹介する機能を利用することでさらなる効果が期待できます。
※Auto Scalingグループの最大・最少ノード数は尊重されます。そのため、Cluster Autoscalerは最大ノード数以上・最少ノード数以下にはスケールできません。

ノードグループのAZごとの分散

ノードグループを、利用しているリージョンのAZを跨るように作成するようにします。利用候補となるインスタンスが増えるためです。そうすることで、スポットインスタンスを利用できる確率を高めることができます。

Priority Expander

しかし、どれだけ対策を練っても、スポットインスタンスが確保できない可能性があります。そんなときは、オンデマンドインスタンスを代わりに確保するようにしましょう。値段は割引がされていない定価で利用することになりますが、ワークロードを中断させないことが最優先です。
Cluster AutoscalerのPriority Expanderを利用することで、スポットインスタンスが見つからない際は、オンデマンドインスタンスのノードグループをスケールさせることで不足したリソースを補填できます。
以下が設定例です。正規表現spotに該当するノードグループが優先してスケールされるように設定しています。

apiVersion: v1
kind: ConfigMap
metadata:
  name: cluster-autoscaler-priority-expander
  namespace: kube-system
data:
  priorities: |-
    50: 
      - .*spot.*
    1: 
      - .*ondemand.*

スポットインスタンスの中断に備える

ここまでは、できるだけスポットインスタンスを多くの割合で利用する方法をご紹介いたしました。
ご紹介した通り、スポットインスタンスは中断させられてしまうものです。この中断の際にワークロードの実体であるPodに対してなんにも考慮に入れていない場合、ワークロードは中断してしまいます。
ここからはスポットインスタンスが切り替わる際にもワークロードを安全に保つための方法をご紹介します。

ワークロードを停止させないために考慮すること

ワークロードを中断させないために考慮する必要があることが4つあります。

  • PodはGraceful Shutdownされるようになっている。
  • Podは必要な数を維持したままドレインされる。
  • Podが正常であるという状態を適切に定義されている。
  • Podの可用性を考慮してスケジュールされるようになっている。

Graceful Shutdownされるようになっている。

再調整推奨シグナルを受け取り、EKSがノードのドレインを開始して、瞬時にPodが削除されてしまった場合、問題が発生する可能性があります。その問題について理解するには、まずPodの削除が始まると何が起こるかを理解する必要があります。
Podの削除が始まると以下が行われます。

  • Podの終了処理
  • ServiceからのPodの切り離し

これらが、問題の生じない順序で行われたら良いのですが、Kubernetesではこれらは個別のプロセスとして別々に行われるので、それは保証されません。
順序が異なる場合、以下のような問題が生じます。

  • リクエストを受け付けたが、処理するPodが削除されてしまい、エラーになる。

また、Podが最後のリクエストを受け付けたはいいが、処理している間にPodが終了されてしまった場合はどうなるでしょうか。このような問題が生じます。

  • リクエストの処理が終わっていないにもかかわらずPodが削除されてしまい、エラーになる。

このような状況が生じ、ワークロードに影響を及ぼさないためにもPodがGraceful Shutdownをするように適切に設定しなければいけません。
この問題を解決するには2つの対策が必要です。
以下では、2つの対策・Podのライフサイクルを語る上では非常に重要な用語を用いて解説します。

  • SIGTERM:終了処理を開始するように命令するシグナル
  • SIGKILL:コンテナを強制的にシャットダウンするように命令するシグナル
  • terminationGracePeriodSeconds: deletionTimeStampが設定されてから何秒でSIGKILLをおくるかの設定
    - preStopと、SIGTERMはこの時間内に終わらせることが必要です。
  • preStop: Podが終了する前に実行される処理
  1. preStopライフサイクルフックで、SIGTERMが、サービスから切り離されたあとに送られるように待機させる。
  2. アプリケーション側でSIGTERMシグナルを適切にハンドリングして、リクエストの処理が終了した後でアプリが終了するように実装する。
  3. terminationGracePeriodSecondsを十分な時間設定して、SIGKILLが、リクエストの処理+SIGTERMハンドリング処理が終わったのちにPodに送られるよう待機させる。

図で説明すると、以下のような時系列に処理が行われるようにすることで、Graceful Shutdownは達成できます。

Podが必要な数を維持したままドレインされる。

再調整推奨シグナルが出た際などの、ノードがPodをドレインする必要が生じた際、全てのPodを一度に停止してしまったらどのようなことが起こり得るでしょうか。
リクエストを受け付けるPodがなくなり、サーバーサイドエラーになってしまい、ワークロードに障害が生じます。
このような状況を防ぐためにPodDisruptionBudget、通称PDBという機能が存在しています。これは、ノードがPodをドレインする際に、Podが一度に停止できる数を制限できます。
これは、最大停止数maxUnavailable, 最少起動数minAvailableを、レプリカ数または割合を指定することで適用できます。

apiVersion: policy/v1beta1
kind: PodDisruptionBudget
metadata:
  name: my-pdb
spec:
  minAvailable: 2  # ここには最小起動数を指定します。数値または割合を指定できます。
 #maxUnavailable: 1 またはこのように、最大停止数を指定できます。
  selector:
    matchLabels:
      app: my-app  # ここには、この PDB を適用する Pod のラベルを指定します。

Podが正常であるという状態を適切に定義する。

無事新しいスポットインスタンスにPodがスケジュールされたとしても考慮しなければいけないことがあります。それは、Podがリクエストを受け付けられるかのヘルスチェックを正確にすることです。
Podの準備ができていないにも関わらず、トラフィックをPodにルーティングした場合、Podはリクエストを処理できずにエラーになってしまいます。これでは、ワークロードが中断してしまうため、非常に問題です。
これを解決するのは、Readiness Probeです。
Readiness Probeは、httpGetを用いて特定のパスの状態をチェックするなどして、アプリケーションがリクエストを正常に処理できる状態になっているかを確認します。これにより、DB接続や時間のかかる起動プロセスが全て完了している、つまりトラフィックを受け付ける準備が整ったときにだけ、トラフィックがPodにルーティングされます。

Pod Readiness Gate

AWS Load Balancer Controllerを利用している場合は、AWS LoadBalancer Controller Pod Readiness Gateを有効にすることを強くお勧めします。仮に、ローリングアップデートが非常に高速に行われてしまい、PodのLoad Balancerターゲットグループへの登録が遅れた場合はどうなるでしょうか。Load Balancerはバックエンドに、リクエストを送信できる正常なPodがないと判断し、リクエストを送信せずにワークロードが中断してしまいます。
しかし、なぜこのようなことが生じてしまうのでしょうか。それは、Load Balancerが正常であると判断するタイミングと、Kubernetes内でPodが正常であると判断されるタイミングに差があるからです。
このような問題が生じることをPod Readiness Gate機能を利用することで防ぐことができます。この機能は、Pod Readiness Probeに、「Load Balancerのターゲットグループへ登録されていること」という条件を追加できます。この新たな条件により、Podが正常な状態として判断される前に、そのPodがLoad Balancerのターゲットグループに正しく登録されていることが確認されます。
この結果、ローリングアップデートが行われて新しいPodがデプロイされる際、すべてのPodがLoad Balancerに適切に登録されてからリクエストを処理し始めることが保証されます。

Podの可用性を考慮したスケジュール

次は、Podの可用性について考えてみましょう。先ほど、ノードがPodをドレインする際にPDBを設定することにより、ワークロードを保つために必要な最低限のPod数を指定しました。
しかし、仮に災害や電源の問題など、何らかで、一部のノードが全く利用できなくなった場合、Pod Disruption Budgets (PDB) 設定だけでは、Podの最少数を維持できなくなってしまう可能性があります。
例えば、1つのノードに全てのPodがスケジュールされていたとします。その1つのノードがダウンした場合、サービスは停止することになります。
こうしたシナリオを考慮すると、Podを複数のノード、さらにアベイラビリティゾーン(以下AZ)に分散する方針が重要になってきます。これにより、単一のノード・AZで問題が発生した場合でも、他のAZに展開されたPodがワークロードを維持できます。
そのため、KubernetespodAntiAffinityや、topologySpreadConstraintsの設定を使用して、Podの分散を制御するべきです。
podAntiAffinityを利用した場合:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 8
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: myapp
        image: myapp:1.0.0
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 100
            podAffinityTerm:
              labelSelector:
                matchLabels:
                  app: myapp
              topologyKey: topology.kubernetes.io/zone

topologySpreadConstraintsを利用した場合:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 8
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: myapp
        image: myapp:1.0.0
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: myapp

上記のYAML設定は、podAntiAffinityとtopologySpreadConstraintsをそれぞれ利用した場合どのようにAZ間の分散を実現するかの例です。

podAntiAffinityの設定は、同じAZ内に同じアプリケーションのPodが配置されることを避けることを示しています。これはpreferredDuringSchedulingIgnoredDuringExecutionオプションを使用して、スケジューリング時には可能な限り考慮されます。
ここでは、topologyKeyをtopology.kubernetes.io/zoneに設定しているため、同じAZ内ではなく異なるAZにPodが配置されます。
requiredDuringSchedulingIgnoredDuringExecutionは利用しないでください。同じAZでのスケジュールが不可になるため、Podの最大数がAZ数に制限されてしまいます。
2つ目の例では、topologySpreadConstraintsを用いて、Podが均等に分散されるよう制約を加えています。ここではmaxSkew1に設定しており、これは任意の2つのAZ間でのPodの最大の数量差を示します。
つまり、各AZのPodの数が1つだけ異なる場合にのみPodのスケジューリングを許可します。topologyKeyはtopology.kubernetes.io/zoneに設定され、Podは異なるAZに均等に分散されます。
そしてwhenUnsatisfiable: DoNotScheduleにより、制約を満たすことができない場合には新たなPodのスケジューリングが行われません。
このようにして、Podの配置を細かく制御し、ノードやAZの障害からアプリケーションを保護できます。これらの設定を適切に使用することで、災害や電源の問題など、予期しないイベントが発生した場合でも、ワークロードの中断を回避する、または最小限に抑えることができます。

まとめ

今回、お話しさせていただいた内容は以下です。

  • スポットインスタンスのリソース確保
    • マネージド型ノードグループの利用
    • Mixed Instance Policyの利用
    • Cluster Autoscalerの利用
      • ノードグループのAZごとの分散
      • Priority Expanderの利用
  • スポットインスタンスの中断に備える
    • PodはGraceful Shutdownされるようになっている。
    • Podは必要な数を維持したままドレインされる。
    • Podが正常であるという状態を適切に定義されているか。
    • Podの可用性を考慮してスケジュールされるようになっている。

これらを実践することで、よりスポットインスタンスを安全に効率的にご利用いただけます。また、スポットインスタンスを利用しない場合でも、後半の「スポットインスタンスの中断に備える」の部分を実践していただくことで、より堅牢にサービスを維持していただくことができます。

おわりに

X(クロス)イノベーション本部 クラウドイノベーションセンターでは、新卒・キャリア採用問わず共に働いてくれる仲間を探しています。
本記事で紹介した私の働き方や、クラウドを中心とした業務にご興味をお持ちの方は、ぜひ採用ページよりご応募ください。

執筆:@taiki_ishii、レビュー:柴田 崇夫 (@shibata.takao)/ 寺山 輝 (@terayama.akira)
Shodoで執筆されました