EC2 AutoScalingを使ったGraceful Shutdownを考える

# はじめに
筆者がEC2 AutoScalingを含むAWS環境の運用を行う中で理解した内容を書き起こしています。
EC2のシャットダウンについて、AWSのサービスから各種ソフトウェアまで横断的に説明された記事があまり見つからなかったので、この記事が誰かの助けになればと思います。

# 前提知識
## EC2 AutoScalingとは?
EC2 AutoScalingはEC2のスケールイン/スケールアウトを担うサービスです。
設定したスケジュールベースのスケーリングや各種条件を踏まえた動的なスケーリングなど様々なタイプのスケーリングに対応しています。

また、**LifeCycleHook**と呼ばれるEC2 AutoScalingの機能を利用してスケールイン/スケールアウト時の前処理をキックすることも可能です。
実装例はこの記事に記載しますが、LifeCycleHookに関する説明は下記のリンク先に記載されています。
https://docs.aws.amazon.com/ja_jp/autoscaling/ec2/userguide/lifecycle-hooks.html

## Graceful Shutdownとは?
サービスなどのシャットダウンにおいて**「決められた手順に従って正常にサービス (当記事ではEC2インスタンス) を終了させること」**を指しています。
Graceful Shutdownを実現するために、サービスによっては「特定のAPIリクエストを受け付けると新規リクエストの受付を停止する」などの補助機能を用意しているものもあります。

# なぜGraceful Shutdownが重要なのか?
ここからはクラウド環境(非コンテナ環境)においてサービスを運用している想定で話を進めていきます。
自分が運用に携わった環境では、サーバ(EC2)の運用にあたって元々下記のような要望がありました。

- ログ管理
    - スケールインの際に、アプリやOS周りのログを退避させたい
