0
0

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

Posted at

作成経緯

まず筆者が下記の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サブフォルダが無い場合は自動的に作成するので、手動で用意する必要はない。

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が存在しない場合は、エラーメールを送信している。

コードソース
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分置きの実行も可能。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0