AWS Lambda Pythonで複数AWSアカウントのEC2インスタンスの起動(Start)、停止(Stop)をスケジュール実行・管理するPythonスクリプト(バッチ処理プログラム) 〜タグの値に起動時間・停止時間を指定、土日祝日を考慮〜

4月 5, 2017AWS,EC2,IAM,Lambda,Programming,Python,Shell Script

AWS Lambda Python、Amazon API Gatewayが登場した現在、今後を考えるとEC2インスタンスの役割は次第に少なくなっていくと考えられます。
ただ、一方でAWS Lambdaでは長時間のサービス実行や自由にパッケージをインストールしたシステム構築など、その仕様上実現できないことも数多くあります。

このような状況においてEC2インスタンスは役割を特化しながら今後も必要性を維持していくと思われますが、今までも今後も度々課題として挙げられるのがEC2インスタンスの料金です。
EC2インスタンスの料金は起動させ続けるとAWSサービスの中でも比較的多くなる傾向にあります。

そのため、EC2インスタンスの利用は必要な時間に必要なだけ起動してランニングコストを少なく抑えたいものです。
EC2インスタンスの料金を低く抑えるためにはEC2インスタンスの起動(Start)、停止(Stop)をスケジュール実行・管理するPythonスクリプト(バッチ処理プログラム)を作成することが一般的には行われている方法です。

今回は以前「日本の祝日を取得するPythonスクリプト(バッチ処理プログラム)(「山の日」対応) – カレンダーファイル(ICSファイル)で祝日の取得 〜2015年の祝日一覧、2016年の祝日一覧、2017年の祝日一覧、2018年の祝日一覧、2019年の祝日一覧、2020年の祝日一覧〜」で記載した祝日を習得するPythonスクリプト(バッチ処理プログラム)の内容も応用して、AWS Lambda Pythonで複数AWSアカウントのEC2インスタンスの起動(Start)、停止(Stop)をスケジュール実行・管理するPythonスクリプト(バッチ処理プログラム)を備忘録として記載しておきます。

このPythonスクリプト(バッチ処理プログラム)の大まかな処理の流れは下記のようになります。

  • 実行日が土日祝日であれば処理全体をスキップする
  • 指定された複数のAWSアカウントに対して下記を実行する
  • STSでEC2インスタンスへの実行権限を持つCredentialsを取得する
  • 指定されたタグキーを持つインスタンスを取得する
  • 指定されたタグキーに対するタグバリューが実行時刻から実行時刻の10分前の範囲にある場合に指定されたアクション(StartまたはStop)をEC2インスタンスに対して実行する

今回のEC2インスタンスの起動(Start)、停止(Stop)のPythonスクリプト(バッチ処理プログラム)をLambdaのスケジューリング機能で10分毎に実行することでEC2インスタンスを定期的にスケジュール実行・管理することが可能になります。

今回のPythonスクリプト(バッチ処理プログラム)の説明においてAWS Lambdaのevent JSONやIAM Roleのポリシーの例やサンプルの前提として、
LambdaでEC2インスタンス起動(Start)、停止(Stop)Pythonスクリプト(バッチ処理プログラム)を実行するAWSアカウントのアカウント番号を012345678901、
アクションを実行されるEC2インスタンスが存在するアカウントを987654321098とします。

AWS Lambda Pythonで複数AWSアカウントのEC2インスタンスの起動(Start)、停止(Stop)をスケジュール実行・管理するPythonスクリプト(バッチ処理プログラム)

Pythonスクリプト(バッチ処理プログラム)を実行するLambda関数に付与するRoleポリシー

Lambda関数に付与するRoleポリシー内容はCloudWatchへのログ出力、STSのAssumeRoleの呼び出しへのアクセス許可です。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:*"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "lambda:InvokeFunction",
                "lambda:InvokeAsync",
                "sts:AssumeRole"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

RoleはAWS LambdaのAWSサービスロールとして作成され、
Roleの信頼関係は信頼されたエンティティが「IDプロバイダー:lambda.amazonaws.com」となる必要があります。
このポリシーをRoleに適用しLambda関数に関連付けます。

アクションを実行されるEC2インスタンスが存在するAWSアカウントごとに用意するクロスアカウントRoleのポリシー

EC2インスタンスに対するアクセス許可は下記の参照、起動(Start)、停止(Stop)の権限です。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeInstances",
                "ec2:StartInstances",
                "ec2:StopInstances"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

Roleは所有しているAWSアカウント間のクロスアカウントアクセスのロールとして作成され、
Roleの信頼関係は信頼されたエンティティが「アカウント:012345678901(Lambdaを実行するアカウント)」となる必要があります。
このポリシーをRoleに適用し下記で説明するLambdaを実行する際のevent JSONのtarget_role_arns配列に入力します。

AWS LambdaでEC2インスタンス起動(Start)、停止(Stop)Pythonスクリプト(バッチ処理プログラム)に渡すevent JSONの内容

