サーバレスなk8sクラスタを体験してみる〜Spot Oceanの検証〜

# この記事の要約
Kubernetes環境におけるインスタンスを動的に管理し、コストの最適化も行ってくれる**Spot Ocean**をEKSクラスタに適用し、その基本的な挙動について検証してみました。

:::note
今回の検証で確認できたこと
- クラスタのリソースが不足　→ 自動スケール**アウト**
    - 追加されるインスタンスサイズはスケジュール待ちpodの要求リソースに応じて自動選択
    - クラスタに組み込まれるインスタンスの種類はSpotインスタンスなどの安価なもの
    - podの再配置が可能な場合は、スケールアウト → より高スペックなインスタンスへpodを集約 → 不要インスタンスのスケールイン(削除)という流れでスケール**アップ**のような挙動を取る
- クラスタに余剰リソースあり → 自動スケール**イン**
    - podを自動で再配置し、積極的にインスタンスコストを節約
    - 再配置(pod削除/再作成)が不都合なワークロードは個別に指定することで回避可能
    - podの再配置が可能な場合は、スケールアウト → より低スペックなインスタンスへpodを集約 → 不要インスタンスのスケールイン(削除)という流れでスケール**ダウン**のような挙動を取る
:::

:::note warn
コスト削減効果について
- 今回の検証環境では68.95%のコスト削減効果でした
    - 実環境での効果を保証するものではないのでご注意ください。
:::

# Spot by NetAppについて
Spot by NetAppはAWSで言うところのSpotインスタンス[^1]を活用してインスタンスのコストを最適化するFinOpsなマネージドサービスです。
Spotインスタンス自体の解説とSpot by NetAppの活用例については以下をご覧ください。
[^1]:Spot by NetApp自体はAWS以外にもMicrosoft Azure, Google Cloudに対応しています。


https://qiita.com/yasuoohno/items/a141053cf5864e56e914

**ちなみにSpot by NetAppの利用料はインスタンス20台分までなんと無料！（宣伝）**

## Spot Oceanについて
Spot OceanはSpot製品ファミリーの中で**Kubernetes環境におけるコスト最適化**を担うプロダクトです。
k8s上に払い出されるワークロード(pod/job等)に応じて自動でインスタンスを払い出したり、リソース配置を最適化することでインスタンス料金を抑える、というのが主な機能になります。

https://spot.io/products/ocean/

上記公式サイトでも**Serverless Infrastructure Container Engine**と謳っていますが、k8sのノードを構成するクラウドインスタンスはSpot Oceanによって自動管理されるため、**インスタンスやノードグループを作成・管理する必要はありません。**

# Spot Oceanの機能検証（本編）
Spot Oceanは新規・既存のいずれのクラスタにも適用できますが、今回は新規に検証用EKSクラスタを構築します。

EKSのクラスタを新規に構築し、Spot Oceanの管理下に登録するという操作は、ほぼSpotのコンソール上で完結させることができます。
こちらの記事に詳しく構築手順がまとまっていますので良ければご覧ください。

https://qiita.com/seijitanabe/items/00e2051b8ee638aea518

なお、以後の記載では以下のように用語を区別します。
- スケールアップ: 既存のインスタンスのスペックを増強することでk8sクラスタ内のリソース追加すること
- スケールアウト: インスタンス自体を増やすことでk8sクラスタ内のリソース追加すること
- スケールダウン: 既存のインスタンスのスペックを減少させることでk8sクラスタ内のリソース削減すること
- スケールイン: インスタンス自体を減らすことでk8sクラスタ内のリソース削減すること

## 初期状態の確認
まずはEKS構築完了時点のクラスタの状況を確認しておきます。

