サーバごとかつ日ごとに不定の時刻でEC2インスタンスを自動起動停止するLambda関数

# 作成経緯
まず筆者が下記のLambda関数を作成するに至った経緯を記す。

対象となっているサーバはベンダーが作業時に使う踏み台サーバである。
踏み台用のAWSアカウントを切り出し、SSMセッションマネージャーのポートフォワードで
ベンダーには踏み台サーバへアクセスしてもらっている。

この踏み台サーバの稼働時間は、ベンダーの作業によって変わるため一定ではない。
そのため、EventBridgeでサーバごとにルールを作成し、都度実行時間を更新するのは
手間がかかりすぎるため、EC2インスタンスの起動停止時間を可変にする仕組みを考えた。
折角作ったので備忘としてここに残す。

（正直EC2インスタンスに起動用タグ・停止用タグを付与し、
　タグを対象に月〜金の9時〜18時といった固定時間でEventBridgeルールを作成。
　ベンダーにはこの運用ルールを伝え、人間をルールに合わせて作業させればいいと思う。
　これができるなら本記事を読む必要はない）

# 概要
EC2インスタンスの起動停止を行うLambda関数を作成し、
この関数をEventBridgeで毎日毎時実行する。
対象となるEC2インスタンスの情報、起動時刻、停止時刻は
ひとつのテキストファイルにまとめ、サーバごとに分けてS3バケットへ保管する。

また、S3バケットの管理を簡略化するため、ローカルフォルダにファイルを格納後、
s3 syncコマンドにてファイルのアップロードを行う。

# S3バケットの準備
同期を行うローカルフォルダとS3バケットをそれぞれひとつずつ作成する。
以下のようにローカルフォルダ内にサーバ名で
サブフォルダを作成し、YYYY-MM-DD.txtファイルを格納する。
ファイル一覧を取得し、実行日のファイルがあるか
判定しているため、ファイル名はこの形式で固定する。
s3sync.ps1を実行し、ローカルフォルダ内のファイルをS3バケットへアップロードする。

```:ローカルフォルダ（S3バケット）の構造
ローカルフォルダ（S3バケット）
├サーバA
│　├old
│　│　├2024-02-03.txt
│　│　└2024-02-04.txt
│　├2024-03-05.txt
│　├2024-03-06.txt
│　└2024-03-07.txt
├サーバB
│　├old
│　├2024-03-05.txt
│　├2024-03-06.txt
│　└2024-03-07.txt
└s3sync.ps1
```
各ファイルの記載は以下のようになる。
この場合、EC2インスタンスを9時に起動し、18時に停止する。

```:YYYY-MM-DD.txt
#時刻は0-23で記載する
インスタンスID:i-xxxxxxxxxxxx
起動時刻:9
停止時刻:18
```
SSMセッションマネージャーのポートフォワード時にインスタンスIDが必要になるため、
ベンダーには伝達済み。そのためテキストファイルはすべてベンダーに記載させる想定。
運用側はファイルをもらってフォルダに格納し、シェルを実行するだけで済む。

### S3同期シェル
#### S3バケット同期用ポリシー作成
s3 syncコマンド実行用に以下のポリシーを作成し、
AWS CLIを実行するIAMユーザにアタッチする。
複数人で使い回すことを想定しているため、筆者環境ではIAMユーザも新規作成している。
```
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::S3バケット名/*",
                "arn:aws:s3:::S3バケット名"
            ]
        }
    ]
}
```

#### プロファイル作成
新しく払い出したアクセスキー等の登録に、以下のコマンドを実行する。
```
aws configure --profile s3syncuser
```

#### S3バケット同期シェル
s3 syncコマンドは上記で作成したプロファイルを指定し、実行している。
s3syncuserプロファイルを作成していない場合は、適宜修正が必要。

基本的な動きは同期によるファイルのアップロードだが、
前月分のファイルはまとめてoldサブフォルダへ移動している。
移動先のoldサブフォルダが無い場合は自動的に作成するので、手動で用意する必要はない。
```PowerShell:s3sync.ps1
$FOLDER_PATH = "フォルダパス"
$BUKECT_NAME = "s3://S3バケット名/"
$GET_LAST_MONTH_DAY = (Get-Date -Day 1).AddDays(-1)
$MOVE_FILE_LIST = Get-ChildItem "$FOLDER_PATH\*\*.txt" | Where-Object {$_.LastWriteTime.Date -lt $GET_LAST_MONTH_DAY}

foreach($file in $MOVE_FILE_LIST) {
    $parent_folder = Split-Path $file -Parent
    if(!(Test-Path $parent_folder\old)) {
        New-Item -ItemType Directory -Path $parent_folder\old
    }
    Move-Item -Path $file -Destination $parent_folder\old -Force
}

aws s3 sync $FOLDER_PATH $BUKECT_NAME --delete --exact-timestamps --profile s3syncuser

pause
```

# Lambda関数作成
### Lambda関数実行権限
#### IAMポリシー作成
```
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeInstances",
                "sns:Publish",
                "ec2:StartInstances",
                "ec2:StopInstances"
            ],
            "Resource": "*"
        }
    ]
}
```

#### IAMロール作成
エンティティをLambdaで作成し、以下のポリシーをアタッチする。
* AmazonS3ReadOnlyAccess
* 上記で作成したカスタムポリシー

