AWSのEC2インスタンスを祝日を除いた平日に自動起動するLambdaを作る記録

EC2インスタンスを決まった時間に自動起動したいです。

起動しっぱなしでいいインスタンスですが、たまにうっかり誰かが停止しちゃったりします。
なので、始業時間になって停止していたら自動で起動してほしいです。

AutoStartタグに設定した時間になったら起動したいです。

起動したい時間は、時と共に変わるかもしれませんし、インスタンスによっても変わります。
なので、インスタンスに「AutoStart」というタグとその値に起動したい時間を設定します。

祝日は自動起動しないでほしいです。

平日(月~金曜日)にLambdaの実行を設定しても、祝日は意識してもらえません。
祝日は、使わないので節約のためにも起動しなくていいんです。

先人の知恵をパクッて使います。

xp-cloud.jp

S3にバケットを作ります。

祝日判定をするために使用するGoogleカレンダーの祝日リストを入れるためのバケットです。

祝日判定を行うにあたって、祝日のリストが必要になります。
今回はGoogleカレンダーで取得できる祝日リストを利用したいと思います。
下記URLから自由にダウンロードでき、現在から前後1年間を含む3年間分の祝日データが取得可能となっております。
https://www.google.com/calendar/ical/ja.japanese%23holiday%40group.v.calendar.google.com/public/basic.ics
Lambdaで祝日判定 | AWSやシステム・アプリ開発の最新情報|クロスパワーブログ

S3にバケットを作ります。

  1. AWSのコンソールにある[S3] > [バケットを作成する]ボタンで画面を開きます。
    • f:id:ponsuke_tarou:20200214101427p:plain
  2. 入力欄を入力して[作成]ボタンで作成する。
    • バケット名 : google-holiday-list(任意の文字列)
    • リージョン : Lambdaを作る予定のリージョンと同じリージョン
    • f:id:ponsuke_tarou:20200214102846p:plain

取得実リストを取得するLambda関数を作成します。

  1. AWSのコンソールにある[Lambda] > [関数の作成]ボタンで画面を開きます。
  2. 必要な項目を入力後に[関数の作成]ボタンで関数を作成します。
    • オプション : [一から作成]
    • 関数名 : get_google_holiday_list(任意の関数名でOK)
    • ランタイム : Python3.8
    • 実行ロール : 既存のロールを使用する
      • 既存のロール : [lambda_basic_execution]を選択します。
  3. [関数の作成]ボタンで関数を作成します。

Lambda関数を実行するトリガーを作成します。

  1. [Designer]にある[トリガーを追加]ボタンでトリガーの設定画面を開きます。
  2. プルダウンから[CloudWatch Events]を選択します。
  3. 各入力欄を記載します。
    1. ルール : [新規ルールの作成]
    2. ルール名(必須) : get_google_holiday_list(任意の関数名でOK)
    3. ルールタイプ : [スケジュール式]
    4. スケジュール式 : cron(0 8 1 * ? *)(毎月1日の午前 8:00)
  4. [追加]ボタンでトリガーを追加する

関数を実装します。

import boto3
import urllib.request
import re
import os

s3 = boto3.resource('s3')
# Googleカレンダーの祝日リストを入れるためのバケット
bucket = s3.Bucket('google-holiday-list')

def write_holiday_list(list, file):
    for num in range(len(list)):
        pattern = r"DTSTART;VALUE=DATE:"
        # DTSTART;VALUE=DATE:yyyyMMddの行の正規表現
        repattern = re.compile(pattern)
        target_line = list[num-1].decode('utf-8')
        match = repattern.search(target_line)
        if match is None:
            pass
        else:
            print('出力対象行:' + target_line)
            # 出力対象行(\r\n含む)の後ろから10文字目から8文字を出力する
            file.write(target_line[-10:-2] + '\n')
    return 0

def lambda_handler(event, context):
    # 祝日リストの取得元URL
    url = 'https://www.google.com/calendar/ical/ja.japanese%23holiday%40group.v.calendar.google.com/public/basic.ics'
    try:
        response = urllib.request.urlopen(url)
        list = response.readlines()
    except Exception as e:
        print('祝日リストの取得に失敗しました。')
        return 1

    # 書き込みでファイルを開く
    f = open('/tmp/holiday.txt','w')
    write_holiday_list(list, f)
    f.close()

    data = open('/tmp/holiday.txt', 'rb')
    result = bucket.put_object(Key='holiday.txt', Body=data)
    data.close()
    os.remove('/tmp/holiday.txt')

EC2インスタンス自動起動するLambda関数を作成します。

  1. AWSのコンソールにある[Lambda] > [関数の作成]ボタンで画面を開きます。
  2. 必要な項目を入力後に[関数の作成]ボタンで関数を作成します。
    • オプション : [一から作成]
    • 関数名 : start_instances_by_tag_value(任意の関数名でOK)
    • ランタイム : Python3.8
    • 実行ロール : 既存のロールを使用する
      • 既存のロール : [lambda_basic_execution]を選択します。
  3. [関数の作成]ボタンで関数を作成します。