今回のPythonスクリプト(バッチ処理プログラム)では下記のようにevent JSONの内容を渡してリージョン、対象EC2インスタンスの判定条件、実行アクション、EC2インスタンスを操作するアカウントを設定します。

注意するべき点としてはtarget_role_arnsには複数のAWSアカウントのRoleを配列として渡すことができますが、AWS Lambdaの実行可能時間は5分なのでEC2インスタンスやAWSアカウントが多い場合は分けて実行する必要があることです。

  • sts_region
  • STSを実行するリージョン(AWS Lambdaのリージョン)。

  • ec2_region
  • アクションを実行するEC2のリージョン。

  • ec2_tag_key
  • アクションを実行するかどうかを判定するEC2インスタンスに設定するタグキー。
    ここで指定したタグキーを持つインスタンスを検索し、タグキーに対するタグバリューを実行時間として判定する。

  • ec2_action
  • EC2インスタンスに実行するアクション(StartまたはStop)

  • target_role_arns
  • EC2インスタンスに対するStart, Stop実行を許可するポリシーを付与したRole Arnの配列。
    ここに権限移譲した複数のAWSアカウントのRole Arnを指定することで複数のAWSのインスタンスの起動、停止をまとめてスケジュール実行・管理できる。

event JSONの例

EC2インスタンスを起動(Start)する場合の例

StartTimeタグキーを持つEC2インスタンスに対してStartTimeのタグバリューの時間で判定し起動する。

{
  "sts_region": "ap-northeast-1",
  "ec2_region": "ap-northeast-1",
  "ec2_tag_key": "StartTime",
  "ec2_action": "Start",
  "target_role_arns": [
    "arn:aws:iam::987654321098:role/assume_ec2_exec_role"
  ]
}
EC2インスタンスを停止(Stop)する場合の例

StopTimeタグキーを持つEC2インスタンスに対してStopTimeのタグバリューの時間で判定し停止する。

{
  "sts_region": "ap-northeast-1",
  "ec2_region": "ap-northeast-1",
  "ec2_tag_key": "StopTime",
  "ec2_action": "Stop",
  "target_role_arns": [
    "arn:aws:iam::987654321098:role/assume_ec2_exec_role"
  ]
}

EC2インスタンス起動(Start)、停止(Stop)Pythonスクリプト(バッチ処理プログラム)

EC2インスタンス起動(Start)、停止(Stop)Pythonスクリプト(バッチ処理プログラム)の大まかな処理の流れとしては下記のようになります。

  • 実行日が土日祝日であれば処理全体をスキップする
  • event JSONのtarget_role_arnsで指定されたアクション実行対象のEC2インスタンスが存在するAWSアカウント分繰り返して下記を実行する
  • STSで各AWSアカウントのEC2インスタンスへの実行権限を持つCredentialsを取得する
  • event JSONのec2_tag_keyで指定されたタグキーを持つインスタンスを取得する
  • event JSONのec2_tag_keyで指定されたタグキーに対するタグバリューが実行時刻から実行時刻の10分前の範囲にある場合にevent JSONのec2_actionで指定されたアクション(StartまたはStop)をEC2インスタンスに対して実行する
# -*- coding: utf-8 -*-

import boto3
import commands
import json
import os
from datetime import datetime, timedelta

print('Loading function')

#Lambdaの時刻はUTCなのでJSTに合わせる時差を用意する
time_diff = 9

#祝日を取得するスクリプトを用いて当日が祝日かどうかを確認するメソッド
def is_holiday(today):
    flg = _("./get_holidays.sh %s | wc -l" % today).strip()

    if flg == "1":
        print("Today is holiday.")
        return True
    else:
        print("Today is not holiday.")
        return False

#role_arnとendpointからcredentialsを取得するメソッド
def get_sts_credentials(role_arn, sts_region):
    sts_endpoint_url = "https://sts." + sts_region + ".amazonaws.com"
    sts_client = boto3.client('sts',endpoint_url=sts_endpoint_url,use_ssl=True)
    assume_role = sts_client.assume_role(
        RoleArn=role_arn,
        RoleSessionName="AssumeRoleSession1"
    )
    return assume_role['Credentials']

#Linuxのコマンドを実行するメソッド
def _(cmd):
    return commands.getoutput(cmd) 

