EC2インスタンスの自動起動停止を、より柔軟にしてみた（Lambda）

# はじめに
こんにちは、はやぴー（@HayaP）です。
皆さん、サーバーの節約対策していますか？

クラウドを使うのであれば、なるべくサーバーを
効率的に使いたいものです。

そこで、今回は
EC2インスタンスの自動起動を、より柔軟に実装した
経験を記したいと思います。

## 対象読者
- サーバー代を節約したい。
- サーバー毎に起動、停止時間を柔軟にしたい。

この記事が、クライドネイティブな運用の参考になれればと願っています。

# 概要
## 実現方法
AWS公式記事や、技術ブログを見ていると
複数のやり方がありますが、主に下記だと思います。

- [インスタンススケジューラー（AWS ソリューションライブラリー）](https://aws.amazon.com/jp/solutions/implementations/instance-scheduler-on-aws/)
- System Managerを用いた方法
- Lambdaを用いた方法

今回は、Lambdaを用いて
より柔軟なサーバーの自動起動停止を実現したいと思います。

## 要件
タイトルにもありますが、今回はより柔軟に自動起動停止を実現したいと思います。
具体的には、
- サーバー毎に、1h単位で自動起動時間を設定したい
- 平日と休日でも、起動パターンを変えたい
- サーバー数が、今後増える想定のためスケーラブルに
というのが要件です。

例：
サーバーＡ
起動：08:00
停止：18:00

サーバーＢ
起動：10:00
停止：24:00

サーバーC
起動：(平日のみ)10:00
停止：(平日のみ)18:00

## 問題
開発、運用面を考えると、AWS ソリューションライブラリーが
ベストだと思います。

しかし、要件にあるような柔軟さを実現しようとすると
複数の起動パターンを作る必要があり、最適とは言えません。

## 解決策
そのため、EC2タグと、Lambdaを用いた方法で
よりシンプルに実現する方法を考えました。

かなり雑ですが、構成は下記のとおりです。

1. 毎時、自動起動対象インスタンスを検索するLambdaを起動
1. 対象インスタンスを、SNS,SQSに通知
1. 自動起動/停止を実行するLambdaを、非同期に呼び出す
![auto_shutdown.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478323/013b5b65-2f63-0087-787c-3e8120570f3e.png)

では、具体的な方法を記していきます。

# 詳細
リソース毎に説明をします。順序ではないので、注意してください。

## EventBridge
![auto_shutdown.drawio (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478323/df27a542-7791-8c14-6bc6-1e319f7f5d01.png)

スケジュールを作成します。
毎時起動したいため、
cron 0 * * * ? *
を指定します。

ターゲットは、一つ目のLambda（ターゲット検索）を指定します。

![MicrosoftTeams-image (16).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478323/788643ed-1b6b-c35d-3139-500a5feded75.png)

![MicrosoftTeams-image (17).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478323/30c9f842-fb54-4166-6b9d-4cc1a84dee1d.png)

## SNS
![auto_shutdown.drawio (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478323/66d5fae6-f9f4-e78d-9ed9-6c53a47f68a0.png)

トピックを作成します。タイプはスタンダードにしています。
要件に応じて変更をしてください。
![MicrosoftTeams-image (18).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478323/b49c031a-9a8b-04f7-00ca-61c61ef986d8.png)

次に、サブスクリプションを作成します。
エンドポイントは、後続で作成するSQSキューを指定してください。
![MicrosoftTeams-image (19).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478323/63704fc2-383e-943c-128e-b616c68a782e.png)
## SQS
![auto_shutdown.drawio (4).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478323/527be5d0-4509-dc25-ddb6-54505ef6f567.png)

キューを作成します。
タイプは標準にしています。
要件に応じて変更をしてください。
![MicrosoftTeams-image (20).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478323/b2eae03f-d521-35fd-92af-e1f94a07bd70.png)

次に、Lambdaトリガーを設定します。
後続で作成するLambdaを指定してください。
![MicrosoftTeams-image (21).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478323/05671de0-648d-a708-fd0d-496ba87a4b93.png)

## Lambda
![auto_shutdown.drawio (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478323/b279d8f7-6838-9069-5bea-93b7485a4575.png)

最後に、
２つのLambdaを作成します。

１. ターゲット検索
２. 自動起動/停止実行

設定は、基本的にデフォルトでOKです。要件に応じて変更してください。
下記は、サンプルコードです。

１. ターゲット検索
```getTarget.py
import boto3
import datetime
from datetime import datetime
from zoneinfo import ZoneInfo

REGION = "ap-northeast-1"
TIME_ZONE = "Asia/Tokyo"
TOPIC_ARN = "xxx"

AUTO_START_TAG_KEY = "AutoStart"
AUTO_STOP_TAG_KEY = "AutoStop"

ec2_boto3 = boto3.client('ec2', region_name=REGION)
sns_boto3 = boto3.client('sns')

def get_instance_info(instance_state, tag_key, tag_value):
    return ec2_boto3.describe_instances(Filters=[
        {
            'Name': 'instance-state-name',
            'Values': [instance_state],
        },
        {
            'Name': f'tag:{tag_key}',
            'Values': [tag_value],
        },
    ])


def create_instance_id_list(response):
    instance_id_list = []
    for instance_info in response['Reservations']:
        instance_id_list.append(instance_info["Instances"][0]["InstanceId"])
    return instance_id_list


def publish_sns(message, subject):
    sns_boto3.publish(
        TopicArn=TOPIC_ARN,
        Message=message,
        Subject=subject,
    )

def lambda_handler(event, context):
    try:
        now = datetime.now(ZoneInfo(TIME_ZONE))
        hour = now.strftime("%H")
        # 5->土曜日, 6->日曜日
        if now.weekday() != (5 or 6):
            start_target_list = get_instance_info(
                "stopped", AUTO_START_TAG_KEY, "wd" + hour)
            stop_target_list = get_instance_info(
                "running", AUTO_STOP_TAG_KEY, "wd" + hour)
            if start_target_list["Reservations"]:
                for instance_id in start_target_list["Reservations"][0]["Instances"]:
                    publish_sns(instance_id["InstanceId"], "start")
            if stop_target_list["Reservations"]:
                for instance_id in stop_target_list["Reservations"][0]["Instances"]:
                    publish_sns(instance_id["InstanceId"], "stop")
        start_target_list = get_instance_info(
            "stopped", AUTO_START_TAG_KEY, hour)
        stop_target_list = get_instance_info(
            "running", AUTO_STOP_TAG_KEY, hour)
        if start_target_list["Reservations"]:
            for instance_id in start_target_list["Reservations"][0]["Instances"]:
                publish_sns(instance_id["InstanceId"], "start")
        if stop_target_list["Reservations"]:
            for instance_id in stop_target_list["Reservations"][0]["Instances"]:
                publish_sns(instance_id["InstanceId"], "stop")
        return {
            'statusCode': 200
        }
    except Exception as e:
        print("errorMessage:", e)
        raise Exception
```

```execute.py
import json
import boto3

ec2_boto3 = boto3.client('ec2')
ssm_boto3 = boto3.client('ssm')
lambda_boto3 = boto3.client('lambda')

def parse_event(event):
    instance_id = json.loads(event["Records"][0]["body"])["Message"]
    execution_type = json.loads(event["Records"][0]["body"])["Subject"]
    return instance_id, execution_type

def lambda_handler(event, context):
    try:
        # execution_type = "start" or "stop"
        instance_id, execution_type = parse_event(event)
        print("instance_id:",instance_id,"execution_type:",execution_type)
        if execution_type == "start":
            ec2_boto3.start_instances(InstanceIds=[instance_id])
        elif execution_type == "stop":
            ec2_boto3.stop_instances(InstanceIds=[instance_id])
        else:
            raise
        return {
            'statusCode': 200
        }
    except Exception as e:
        print("errorMessage:",e)
        raise Exception
```

# まとめ
いかがだったでしょうか？

今回は、Lambdaを使用しましたが、
初めに紹介した方法に加え、StepFunctionsでも実装できると思います。

要件と、ケイパビリティによって技術選定をしましょう。
是非、活用してみてください！