### Lambda関数作成
ランタイムは「Python 3.12」を選択する。
上記で作成したIAMロールを選択し、Lambda関数を作成する。
作成後、環境変数に以下のキーと値を入力する。
| キー | 値 |
|:-|:-|
|TZ|Asia/Tokyo|
|region|ap-northeast-1|
|bucket_name|S3バケット名|
|sns_topic_arn|arn:aws:sns:ap-northeast-1:AWSアカウント番号:SNSトピック名|

コードソースは以下の通り。
実行時間とファイルに記載されている時刻が合致していた場合、
EC2インスタンスの起動または停止を行う。
起動または停止が実行された場合、SNS経由でメールの発報を行う。

また、ファイルに記載された起動時刻と停止時刻が同じ場合、
記載されたインスタンスIDが存在しない場合は、エラーメールを送信している。
```Python:コードソース
import boto3
import os
import sys
import unicodedata
from datetime import datetime, timezone, timedelta

S3 = boto3.client('s3')
EC2 = boto3.client('ec2', region_name=os.environ['region'])
EC2_RES = boto3.resource('ec2')
SNS = boto3.client('sns')
BUCKET_NAME = os.environ['bucket_name']
SNS_TOPIC_ARN = os.environ['sns_topic_arn']
TODAY = datetime.today()
EXECUTION_DAY = TODAY.strftime("%F")
EXECUTION_HOUR = int(TODAY.strftime("%H"))

def lambda_handler(event, context):
    object_list = []
    object_names = []
    response = S3.list_objects_v2(Bucket=BUCKET_NAME, Delimiter='old/')
    for object in response['Contents']:
        object_list.append(object['Key'])
    for day_files in object_list:
        if EXECUTION_DAY in day_files:
            object_names.append(day_files)
    object_null_check(object_names)
    for day_file_name in object_names:
        object_values = get_object_contents(day_file_name)
        start_hour = int(shape_value(object_values, '起動時刻:', 1))
        stop_hour = int(shape_value(object_values, '停止時刻:', 1))
        if time_same_check(start_hour, stop_hour):
            send_mail("ERROR", day_file_name, 2)
            continue
        ec2_id = str(shape_value(object_values, 'インスタンスID:', 1))
        if time_same_check(EXECUTION_HOUR, start_hour):
            ec2_action(ec2_id, EC2.start_instances, "stopped", day_file_name)
        if time_same_check(EXECUTION_HOUR, stop_hour):
            ec2_action(ec2_id, EC2.stop_instances, "running", day_file_name)

def object_null_check(execution_file):
    if not execution_file:
        sys.exit()

def get_object_contents(object_name):
    byte_body = S3.get_object(Bucket=BUCKET_NAME, Key=object_name)['Body'].read()
    str_body = str(byte_body, 'utf-8')
    str_body_arr = str_body.splitlines()
    return str_body_arr

def shape_value(values, search_word, index):
    for line in values:
        if search_word in line:
            line_hankaku = to_half_width(line)
            value_split = line_hankaku.split(':')
            value = value_split[index]
            return value
    value = 255
    return value

def to_half_width(text):
    return ''.join(unicodedata.normalize('NFKC', char) for char in text)

def time_same_check(time1, time2):
    if time1 == time2:
        return True

def ec2_action(ec2_id, order, status, instance_name):
    if id_exists_cheak(ec2_id):
        if ec2_status_check(ec2_id) == status:
            order(InstanceIds = [ec2_id])
            send_mail(status, instance_name, 0)
    else:
        send_mail(status, instance_name, 1)

def id_exists_cheak(ec2_id):
    instance_iterator = EC2_RES.instances.all()
    for instance in instance_iterator:
        if ec2_id == instance.instance_id:
            return True

def ec2_status_check(instance_id):
    list_instance_id = [instance_id]
    status_response = EC2.describe_instances(InstanceIds=list_instance_id)
    return status_response['Reservations'][0]['Instances'][0]['State']['Name']

def send_mail(status, day_file_name, error_code):
    instance_name = day_file_name.split('/')
    subject = "【踏み台環境】"
    if error_code == 0:
        if status == "stopped":
            subject = subject + "成功:自動起動:" + instance_name[0]
            msg = instance_name[0] + "を起動しました。"
        else:
            subject = subject + "成功:自動停止:" + instance_name[0]
            msg = instance_name[0] + "を停止しました。"
    elif error_code == 1:
        subject = subject + "エラー:" + instance_name[0]
        msg = instance_name[0] + "の起動停止に失敗しました。\nファイルに記載されたインスタンスIDが間違っています。\n手動で" + instance_name[0] + "を操作し、ファイルを修正してください。"
    elif error_code == 2:
        subject = subject + "エラー:" + instance_name[0]
        msg = instance_name[0] + "の起動停止をスキップしました。\nファイルに記載された起動時刻と停止時刻が同じです。\n手動で" + instance_name[0] + "を操作し、ファイルを修正してください。"
    
    SNS.publish(
        TopicArn = SNS_TOPIC_ARN,
        Message = msg,
        Subject = subject
    )
```

### Lambda関数実行用のEventBridgeルール作成
作成したLambda関数を対象に、EventBridgeのルールを作成する。
このときの実行スケジュールはcron表記で以下となる。
```
0 * * * ? *
```

# 終わりに
想定は毎時実行での運用だが、テキストファイルの表記を
「起動時刻:MM:DD」にし、コードソースの中でshape_value関数を
呼び出す際のindexの値を2にすれば分の取得ができる。

そうすると分の値で実行時刻と数値比較ができるので、EvemtBridgeルールの
cronを「0/30 * * * ? *」とすれば30分置きの実行も可能。