Amazon EKS でなんかいい感じにスポットインスタンスを使う

本記事は Amazon EKS Advent Calendar の20日目です！（何故人は直前にならないと書き始めないのでしょうか。私だけですかね。すいません）

少し前に「[AWS が Kubernetes でのスポットインスタンスノードの自動ドレイニングをサポート](https://aws.amazon.com/jp/about-aws/whats-new/2019/11/aws-supports-automated-draining-for-spot-instance-nodes-on-kubernetes/)」というリリースがありました。本記事では、リリース内で触れている「[AWS Node Termination Handler](https://github.com/aws/aws-node-termination-handler)」を中心に、大幅なコスト削減に利用できるスポットインスタンスを Amazon EKS と組み合わせて使うことについて説明します。結構簡単なのでぜひ試してみて下さい。（スポットインスタンス初心者大歓迎です）


# [スポットインスタンス](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/using-spot-instances.html) とは

詳細な説明は [公式ドキュメント](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/using-spot-instances.html) を参照して頂ければと思いますが、簡潔に言うとスポットインスタンスとは **制約** を受け入れることで大幅な割引価格で EC2 インスタンスを利用できる機能（起動方法）です。
こちらは一例ですが、直近（2019-12-19執筆時点）の c5.large のスポット価格はオンデマンドの **約31%** です。👇👇👇（大体、7割引ということですね！！）
※ スポット価格はインスタンスタイプやアベイラビリティゾーンによって変わります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45249/56506622-0c65-4ccf-7a7a-be0c9d399dd4.png)

## 制約とは