- 監視
    - スケールインで消えたサーバは監視の対象外にしたい
    - [DatadogのAutomuting](https://docs.datadoghq.com/ja/integrations/amazon_ec2/) のような削除されたインスタンスを自動的に監視対象外にすることができないので、スケールイン時に手動で監視対象から除外したい
- 構成管理
    - 各種設定ファイルの変更時に、変更前の状態をバックアップとして保管したい

<br>
この環境ではEC2 AutoScalingを利用していましたが、「インスタンスがいつ消えてもいい」前提で上記のような運用設計を行う必要がありました。
つまり、スケールイン時にインスタンスをただ削除するのではなく、**必要な事前処理を全て実行した上でEC2インスタンスを削除する**ことが求められていました。

構成管理に関しては、構成変更時にEC2 AutoScalingのベースとなるAMIを毎回取得することで対応できるため、ログ管理・監視について考える必要がありました。

ログ管理・監視について上記の要件を満たすために、この環境では下記のようなAWSサービスとソフトウェアを利用しました。

- AWSサービス
    - EC2 
    - EC2 AutoScaling
    - SQS
- Fluentd
- Zabbix

# EC2 AutoScaling LifeCycleHookを利用した処理フロー
上記の内容を実現するために、EC2 AutoScalingのLifeCycleHookをメインで利用します。

まず、参考としてLifeCycleHook利用時のスケールアウトのフローを下記に記載します。
(https://docs.aws.amazon.com/ja_jp/autoscaling/ec2/userguide/lifecycle-hooks.html)

1. (EC2 AutoScaling) スケールアウトのイベントが発生
2. (EC2 AutoScaling LifeCycleHook) インスタンスを待機状態(`Terminating:Wait`)にする
    - SQS内のメッセージを確認する
3. カスタムアクションの実行
    - 通知ターゲットに対してメッセージの送信
    - CloudWatchイベントを利用したLambda関数の呼び出し
4. (EC2 AutoScaling LifeCycleHook) ライフサイクルアクションを完了させて待機状態を解除する
5. (EC2 AutoScaling) インスタンスをスケールアウトする

<br>
これをベースに、スクリプトを用いて下記のような処理フローでの対応を試みました。

1. (EC2 AutoScaling) スケールアウトのイベントが発生
2. (EC2 AutoScaling LifeCycleHook) インスタンスを待機状態(`Terminating:Wait`)にする
3. (EC2 AutoScaling) カスタムアクションの実行
    - **(EC2 AutoScaling) 通知ターゲット(SQS)に対してメッセージの送信**
    - **(EC2) SQSにメッセージを見に行くスクリプト(A)をcronjobを用いて定期的に実行させる**
        - **メッセージ内のホスト名が一致する場合のみ必要な事前処理を実施する**
4. (EC2 AutoScaling LifeCycleHook) ライフサイクルアクションを完了させて待機状態を解除する
    - **(EC2) ライフサイクルアクションを完了させるコマンドをスクリプト(A)内で実行する**
5. (EC2 AutoScaling) インスタンスをスケールアウトする


## カスタムアクションの実行
上記の処理フローの具体的な内容を確認していきます。

上記のフローの前提として、EC2 AutoScalingにLifeCycleHookを作成する必要があります。
通知先のターゲットはGUIから設定できないため、AWS CLIを利用して作成します。**(0)**

```
aws autoscaling put-lifecycle-hook \
　--lifecycle-hook-name <LifecycleHookName> \
　--auto-scaling-group-name <AutoScalingGroupName> \
　--lifecycle-transition autoscaling:EC2_INSTANCE_TERMINATING \
　--notification-target-arn arn:aws:sqs:<RegionName>:<AccountId>:<SqsName> \
　--role-arn arn:aws:iam::<AccountId>:role/<RoleName>
```

LifeCycleHookを設定した上で、上記フローの流れを確認していきます。
EC2 AutoScalingによってインスタンスのスケールアウトのイベントが発生する **(1)** と、LifeCycleHookによってインスタンスが待機状態(`Terminating:Wait`)になります。**(2)**

`Terminating:Wait`の状態になったタイミングで、**(0)**で指定した通知ターゲット(SQS)に対してメッセージを送信します。
メッセージの中には、下記のようなメタデータが含まれています。

- `EC2InstanceId`：削除対象のEC2インスタンスのID
- `LifecycleTransition`：ライフサイクルフックをアタッチするEC2インスタンスの状態
- `AutoScalingGroupName`：EC2 AutoScalingのグループ名

```json
{
    "Messages": [
        {
            "Body": "{\"LifecycleHookName\":\"<LifecycleHookName>\",\"AccountId\":\"<AccountId>\",\"RequestId\":\"8e5ccd3a-...2bbd\",\"LifecycleTransition\":\"autoscaling:EC2_INSTANCE_TERMINATING\",\"AutoScalingGroupName\":\"<AutoScalingGroupName>\",\"Service\":\"AWS Auto Scaling\",\"Time\":\"2020-01-21T05:02:07.003Z\",\"EC2InstanceId\":\"<EC2InstanceId>\",\"LifecycleActionToken\":\"<ActionToken>\"}",
            "ReceiptHandle": "<ReceiptHandle>",
            "MD5OfBody": "<MD5OfBody>",
            "MessageId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
        }
    ]
}
```

一方、インスタンス側では、cronjobを用いて定期的にスクリプトを実行します。
最初に、SQSのキュー内のメッセージの有無を確認します。

```
aws sqs receive-message \
　--queue-url https://sqs.<RegionName>.amazonaws.com/<AccountId>/<LifecycleHookName> \
　--region <RegionName>
```

メッセージから各種メタデータを取り出して`EC2InstanceId`を確認し、自分自身が削除対象である場合に事前処理を実行します。
事前処理の中では下記のような内容を実行します。

- 各種エージェントの停止
    - Fluentd：シグナル(`SIGUSR1`)の送信によるエージェントの停止
    - Zabbix：`systemctl`コマンドによるエージェントの停止
- 監視用の設定の削除を行うスクリプト(B)を実行

```
# 各種エージェントの停止
kill -SIGUSR1 $(cat /var/run/td-agent/td-agent.pid)
systemctl stop zabbix-agent

# 監視設定の削除を行うスクリプト(B)を実行
/usr/local/bin/shell/delete_host_conf.sh 
```

## ログ管理
Fluentdでは、各EC2インスタンスからログ保管用のサーバにログを送信するために、`out_forward`プラグインを利用しています。
(実際には、`Input`・`Filter`プラグインを利用していますが長くなるため省略しています。)

```
## Output
<match <tag_pattern> >
   type forward
   heartbeat_type tcp

   flush_at_shutdown true
   flush_interval 60s
   buffer_chunk_limit 256m

   <server>
      name logserver1
      host 192.168.74.150
      port 24224
   </server>
   <server>
      name logserver2
      host 192.168.74.151
      port 24224
      standby
   </server>
</match>
```

詳細な説明は省きますが、FluentdのSupervisor Processに対して`SIGUSR1`のシグナルを送信することで、バッファ内のchunkを強制的にFlushし、転送先のサーバに送るようにします。
（参考：https://docs.fluentd.org/deployment/signals）

## 監視
この環境では監視ツールとしてZabbixを利用しています。
`/usr/local/bin/shell/delete_host_conf.sh `に関する詳細については割愛しますが、中身としてはZabbix APIを利用して下記のような流れでホスト定義を削除します。

- ホスト名でフィルターしてホストIDを取得する(`host.get`)
- ホストIDを指定してホスト定義削除のリクエストを送る(`host.delete`)

バージョンごとの差異は別として、公式ドキュメントを確認することで大まかなAPIの利用方法は理解できるかと思います。
https://www.zabbix.com/documentation/2.2/manual/api

## LifeCycleHookのステータス更新
次に、メッセージから取得した`ReceiptHandle`を変数`$MSG_Receipt`に詰めて、SQS内の指定したメッセージを削除します。

メッセージIDではなく、メッセージハンドルを指定して削除する点は注意が必要です。
参考：https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_DeleteMessage.html

```
aws sqs delete-message \
　--queue-url https://sqs.<RegionName>.amazonaws.com/<AccountId>/<LifecycleHookName> \ 
　--region <RegionName> --receipt-handle $MSG_Receipt
```

最後に、メッセージから取得したメタデータを基に、各種処理が完了したことをAutoScalingに知らせる`complete-lifecycle-action`コマンドを実行します。**(4)**

```
aws autoscaling complete-lifecycle-action \
　--lifecycle-hook-name $LH_Name \
　--auto-scaling-group-name $ASG_Name \ 
　--instance-id $InsId \
　--lifecycle-action-token $MSG_LH_Token \
　--lifecycle-action-result CONTINUE
```

これにより、インスタンスの待機状態が解除されて削除可能な状態(`Termination:Proceed`)になり、インスタンスが削除されます。**(5)**

# 最後に
EC2 AutoScalingと各種サービスを利用したシャットダウン処理についてまとめました。
個人的にはFluentdの機能やオプションの多さに感謝しつつ、まだまだ使いこなせていないと痛感しました。( 勉強せねば... )

# 参考
**EC2 AutoScaling - Lifecyclehook**

- https://docs.aws.amazon.com/ja_jp/autoscaling/ec2/userguide/lifecycle-hooks.html
- https://dev.classmethod.jp/cloud/aws/autoscaling-lifecyclehook/

**Fluentd**

- https://docs.fluentd.org/deployment/signals
- https://docs.fluentd.org/output/forward
- https://abicky.net/2017/10/23/110103/
- https://www.sambaiz.net/article/51/