SpotFleetでスケールイン対象のインスタンスをコントロールする方法と注意点

AutoScalingはターゲットの値に応じて適切にインスタンス数を増やしたり減らしたりといい感じにしてくれるいいやつだが、減らす時にどのインスタンスを終了するかを明示的に指定できないところが困りもの。

画像解析などの処理を行うインスタンスをスポットを用いて並列で行なっている場合、解析が終了したインスタンスから順次眠っていただければいいのですが、スケールインはその対象を決められないのでまだ解析に勤しんでいる子が終了してしまうかもしれない、、、これでは落ち着いて夜も眠れない。

そこで、スケールインの対象をコントロールする方法を検討した。

# TL;DR

スケールアウトは、ポリシートリガに基づいたTargetCapacityの変更を設定。
スケールインは、EC2内から`modify-spot-fleet-request`コマンドを叩きTargetCapacityを明示的に減らし、その際`--excess-capacity-termination-policy`オプションで`NoTermination`を指定する。最後に、EC2自身がterminateする。
ただし、起動後すぐにterminateするとAutoScalingが2度働くっぽい。

# 本編

## オンデマンドインスタンスのAutoScaling

通常のオンデマンドのAutoscalingでスケールイン時に任意のインスタンスを終了させたいならば`Instance Protection`を使う方法がある。

公式参照
https://docs.aws.amazon.com/ja_jp/autoscaling/ec2/userguide/as-instance-termination.html

__STEP 1__
全てのインスタンスが生成時に自身にprotectionを指定しておくことで、仮にスケールインが起こり、希望する容量が減っても実際のインスタンス数は減らされないという状態を実現できる。

__STEP 2__
その後で、終了させたいインスタンスのprotectionを解くとそいつが終了する対象になる。

## スポットインスタンスのAutoScailing

しかしながら、SpotFleetはその特性上、protect terminateを指定できない。
スポットインスタンスなんてそもそもいつ落ちるかわからないのだからプロテクトしてもしょうがないでしょということでないのだろう。

そこで、Spotの場合には`modify-spot-fleet-request`コマンドを用いてオンデマンドインスタンスの時と同等なステップで対象のインスタンスだけをスケールインさせることを実現する。

__STEP 1__
上記のコマンドでスケールインを明示的に行ない、その際、`--excess-capacity-termination-policy`オプションで`NoTermination`を指定する。

こうすることで、希望する容量は減らせるが、実際に稼働中のインスタンスはその数値に合わせてterminateしないことを指定できる。

__STEP 2__
これで、勝手に終了することは防げたので、あとは、指定したいインスタンスを終了するコマンドを実行してあげれば、最終的に任意のインスタンスを終了させることができる。

# 実際にやってみた。

ここではSQSキューをトリガとしてインスタンスをスケールアウトさせ、解析EC2が仕事を終え次第、各インスタンスが自身でspotfleetの容量を減らしたのち終了するようにしている。

仕事の長さはSQSメッセージに書かれた秒数sleepすることで模している。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406130/33460667-6b1a-02dc-4e8d-5a39696a9803.png)

## 準備

まずは`aws configure`書いて、必要なものをインストール

```
$ aws configure
$ sudo yum install -y jq
```

次にec2が起動すると同時に次のスクリプトが走るようにする。

```shell:test.sh
#!/bin/sh

INSTANCE_ID=`curl http://169.254.169.254/latest/meta-data/instance-id/`

# メッセージを取得
MESSAGE=`aws sqs receive-message --queue-url https://sqs.ap-northeast-1.amazonaws.com/xxxxxxxxxxxx/queue_name | jq '.Messages[]'`
ReceiptHandle=`echo ${MESSAGE} | jq '.ReceiptHandle' | sed 's/"*"//g'`
BODY=`echo ${MESSAGE} | jq '.Body' | sed -e 's/[^0-9]//g'`

SFR_ID=`aws ssm get-parameters --names "request_id" | jq '.Parameters[].Value' | sed 's/"*"//g'`

echo ${MESSAGE}
echo ${BODY}
echo ${SFR_ID}
echo "---"

# 指時間だけ処理待ち
sleep ${BODY}

# ターゲット容量の取得とデクリメント
TARGET_CAPACITY=`aws ec2 describe-spot-fleet-requests --spot-fleet-request-id ${SFR_ID} | jq '.SpotFleetRequestConfigs[].SpotFleetRequestConfig.TargetCapacity'`
echo ${TARGET_CAPACITY}
DESIRED_CAPACITY=`expr $TARGET_CAPACITY - 1`
echo ${DESIRED_CAPACITY}

# ターゲット容量の反映
SCALE_IN_RESULT=`aws ec2 modify-spot-fleet-request --spot-fleet-request-id ${SFR_ID} --target-capacity ${DESIRED_CAPACITY} --excess-capacity-termination-policy NoTermination | jq '.Return'`