f:id:ponsuke_tarou:20200206094552p:plain

Lambda関数を実行するトリガーを作成します。

  1. [Designer]にある[トリガーを追加]ボタンでトリガーの設定画面を開きます。
    • f:id:ponsuke_tarou:20200206094718p:plain
  2. プルダウンから[CloudWatch Events]を選択します。
  3. 各入力欄を記載します。
  4. ルール : [新規ルールの作成]
  5. ルール名(必須) : start_instances_by_tag_value(任意の関数名でOK)
  6. ルールタイプ : [スケジュール式]
  7. スケジュール式 : cron(0/10 8-11 ? * MON-FRI *) (平日AM8:00-11:00で10分毎)
  8. [追加]ボタンでトリガーを追加する

f:id:ponsuke_tarou:20200206101020p:plain

関数を実装します。

# -*- coding: utf-8 -*-

from __future__ import print_function

import sys
import json
from datetime import datetime, timedelta, timezone, date
import boto3
import os

DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
JST = timezone(timedelta(hours=+9))
REGION_NAME = 'ap-northeast-1'
TAG_NAME = 'AutoStart'
TMP_HOLIDAY_FILE = '/tmp/holiday.txt'
# CALENDAR_URL = "https://calendar.google.com/calendar/ical/ja.japanese%23holiday%40group.v.calendar.google.com/public/basic.ics"
# NTP_URL = "http://ntp-b1.nict.go.jp/cgi-bin/json"

print("Loading function")
s3 = boto3.resource('s3')
ec2 = boto3.client('ec2', REGION_NAME)

def get_holiday_list():
    """
    S3バケットから祝日リストを取得する
    """
    # Googleカレンダーの祝日リストを入れてあるバケット
    bucket = s3.Bucket('google-holiday-list')
    holiday_obj = bucket.Object('holiday.txt')

    with open(TMP_HOLIDAY_FILE, 'wb') as data:
        holiday_obj.download_fileobj(data)

    f = open(TMP_HOLIDAY_FILE)
    holiday_list = f.readlines()
    f.close()
    os.remove(TMP_HOLIDAY_FILE)

    return holiday_list

def is_holiday():
    """
    土日祝日判定処理
    """
    is_holiday = False
    day = date.today()
    today = day.strftime('%Y%m%d')
    week = day.weekday()

    holiday_list = get_holiday_list()

    check = today + '\n' in holiday_list

    if check == True:
        print('今日は祝日です。')
        is_holiday = True
    elif week == 5 or week == 6:
        print('今日は週末です。')
        is_holiday = True
    elif check == False:
        print('今日は平日です。')
    else:
        print('土日祝日判定に失敗しました')

    return is_holiday

def get_tag(instance):
    """
    TAG_NAMEのタグを配列で取得する.
    """
    tag_list = instance['Tags']
    tag = next(iter(filter(lambda tag: tag['Key'] == TAG_NAME and (tag['Value'] is not None and tag['Value'] != ''), tag_list)), None)
    return tag

def get_tag_value_time(tag):
    """
    タグに設定された時間を取得する.
    """
    val = tag["Value"]
    if val == '':
        print('タグに時間が指定されていません。' + tag)
        return ''

    time = val.split(':')
    now = datetime.now(JST)
    tag_time = datetime(now.year, now.month, now.day, int(time[0]), int(time[1]), 0, 0, JST)
    return tag_time

def is_start_instance(event, tag_time):
    utc_event_time = datetime.strptime(event["time"], DATETIME_FORMAT)
    print('実行時間(UTC)は、' + utc_event_time.strftime(DATETIME_FORMAT))
    jst_event_time = utc_event_time.astimezone(JST)
    print('実行時間(JST)は、' + jst_event_time.strftime(DATETIME_FORMAT))

    # AutoStopタグに指定された時刻の前後5分以内であればインスタンス起動する
    start_time_from = tag_time + timedelta(minutes=-5)
    start_time_to = tag_time + timedelta(minutes=5)
    print('実行時間(' + jst_event_time.strftime(DATETIME_FORMAT) + ')が、' + \
          start_time_from.strftime(DATETIME_FORMAT) + 'から' + start_time_to.strftime(DATETIME_FORMAT) + 'だったら起動します。')

    return (start_time_from <= jst_event_time) and (jst_event_time <= start_time_to)

def start_instance(event, instance):
    """
    インスタンス起動処理
    """
    auto_start_tag = get_tag(instance)
    tag_time = get_tag_value_time(auto_start_tag)
    if tag_time == '':
        return

    print(instance['InstanceId'] + 'のタグに指定された時間(JST)は、' + tag_time.strftime(DATETIME_FORMAT))

    if is_start_instance(event, tag_time):
        ec2.start_instances(InstanceIds=[instance['InstanceId']])
        print(instance['InstanceId'] + 'を起動しました。')