EKS構築時点ではノードを1台も作成しておりませんでしたが、早速Spot Oceanによって自動で1つのノードが作成されています。
そしてそのノード上にはKubernetesのシステム系のpod(kube-system)が乗っている状態です。
というか、spotのコントローラもkube-system内に配置されるのですね。
```bash
$ kubectl get node
NAME                                               STATUS   ROLES    AGE     VERSION
ip-172-31-47-243.ap-northeast-1.compute.internal   Ready    <none>   4m36s   v1.18.9-eks-d1db3c

$ kubectl get pod -o wide -A
NAMESPACE     NAME                                                      READY   STATUS    RESTARTS   AGE     IP              NODE                                               NOMINATED NODE   READINESS GATES
kube-system   aws-node-gltdq                                            1/1     Running   0          2m8s    172.31.47.243   ip-172-31-47-243.ap-northeast-1.compute.internal   <none>           <none>
kube-system   coredns-5fc8d4cdcf-49g9n                                  1/1     Running   0          11m     172.31.35.160   ip-172-31-47-243.ap-northeast-1.compute.internal   <none>           <none>
kube-system   coredns-5fc8d4cdcf-7jffg                                  1/1     Running   0          11m     172.31.32.166   ip-172-31-47-243.ap-northeast-1.compute.internal   <none>           <none>
kube-system   kube-proxy-6wvk4                                          1/1     Running   0          2m8s    172.31.47.243   ip-172-31-47-243.ap-northeast-1.compute.internal   <none>           <none>
kube-system   spotinst-kubernetes-cluster-controller-5d9796c866-mw8xp   1/1     Running   0          6m27s   172.31.36.247   ip-172-31-47-243.ap-northeast-1.compute.internal   <none>           <none>
```

はじめに払い出されたノードのインタンスタイプは**c5.large**でした(2vCPU/4GiB)
![スクリーンショット 2023-01-17 21.27.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/436161/55faa058-2f8a-8c5e-0669-99ea4b414662.png)


coredns等のkube-system系のpodやSpotのコントローラ等が稼働するノードはどうしても必要になるので、**c5.large * 1ノード**がSpot Oceanにおける最小構成ということになるのでしょう。

## ①Podの作成 → 自動スケールアウト

つぎに現状のk8sクラスタの限界を超えるワークロードを作成し、Spot Oceanが自動でk8sノード(Spotインスタンス)を追加する様子を観察します。

現状払い出されているノードにおいて、k8sが使えるリソース分(Allocatable)としては1930ミリコアです。
```
$ kubectl describe node ip-172-31-47-243.ap-northeast-1.compute.internal | grep -A 7
Allocatable:
  attachable-volumes-aws-ebs:  25
  cpu:                         1930m # k8sが使えるCPUリソースの量（1コア=1000ミリコア）
  ephemeral-storage:           18242267924
  hugepages-1Gi:               0
  hugepages-2Mi:               0
  memory:                      3131976Ki
  pods:                        29
```

つまり以下のスペックを持つpodは現状スケジューリングできないことになります。
```yaml
apiVersion: v1
kind: Pod
metadata:
  name: nginx-2
spec:
  containers:
  - name: nginx
    image: nginx:latest
    resources:
      requests:
        cpu: 2000m # ノードのキャパシティを超えるリソース要求
```

実際にこのpodを作成するとk8sのスケジューラが払出し先となるノードを見つけることができず、podは一時的に**Pending**のステータスとなりました。
```
$ kubectl get pod nginx-2
NAME      READY   STATUS    RESTARTS   AGE
nginx-2   0/1     Pending   0          11s

$ kubectl describe pod nginx-2 | grep Events: -A 5
Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  7m3s  default-scheduler  0/1 nodes are available: 1 Insufficient cpu. preemption: 0/1 nodes are available: 1 No preemption victims found for incoming pod.
```

しかしそのまま何もせずに数分待っていると、podは**Running**へと遷移しました。
```
$ kubectl get pod nginx-2
NAME      READY   STATUS    RESTARTS   AGE
nginx-2   1/1     Running   0          3m

$ kubectl describe pod nginx-2 | grep Events: -A 5
Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  7m3s  default-scheduler  0/1 nodes are available: 1 Insufficient cpu. preemption: 0/1 nodes are available: 1 No preemption victims found for incoming pod.
  Normal   Scheduled         5m8s  default-scheduler  Successfully assigned default/nginx-2 to ip-172-31-35-204.ap-northeast-1.compute.internal
  Normal   Pulling           5m8s  kubelet            Pulling image "nginx:latest"
```

Spot Oceanによってノードが自動追加されたことでクラスタのキャパシティが増え、podを払い出すことができた様です。
イベントログから、以下の一連のフローは約2分間の間に行われたようです。

1. podのスケジューリング(リソース不足で失敗)
1. Spot Oceanがスケジュール不可のpodを検知
1. 追加インスタンスの払い出し
1. EKSクラスタへのノード追加
1. podの再スケジューリング(成功)

ちなみに今回追加されたノードのインスタンスタイプは**c5."x"large**でした(4vCPU/8GiB)
![スクリーンショット 2023-01-17 21.43.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/436161/81b3bf4a-b899-9739-8a82-3af7fbc6beee.png)


