Application Load Balancer と Lambda と CloudWatch Alarm を使って ECS Fargate 上のコンテナを動的に起動・停止してランニングコストを最小化する

# はじめに
たまに（かつ、急に）舞い込んでくるデモ機会のために、過去に作った WEB サービスを動かしておきたいけど、動かしっぱなしにしておくと無駄なランニングコストがかかってしまいもったいない、というニッチな課題を解決してみます。

# 方針
「必要なときにだけ」「特別な手順無しで」WEB サービスを立ち上げる方法[^1]を考えます。
課題解決の方針は以下の図の通りです。
![ecs_state.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336603/09cb8c2b-380a-4269-fa4d-fb47ded53ffb.png)
ざっくり書き下すと・・・

- デモしたい WEB サービスは ECS Fargate 上のコンテナで動かす。
- WEB サービスへのリクエストを Application Load Balancer (ALB) で受けて、コンテナが起動済みならリクエストをそのまま転送、コンテナが停止済みならコンテナを新たに起動する。
    - ALB のリスナールールに以下2つのターゲットを登録
        - target-1: コンテナにリクエストを転送
        - target-2: コンテナを起動するための Lambda 関数（sample-service-start）をコール
    - コンテナの状態に応じてターゲットの重みを変更し、選択されるターゲットを制御
        - コンテナ起動済み → target-1 を選択（target-1 の重み = 1, target-2 の重み = 0）
        - コンテナ停止済み → target-2 を選択（target-1 の重み = 0, target-2 の重み = 1）
- WEB サービスへのリクエストが一定時間無ければコンテナを停止する。
    - CloudWatch Alarm で両ターゲットへのリクエストを監視
    - リクエストが一定時間無かった場合には、コンテナを停止するための Lambda 関数（sample-service-stop）をコール