# メッセージ削除とterminate
aws sqs delete-message --queue-url https://sqs.ap-northeast-1.amazonaws.com/xxxxxxxxxxxx/queue_name --receipt-handle ${ReceiptHandle}
aws ec2 terminate-instances --instance-ids ${INSTANCE_ID}
```

このシェルを起動時にec2-userで実行してもらうために以下の設定。
参考）https://qiita.com/teradonburi/items/d56fba81b8f6402f4b88

```
$ cd /etc/init.d/
$ sudo touch myservice
$ sudo vi myservice
$ sudo chmod 775 myservice
$ sudo chkconfig --add myservice
$ sudo chkconfig myservice on
```

```shell:myservice
#!/bin/sh
# chkconfig: 2345 99 10
# description: start shell
case "$1" in
 start)
       su -l ec2-user -c "sh /home/ec2-user/test.sh"
       ;;
 stop)
       ;;
  *) break ;;
esac
```

次に、SpotFleetのリクエストを作成し、SQSをメトリクスとしたAutoScalingを設定。
参考）https://qiita.com/Kept1994/items/8f32f3448b3ec745f464

* なお、テストのためSQSの可視性タイムアウトは10minを指定しておく。ここは、対象の処理の長さに応じて変更する必要あり。最大12時間まで。
* メッセージの受信回数は1回とし、その後はdeadletterキューに放り込むことにしておく。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406130/242ef7b5-d4b2-5b43-2995-0ed90077c6d4.png)

最後にパラメータッストアに、リクエストIDを格納して、EC2から参照できるようにする。

|key|value|
|:---:|:---:|
|request_id|sfr-92c80ef-xxxx|


## テスト

SQSコンソールからキューに3通のメッセージを送る。
テキストは、20、180、360で、それぞれ20秒、3分、6分かかる仕事となる。
shellでは、メッセージを受け取り、指定時間sleepしたのちにメッセージをdelete。Target容量をデクリメントして自身のインスタンスを終了するという処理である。

狙い通りスケールインの対象をコントロールできていれば、全ての処理が終了後、インスタンスとメッセージはともに0となる。万が一、スケールインの過程で初めにterminateするはずの20秒のインスタンスではなく別のインスタンスをterminateしてしまった場合、180あるいは360のメッセージはdeleteされず、10minの可視性タイムアウトを抜けて再びVisibleになり、メッセージが残る点で判別できると考えた。

この時、SpotFleetの容量は`0of0`（5分ごとにAutoSCalingが働くのでしばらく待つ。）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406130/da97edda-c720-337b-ffe5-a55cd7c7407e.png)

数分後、1つ目がなぜかすぐに立ち上がり、早くもメッセージを処理。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406130/f7c1d7bb-3a75-270e-bbf9-50a1eb4d653d.png)

その数十秒後、残る2つのインスタンスも立ち上がる。入れ替わりで1つ目は仕事を終了し、退勤。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406130/7706bc3c-5e1f-411f-a8bb-1227d03aacda.png)

ここで異変が。。なぜか、希望する容量が4とか5になる瞬間が現れる、、、
EC2のコンソールで確認するとやはりインスタンスが立ち上がろうとしている。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406130/a6a08106-7f63-5a40-9e2d-473ef577ee03.png)

とはいえ、これらのインスタンスは処理の対象がないのですぐさまterminate。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406130/8b827738-4d7c-6b93-7fcc-2b3c8f87af6d.png)

この挙動は何回か繰り返しているらしい。。また立ち上がった。
もともと立っていた3つのインスタンスはちゃんと仕事を終えて終了している。
当初懸念していた別インスタンスをスケールインさせてしまう問題は確かに解決できているが、不要なインスタンスがポコポコ湧いてきてしまう問題に直面、、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406130/4bf9a3eb-2e41-3b64-d3d0-26d7cd735e03.png)

その後、しばらく待っていると、インスタンスが立っては容量が減り、立っては容量が減り、、となり、最終的に`0of0`に落ち着いた。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406130/5b94f2e7-ffec-d41e-4b9d-a95ae213d7fc.png)

結局3通のメッセージの処理に4つものインスタンスが余分に立ち上がっては消えてをしていた。

## 考察

__何が起きているのか__
本来クールダウン期間を5minに指定しているため、スケールアウトした直後にインスタンスがさらに増えるといったことはないと思われる。しかし、実際のインスタンスの推移としては、次のようになっていた。

インスタンス数の推移
0→1→3→5→4→３→2→1→0

追加で行ったテストから以下のことも分かった。

* AutoScalingのMAX値を8にしたところ、2回目のスパイクでTarget容量は6になった。
* クールダウンを10minに延長してみても挙動が同じ
* 30sのメッセージを1つ送った際には、0→1→0→1→0と推移している。
* 120sのメッセージを1つ送った際は0→1→0と正常に推移した。（←ここから推測するに終了までの時間が関係している？→正解でした。）

__なぜ起きたのか__
> インスタンスの終了が早すぎた！

新たにメッセージの時間を120s、180s、240sの3通として、テストを行ったところ、問題なく0→3→2→1→0の挙動を辿った。

今回のことから以下のことが推測できる。

* 少なくとも起動から30秒以内にインスタンスが終了するとAutoScalingにより再度Target容量が確保されると考えられる。（これはクールダウンの時間に依存しない。）
* これを回避するには起動から少なくとも120秒以上経過してからインスタンスを終了する必要がある。

なお、いずれもテストした時間から言える結論なので、正確な時間のボーダーは不明。
ただ、そもそもスポットインスタンスの利用目的が、長時間の解析処理などであることを考えると、さほど重要なことではないのかもしれない。
考える必要があるとすれば、対象の処理がエラーした時にインスタンスをすぐに終了させてはいけないといったところだろうか。