作成経緯
まず筆者が下記の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バケット)
├サーバ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時に停止する。
#時刻は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サブフォルダが無い場合は自動的に作成するので、手動で用意する必要はない。
$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分置きの実行も可能。