スポットインスタンスは AWS クラウド内の使用されていない余剰キャパシティーを利用することで、大幅に割り引かれた価格で EC2 インスタンス利用することが出来る機能ですが、これはクラウド内利用状況に応じて価格が変動する、場合によってはインスタンスが Terminate されることを意味します。
したがって、利用の際にはインスタンスの Terminate　をいかにハンドリングするかが重要となってきます。
具体的には Terminate の2分前に [Amazon EventBridge](https://aws.amazon.com/jp/eventbridge/) (CloudWatch Events) と[インスタンスメタデータ](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/WindowsGuide/spot-interruptions.html#instance-action-metadata) に通知が来るので、その情報を元にインスタンス上で稼働している Pod を他のインスタンス上に移す必要があります。

今まではこの様な実装を各自行っていた（OSSツールもあります）のですが、[AWS Node Termination Handler](https://github.com/aws/aws-node-termination-handler)としてオフィシャルに提供されるようになったので、より簡単にスポットインスタンスを利用できるようになりました。

# Let'a try!

## クラスターの起動

まずは EKS クラスターを準備します。既に検証用のクラスターがある場合は飛ばしていただいて構いません。

今回は`eksctl`を利用します。（クラスター名やリージョンはお好きなものに変更して下さい）

```bash
eksctl create cluster --name=spot-example --nodes=3 --managed --region=ap-northeast-1
```

クラスターの作成が終わったら、オンデマンドで起動したことが分かるようにラベルを付与します。（スポットインスタンスの利用には直接的には不要ですが、今回は分かりやすくするために付与します。）

```bash
kubectl label nodes --all 'lifecycle=OnDemand'
```

終わったらワーカーノードを確認します。`--selector=lifecycle=OnDemand` オプションで指定したラベルが付与されたワーカノードのみを表示することが出来ます。

```bash
kubectl get nodes --show-labels --selector=lifecycle=OnDemand
```

## スポットインスタンスの Node Group の作成

まずは下記のような定義ファイルを作成します。

```yaml:ng-spot.yaml
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
  name: spot-example 
  region: ap-northeast-1
nodeGroups:
  - name: ng-spot
    labels:
      lifecycle: Ec2Spot # Spot であることが分かるようにしています！
    taints:
      spotInstance: true:PreferNoSchedule
    minSize: 2
    maxSize: 2
    instancesDistribution: # 少なくとも2つのインスタンスタイプが必要です
      instanceTypes:
        - m5.large
        - c5.large
        - r5.large
      onDemandBaseCapacity: 0
      onDemandPercentageAboveBaseCapacity: 0 # 一部オンデマンドで起動することも出来ます。
      spotInstancePools: 2
```

終わったら Node Group を作成します。完了したら同様に `kubectl get nodes` コマンドで確認します。

```bash
eksctl create nodegroup -f ./ng-spot.yaml
kubectl get nodes --show-labels --selector=lifecycle=Ec2Spot
```

## [AWS Node Termination Handler](https://github.com/aws/aws-node-termination-handler) とは

ここからが本題です。前述の通り、スポットインスタンスはユーザーの意図しないタイミングで Terminate されるため、その2分前に [Amazon EventBridge](https://aws.amazon.com/jp/eventbridge/) (CloudWatch Events) と[インスタンスメタデータ](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/WindowsGuide/spot-interruptions.html#instance-action-metadata) にくる通知をハンドリングして Pod の退避を行う必要があります。

これを行ってくれるのが AWS Node Termination Handler です。AWS Node Termination Handler は[インスタンスメタデータ](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/WindowsGuide/spot-interruptions.html#instance-action-metadata)をチェックし、Terminate 予定のあるワーカノードを退役させます。具体的な動きは下記のような感じです。

1. AWS Node Termination Handler をクラスターに展開する。 (`kubectl apply`)
2. Daemonset として Pod が各ワーカーノードで実行される
3. Pod に含まれるコンテナが [インスタンスメタデータ](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/WindowsGuide/spot-interruptions.html#instance-action-metadata) を定期的にチェックする
4. Terminate の2分前があると、ワーカノードをクラスターから退役させる
5. Kubernetes クラスターは退役予定のワーカノード上で動いている Pod を他のワーカーノードに移動させる

## AWS Node Termination Handler の実行

下記コマンドで Daemonset を宣言します。

```bash
kubectl apply -k "https://github.com/aws/aws-node-termination-handler/config/base?ref=master"
```

`kubectl get daemonsets` で確認します。

```bash
kubectl --namespace=kube-system get daemonsets

NAME                       DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR   AGE
aws-node                   5         5         5       5            5           <none>          139m
kube-proxy                 5         5         5       5            5           <none>          139m
node-termination-handler   5         5         5       5            5           <none>          19s
```

node-termination-handler　が 5Pods 動いているかと思いますが（既存のクラスタで試されている場合はこの限りではありません）、OnDemand: 3, Spot:2 にも関わらず、全ワーカーノードで node-termination-handler が動いてしまっているためあまり効率的ではありません。
そこで予め付与しているラベルを利用して、`lifecycle: Ec2Spot`のワーカノードのみに制限するようにしましょう。作業は下記コマンドで行います。

```bash
kubectl apply "https://github.com/aws/aws-node-termination-handler/config/overlays/spot-node-selector/daemon-set-spot-node-selector.yaml?ref=master"
```

ソースコードを見て頂ければ分かりますが、上記 base の定義ファイルに加えて下記のような`nodeSelector`の設定が入っています。仮に自社でスポットインスタンスに異なるタグを付与している場合は、`Ec2Spot`を変更する必要があります。

```yaml:daemon-set-spot-node-selector.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: node-termination-handler
spec:
  template:
    spec:
      nodeSelector:
        lifecycle: Ec2Spot # 👈 任意のラベルに変更可能
```

改めて確認してみると、node-termination-handler が起動する Node が `lifecycle=Ec2Spot` に制限され、Pod 数がスポットインスタンス数に一致していることが確認できるかと思います。

```bash
kubectl --namespace=kube-system get daemonsets

NAME                       DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR       AGE
aws-node                   5         5         5       5            5           <none>              151m
kube-proxy                 5         5         5       5            5           <none>              151m
node-termination-handler   2         2         2       2            2           lifecycle=Ec2Spot   12m
```

簡単ですね。

※ なお今回は Node Selector の説明をするために分けて `kubectl apply` しましたが、

```bash
kubectl apply "https://github.com/aws/aws-node-termination-handler/config/overlays/spot-node-selector/daemon-set-spot-node-selector.yaml?ref=master"
```
だけで問題ありませんので、その点だけご注意下さい。

## AWS Node Termination Handler だけで「いい感じに」使えるのか？

クラスター側の設定は上記で問題ありませんが、Pod の処理が中断された場合のハンドリングは別途アプリケーションレベルで行う必要があります。

Web の UI や　API を提供している場合は、一定時間内に Connection を終了し別の Pod にロードバランスされるように適切に設定したり、ジョブワーカーの場合は処理途中で停止した Pod の掴んでいたキューを他のPod が再処理しても問題が起きないように作るといった感じです。

このあたりはコンテナ化する際にクリアされているケースが多いと思われますが、スポットインスタンス利用の際は中断時の挙動を見直すことをおすすめします。

# まとめ

今まで各社個別にスポットインスタンスの退役処理を実装されていたかと思いますが、AWS Node Termination Handler の登場でより簡単にスポットインスタンスを利用できるようになりました。
[AWS Fargate の Amazon EKS サポート](https://aws.amazon.com/jp/blogs/news/amazon-eks-on-aws-fargate-now-generally-available/)もかなり熱いアップデートですが、より高いコスト効率を求める方はぜひスポットインスタンスを試してみて下さい。
なお、今回は Amazon EKS, `eksctl` 前提ででも環境を作成しましたが、`kops` 等で構築した Self-hosted な Kubernetes でも利用可能です。