## ②自動スケールイン（リソース配置の最適化）
そのままk8sクラスタの状況を観察していると、当初c5.largeのノードで稼働していたはずのpodが、先ほど追加されたc5."x"largeのノードへとどんどん移動していきます。
(c5.largeのノードから削除 → c5."x"largeのノード上に再作成)

```
$ kubectl get pod -A -o wide
NAMESPACE     NAME                                                      READY   STATUS        RESTARTS   AGE     IP              NODE                                               NOMINATED NODE   READINESS GATES
default       nginx-2                                                   1/1     Running       0          10m     172.31.40.158   ip-172-31-35-204.ap-northeast-1.compute.internal   <none>           <none>
kube-system   aws-node-gfh6r                                            1/1     Running       0          9m12s   172.31.35.204   ip-172-31-35-204.ap-northeast-1.compute.internal   <none>           <none>
kube-system   aws-node-gltdq                                            1/1     Running       0          24m     172.31.47.243   ip-172-31-47-243.ap-northeast-1.compute.internal   <none>           <none>
kube-system   coredns-5fc8d4cdcf-7jffg                                  0/1     Terminating   0          34m     172.31.32.166   ip-172-31-47-243.ap-northeast-1.compute.internal   <none>           <none>
kube-system   coredns-5fc8d4cdcf-sm4qj                                  0/1     Running       0          7s      172.31.39.80    ip-172-31-35-204.ap-northeast-1.compute.internal   <none>           <none>
kube-system   coredns-5fc8d4cdcf-tjrp6                                  1/1     Running       0          47s     172.31.41.56    ip-172-31-35-204.ap-northeast-1.compute.internal   <none>           <none>
kube-system   kube-proxy-6wvk4                                          1/1     Running       0          24m     172.31.47.243   ip-172-31-47-243.ap-northeast-1.compute.internal   <none>           <none>
kube-system   kube-proxy-h5v8n                                          1/1     Running       0          9m12s   172.31.35.204   ip-172-31-35-204.ap-northeast-1.compute.internal   <none>           <none>
kube-system   spotinst-kubernetes-cluster-controller-5d9796c866-mw8xp   0/1     Terminating   0          29m     172.31.36.247   ip-172-31-47-243.ap-northeast-1.compute.internal   <none>           <none>
kube-system   spotinst-kubernetes-cluster-controller-5d9796c866-t97mx   1/1     Running       0          87s     172.31.32.220   ip-172-31-35-204.ap-northeast-1.compute.internal   <none>           <none>
```

眺めている間に全てのpodが移動完了し、空っぽになったc5.largeのノードならびにインスタンスは削除されました。

![スクリーンショット 2023-01-17 21.45.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/436161/1f6254a1-b370-a034-60a9-fa16ea98ccd6.png)

どうやら先ほど追加された**c5.xlargeのインスタンスだけでクラスタ内の全てのワークロードが賄えるとSpotが判断し、不要なc5.largeのインスタンス料金を削減**しにかかった様です。

Spotコンソール上のログからも上記の挙動が伺えます。
![スクリーンショット 2023-01-17 23.22.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/436161/01d11595-cf14-ff22-c020-43a2bace6481.png)

ここまでの流れをまとめると以下の様になります。

```
podのスケジューリング(リソース不足で失敗)
↓
c5."x"largeのインスタンスを追加（スケールアウト）
↓
podの再スケジューリング（c5."x"large上で起動）
↓
c5.largeのインスタンス上で稼働していた既存のpod群を、c5."x"largeのインスタンスへ移行
↓
c5.largeのインスタンスを削除（スケールイン）
```

スケールアウトとリソース配置の最適化をシームレスに実行したことで、**擬似的にではありますがSpot Oceanの機能によって1つのノードをスケールアップした**、という見方もできそうです。

## ③podの削除　→　スケールダウン
①で作成したpodを削除してみます。
つまりEKS構築時点のリソース状況に戻ることになるので、インスタンス構成も初期状態(c5.large * 1)に自動で戻るのでしょうか？
```
$ kubectl delete pod nginx-2
pod "nginx-2" deleted
```

結果は、c"3".largeと初期状態とはまた別のインスタンスに移行される形でインスタンスがスケールダウンされました。

![スクリーンショット 2023-01-18 17.23.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/436161/fc171c26-9c4e-741e-15df-382414e6e436.png)