[^1]: [Lambda 関数におけるコールドスタート](https://qiita.com/ny7760/items/700ae917da2c5b5e3f8a)的なものを ECS Fargate 上の自前コンテナで実現するイメージでした。

# 実装のポイント
本記事では各サービスの具体的な設定については触れませんが、ポイントになる Lambda 関数2つと CloudWatch Alarm 周りの設定のみ、以下でまとめておきます。

## 前準備
まず前準備として、2つのターゲットグループ（コンテナにリクエストを転送するための target-1 と、起動用 Lambda 関数をコールするための target-2）を作成して、ALB のリスナールールに登録しておきます。これらの「重み」を、コンテナを起動・停止するための Lambda 関数で変更することで、コンテナの状態に応じて ALB で選択されるターゲットを制御できます。
![スクリーンショット 2021-05-05 9.57.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336603/c6c89f15-7d99-b894-30d0-6f648d3e1905.png)

## コンテナの起動・停止と ALB リスナールールの設定変更を行うための Lambda 関数
Python3.8 で書くとそれぞれ以下のような感じです。
また、これらの関数の実行には、デフォルトで付与されるログ出力関連の権限に加えて、以下の権限も必要になるので、適宜ポリシーを設定します。

- ecs:DescribeServices
- ecs:UpdateService
- elasticloadbalancing:Describe*
- elasticloadbalancing:ModifyRule

### 起動用 Lambda 関数（sample-service-start）
「コンテナの起動」と「コンテナにリクエストを転送するためのターゲットグループの重み変更」を行う関数です。

```python:sample-service-start
import boto3
from botocore.exceptions import ClientError

def lambda_handler(event, context):
    response = {
		"statusCode": 200,
		"statusDescription": "200 OK",
		"isBase64Encoded": False,
		"headers": {
			"Content-Type": "application/json; charset=utf-8"
    	}
	}

    try:
        # sample-serviceを起動 (ECSのタスク数に1を設定)
        client = boto3.client('ecs')
        cluster_name = 'mycluster'
        service_name = 'sample-service'
        ecs_response = client.update_service(
            cluster = cluster_name,
            service = service_name,
            desiredCount = 1
        )
        print(ecs_response)

        # ALBリスナールールを更新
        client = boto3.client('elbv2')
        alb_response = client.modify_rule(
            RuleArn='ALBリスナールールのARN',
            Actions=[{
                'Type': 'forward',
                'ForwardConfig': {
                    'TargetGroups': [
                        {
                            'TargetGroupArn': 'target-1のARN',
                            'Weight': 1
                        },
                        {
                            'TargetGroupArn': 'target-2のARN',
                            'Weight': 0
                        }
                    ]
                }
            }]
        )
        print(alb_response)

        response['body'] = '{"message":"sample-service starting..."}'

    except ClientError as e:
        print("error: %s" % e)
        response['body'] = '{"message":"sample-service start error"}'

    return response
```
このサンプルでは```200 OK```と共に、JSON のメッセージを返却してますが、このあたりは使い方に応じて工夫できると思います。

### 停止用 Lambda 関数（sample-service-stop）
「コンテナの停止」と「起動用 Lambda 関数をコールするためのターゲットグループの重み変更」を行う関数です。

```python:sample-service-stop
import boto3
from botocore.exceptions import ClientError

def lambda_handler(event, context):

    try:
        # sample-serviceを停止 (ECSのタスク数に0を設定)
        client = boto3.client('ecs')
        cluster_name = 'mycluster'
        service_name = 'sample-service'
        ecs_response = client.update_service(
            cluster = cluster_name,
            service = service_name,
            desiredCount = 0
        )
        print(ecs_response)

        # ALBリスナールールを更新
        client = boto3.client('elbv2')
        alb_response = client.modify_rule(
            RuleArn='ALBリスナールールのARN',
            Actions=[{
                'Type': 'forward',
                'ForwardConfig': {
                    'TargetGroups': [
                        {
                            'TargetGroupArn': 'target-1のARN',
                            'Weight': 0
                        },
                        {
                            'TargetGroupArn': 'target-2のARN',
                            'Weight': 1
                        }
                    ]
                }
            }]
        )
        print(alb_response)

    except ClientError as e:
        print("error: %s" % e)
```
こちらは、次で説明する CloudWatch Alarm のイベントを受けてコールされる関数なので、レスポンスは返しません。


## リクエストが一定時間無かった場合に停止用 Lambda 関数をコールするための CloudWatch Alarm
target-1 と target-2 への RequestCount の合計（SUM）が 0 になったら発報するアラームを作成します。
![スクリーンショット 2021-05-05 14.05.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336603/0463c095-2971-8136-a26a-9305526ec1b7.png)
アラームの発報は EventBridge で受け取ります[^2]。EventBridge にルールを設定するために必要な、「ALARM」イベント受信用のイベントパターンは以下の通りです。

```JSON:event_pattern
{
  "source": ["aws.cloudwatch"],
  "detail-type": ["CloudWatch Alarm State Change"],
  "detail": {
    "state": {
      "value": ["ALARM"]
    }
  },
  "resources": ["アラームのARN"]
}
```

CloudWatch Alarm → EventBridge → Lambda とつなぐことで、WEB サービスへのリクエストが一定時間無くなった時点でコンテナを停止できるようになります。

[^2]: 公式ドキュメント（[アラームイベントと EventBridge - Amazon CloudWatch](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/cloudwatch-and-eventbridge.html)）が参考になります。

# おわりに
コンテナの起動に数十秒から数分かかってしまうため、本番運用している WEB サービスに適用するのは難しいですが、デモ用途であれば十分実用的です。CPU/GPU やメモリを大量に消費するサービスなどを動かす必要があってコスト面の心配が大きい場合には、定期的にコンテナの稼働状態を確認したり停止用 Lamda 関数をコールしたりするためのルールを EventBridge に追加しておくとより安心できるかもしれません。

# 参考
- [【節約】ECSを自動起動・自動停止する！【コスト削減】｜HikariBlog](https://hikari-blog.com/ecs-auto-start-and-stop/)
- [検証環境のFargateのタスクを定期停止・定期起動してみた #Fargate | DevelopersIO](https://dev.classmethod.jp/articles/fargate-service-task-schedule-stop-start/)
- [ALBリスナールールとLambdaだけでメンテナンスページへの切替が簡単にできる仕組みを作る | DevelopersIO](https://dev.classmethod.jp/articles/alb-listener-rule-lambda-maintenance-page/)
- [ぼくのかんがえた さいきょうの AWS 向け WordPress 環境を構築してみた | ハックノート](https://hacknote.jp/archives/42360/)