def lambda_handler(event, context):
    print("Received event: " + json.dumps(event, indent=2))
    if is_holiday():
        # 土日祝日なら処理終了
        print('土日祝日は起動しません。')
        return

    # (停止している)かつ([AutoStart]タグのついている)インスタンス情報を取得する
    instances = ec2.describe_instances(
        Filters=[
            {'Name': 'instance-state-name', 'Values': ['stopped']},
            {'Name': 'tag-key', 'Values': [TAG_NAME]}
        ]
    )['Reservations'][0]['Instances']

    if len(instances) > 0:
        # インスタンスを順番に処理していく
        for instance in instances:
            start_instance(event, instance)
使った関数の情報

失敗したこと

'ec2.ServiceResource' object has no attribute 'describe_instances'

  • 原因 : 使うオブジェクトを間違ってしまった。
    • describe_instances()は、boto3.client('ec2')で取得したオブジェクトに含まれる関数なのに誤ってboto3.resource('ec2')で取得したオブジェクトで実行してしまった。
    • 人のコードをコピペして使っているとこういうことが起こります。
    ec2 = boto3.resource('ec2') #<<<< boto3.client('ec2')が正解

    instances = ec2.describe_instances()
  • 対応 : boto3.client('ec2')で取得したオブジェクトを使う

can't compare offset-naive and offset-aware datetimes

def get_tag_value_time(tag):
    ...省略...
    # 1. タイムゾーンを指定していなかった
    tag_time = datetime(now.year, now.month, now.day, int(time[0]), int(time[1]))
    print('タグに指定された時間(JST)は、' + tag_time.strftime(DATETIME_FORMAT))
    return tag_time

def is_start_instance(event, tag_time):
    ...省略...
    # 2. 実行時間はタイムゾーンをJSTにした
    jst_event_time = utc_event_time.astimezone(timezone(timedelta(hours=+9)))
    print('実行時間(JST)は、' + jst_event_time.strftime(DATETIME_FORMAT))

    # AutoStopタグに指定された時刻の前後5分以内であればインスタンス起動する
    # 3. タイムゾーンはNoneになっていた
    start_time_from = tag_time + timedelta(minutes=-5)
    start_time_to = tag_time + timedelta(minutes=5)
    print('処理時間(' + jst_event_time.strftime(DATETIME_FORMAT) + ')が、' + \
          start_time_from.strftime(DATETIME_FORMAT) + 'から' + start_time_to.strftime(DATETIME_FORMAT) + 'だったら起動します。')

    # 4. タイムゾーンがJSTとNoneのdatetimeオブジェクトを比較してエラーになった
    return (start_time_from <= jst_event_time) and (jst_event_time <= start_time_to)

def start_instance(event, instance):
    ...省略...
    tag_time = get_tag_value_time(auto_start_tag)

    if is_start_instance(event, tag_time):
    ...省略...
  • 調べた方法 : 各datetimeオブジェクトについてutcoffset()でタイムゾーンを確認した
    print(tag_time.utcoffset())         # None
    print(jst_event_time.utcoffset())   # 9:00:00
    print(start_time_from.utcoffset())  # None
    print(start_time_to.utcoffset())    # None
  • 対応 : タグから取得した時間でdatetimeオブジェクトを作るときにタイムゾーンを指定する
JST = timezone(timedelta(hours=+9))
...省略...
def get_tag_value_time(tag):
    ...省略...
    tag_time = datetime(now.year, now.month, now.day, int(time[0]), int(time[1]), 0, 0, JST)
    print('タグに指定された時間(JST)は、' + tag_time.strftime(DATETIME_FORMAT))
    return tag_time

Unable to import module 'lambda_function': No module named 'urllib2'

  • 原因 : Python 3から urllib2 モジュールがなくなったから
import urllib2
...省略...
        response = urllib2.urlopen(url)

注釈 urllib2 モジュールは、Python 3 で urllib.request, urllib.error に分割されました。 2to3 ツールが自動的にソースコードのimportを修正します。
20.6. urllib2 --- URL を開くための拡張可能なライブラリ — Python 2.7.17 ドキュメント

import urllib.request
...省略...
        response = urllib.request.urlopen(url)

cannot use a string pattern on a bytes-like object

  • 原因 : byte型とstr型を比較するから
    • type関数で型を調べるとbyte型とstr型でした。
        pattern = r"DTSTART;VALUE=DATE:"
        # DTSTART;VALUE=DATE:yyyyMMddの行の正規表現
        repattern = re.compile(pattern)
        print(type(pattern)) # >>>>>>>>>>>>>>>>>>> <class 'str'>
        print(type(list[num-1])) #>>>>>>>>>>>>>>>>> <class 'bytes'>
        print(list[num-1]) #>>>>>>>>>>>>>>>>>>>>> b'END:VCALENDAR\r\n'
        match = repattern.search(list[num-1])
        pattern = r"DTSTART;VALUE=DATE:"
        # DTSTART;VALUE=DATE:yyyyMMddの行の正規表現
        repattern = re.compile(pattern)
        match = repattern.search(list[num-1].decode('utf-8')) #<<< 変換してから比較する