Spotコンソールでのログはこんな感じです。
**Revert to lower cost node(安価なノードへの切り戻し)** というイベントが記録されています。
![スクリーンショット 2023-01-18 17.39.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/436161/de367409-9f00-5f77-df32-8d4472e4a30d.png)

この際のkubernetes側のログを取ることを失念してしまったのですが、以下の流れでスケールダウンが実施されたものと思われます。

```
podの削除
↓
c3.largeのインスタンスを追加（スケールアウト）
↓
c5."x"largeのインスタンス上で稼働していた既存のpod群を、c3.largeのインスタンスへ移行
↓
c5."x"largeのインスタンスを削除（スケールイン）
```

# 公式ドキュメントを読んでみる
https://docs.spot.io/ocean/features/scaling-kubernetes

実機を使った検証としては以上ですが公式ドキュメントにk8sノードのスケーリングに関する仕様がセクションがあったので、こちらを読んで理解を深めます（いまさら）

## スケールアップ(スケールアウト)について
![features-scaling-k8s-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/436161/4bcf995d-9c45-6895-6b0c-8b1366c84634.png)

- スケジューリングできないpodを検知し、自動でノード追加を行う（上記で確認した通り）
- 追加するノードはスケジューリング不可なpodの合計リソースを計算し、最適なインスタンスタイプを選択する
- インスタンス追加はリソース不足だけでなくアンチアフィニティ等によってもトリガーされる
    - 例として、podAntiAffinityによって3つのレプリカを別々のアベイラビリティゾーンに分散するようなスペックを持つDeploymentを作成した場合、Spot Oceanによって3つのノードが自動追加される

## スケールダウンについて
![features-scaling-k8s-02b.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/436161/d63dee32-0b15-2d12-7b5b-eb76dadcdfd4.png)

- リソース効率の低いノードをプロアクティブに検出し、インスタンスを削除できるかどうかのシミュレートを行う
    - スケールダウンできるかどうかのシミュレートにはPodDisruptionBudget(レプリカの同時停止許容数)が考慮される
    - Spot Oceanが持つ"maxScaleDownPercentage"というパラメータで一度に削除できるノードの割合を制御できる
- シミュレートで削除可能と判断されたノードに対してdrain操作が行われる（新規podを該当ノードへスケジューリングされない様にしつつ、起動済みpodを他のノードに逃す）
    - ノードからのpodのEvictionは120秒間の間に分散される
        - 例えば削除対象のノードに10pod稼働していた場合、12秒間に1podずつEvict(もとのノードからpod削除)される
        - Evictのリトライタイムアウトは1分。それを超えた場合、Podは別のノードに移行されることなく強制削除される。
- 重要なワークロードが自動削除されることを防止するために特定の方法でスケールダウンを抑止することができる
    - Podに特定のラベルをつける(spotinst.io/restrict-scale-down:true)
        - このラベルがついているpodが乗っているインスタンスは削除されなくなる
        - 上記のSpot独自のラベル以外にCluster Autoscalerのラベル(cluster-autoscaler.kubernetes.io/safe-to-evict: false)をつけても同じ動作となる
    - 仮想ノードグループに対して"Restrict Scale Down"を設定
        - SpotコンソールまたはAPIで設定
        - 設定した仮想ノードグループ(=特定のインスタンスの集合)ではスケールダウンが無効になる

# おまけ：今回の検証環境におけるコスト削減効果
Spot Ocean含めSpot by NetAppの１番のバリューは**インスタンスコストの節約**ですので、今回の環境でどれだけコスト削減されたのかも確認しておきます。

![スクリーンショット 2023-01-18 17.12.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/436161/bcfc3ea4-ad0b-b3d4-0c38-8e271dbf6cef.png)
68.95%のコスト節約！ほぼ1/3！

:::note warn
今回の検証では半日程度のインスタンス稼働かつ、検証用ワークロードしかデプロイしておりませんので、あくまで参考情報となります。
:::

# まとめ
Spot Oceanにははじめて触れましたが、インスタンスを管理しなくて良い&料金の節約までしてくれる、というのは楽でいいですね。

一方でコストの最適化を図るために、インスタンスやその上で稼働しているワークロード(pod等)がかなりダイナミックに動く(Spot Oceanが動かしている)のも印象的でした。
本番系かつステートフルなワークロードを扱うクラスタでは**特にスケールダウンの仕様や制御方法について学ぶ必要がある**と感じます。

その辺りはまた時間をみつけて検証してみたいと思います。