#Lambda main関数
def lambda_handler(event, context):
    #Lambdaサーバの時刻からJST時間を取得する
    local_time = datetime.now() + timedelta(hours=time_diff)
    print("Today is %s" % (local_time.strftime("%Y/%m/%d %H:%M:%S")))

    #アクションを実行する対象時刻の範囲(10分間)を取得する
    target_time_to = local_time.strftime("%H%M")
    target_time_from = (local_time + timedelta(minutes=-10)).strftime("%H%M")
    print("target_time_from: %s" % (target_time_from))
    print("target_time_to: %s" % (target_time_to))

    #eventのJSONから対象のAWSアカウントのrole_arn配列、stsのendpoint、EC2のリージョン、
    #対象インスタンスを特定するtag-key、実行するAction(Start or Stop)を取得する
    target_role_arns = event["target_role_arns"]
    sts_region = event["sts_region"]
    ec2_region = event["ec2_region"]
    ec2_tag_key = event["ec2_tag_key"]
    ec2_action = event["ec2_action"]
  
    print("Today is %s" % (local_time.strftime("%Y/%m/%d %H:%M:%S")))

    #当日が土日または祝日の場合は処理をスキップする
    if local_time.weekday() > 4 or is_holiday(local_time.strftime("%Y%m%d")):
        print("Skip All Execute: Because today is holiday.")
        return

    #対象となるAWSアカウントのrole_arn配列からrole_arnを取り出して処理する
    for target_role_arn in target_role_arns:
        print("target_account_role: %s" % (target_role_arn))

        #role_arnからec2を操作するためのSTS Credentialsを取得する
        credentials = get_sts_credentials(target_role_arn, sts_region)

        #操作するec2をリージョン、Credentials情報を指定して取得
        ec2 = boto3.resource('ec2', region_name=ec2_region,
            aws_access_key_id=credentials['AccessKeyId'],
            aws_secret_access_key=credentials['SecretAccessKey'],
            aws_session_token=credentials['SessionToken']
        )

        #Filtersで対象インスタンスを特定するtag-keyで検索してインスタンスを取得する
        for instance in ec2.instances.filter(Filters=[{'Name': 'tag-key', 'Values': [ec2_tag_key]}]):
            print("Target instance: %s" % instance)

            #対象インスタンスを特定するtag-keyに対するtag-valueを取得する
            for tag in instance.tags:
                if tag['Key'] == ec2_tag_key and tag['Value'].strip():
                    
                    print("{%s: {%s: %s}}" % (instance.id, tag['Key'], tag['Value']))
                    print("Action: %s" % (ec2_action))

                    #tag-valueが実行対象時間の範囲内に入っていた場合はアクションを実行する
                    if target_time_from < tag['Value'].strip() and tag['Value'].strip() <= target_time_to:
                        #実行するActionがStartの場合はインスタンスのStart処理を行う
                        if ec2_action == "Start":
                            #インスタンスのstateがstoppedならばstart処理を行う
                            if instance.state["Name"] == "stopped":
                                print("Execute: Start %s" % instance.id)
                                result = instance.start()
                                print("Execute Result: %s" % result)
                            else:
                                print("Skip Execute: %s is not stopped." % instance.id)
                        #実行するActionがStopの場合はインスタンスのStop処理を行う
                        elif ec2_action == "Stop":
                            #インスタンスのstateがrunningならばstop処理を行う
                            if instance.state["Name"] == "running":
                                print("Execute: Stop %s" % instance.id)
                                result = instance.stop()
                                print("Execute Result: %s" % result)
                            else:
                                print("Skip Execute: %s is not running." % instance.id)
                        #Actionの文字列がStart, Stopと一致しなかった場合は処理をスキップする
                        else:
                            print("Skip Execute: %s. There is invalid Action. Check your ec2_action parameter." % instance.id)
                            return
                    #tag-valueが実行対象時間の範囲内でなければ処理をスキップする
                    else:
                        print("Skip Execute: %s does not match target time range. " % instance.id)

「EC2インスタンス起動(Start)、停止(Stop)Pythonスクリプト(バッチ処理プログラム)」にevent JSONを渡して呼び出すAWS Lambda関数

上記の「EC2インスタンス起動(Start)、停止(Stop)Pythonスクリプト(バッチ処理プログラム)」は下記のようにAWS Lambda関数からLambda invokeを用いてevent JSONを渡して呼び出すことで使用します。
今回の例では「EC2インスタンス起動(Start)、停止(Stop)Pythonスクリプト(バッチ処理プログラム)」のLambda関数名はstart_stop_instancesとして登録したと仮定しています。

EC2インスタンスを起動(Start)する場合の例
import boto3

def lambda_handler(event, context):
    client = boto3.client('lambda')

    response = client.invoke(
        FunctionName='start_stop_instances',
        InvocationType='RequestResponse',
        LogType='Tail',
        Payload=b'{ "sts_region":"ap-northeast-1", "ec2_region":"ap-northeast-1", "ec2_tag_key":"StartTime", "ec2_action":"Start", "target_role_arns":[ "arn:aws:iam::987654321098:role/assume_ec2_exec_role" ] }'
    )
    
    print(response)
    
EC2インスタンスを停止(Stop)する場合の例
import boto3

def lambda_handler(event, context):
    client = boto3.client('lambda')

    response = client.invoke(
        FunctionName='start_stop_instances',
        InvocationType='RequestResponse',
        LogType='Tail',
        Payload=b'{ "sts_region":"ap-northeast-1", "ec2_region":"ap-northeast-1", "ec2_tag_key":"StopTime", "ec2_action":"Stop", "target_role_arns":[ "arn:aws:iam::987654321098:role/assume_ec2_exec_role" ] }'
    )
    
    print(response)
    
Reference: Tech Blog citing related sources