OpsWorksのTime-based instancesをLambdaで無理やり日付ベースで稼働設定する

# 前置き

こんにちは。mediba advent calendar 2017 21日目担当のmoriです。

最近はめっきりコードを書くことが減り、コーディング技術が錆びついている状態で、さて何をやろうかと考えてはみたものの良い題材が思い浮かびませんでした。

なので、１年前に[この記事](http://ceblog.mediba.jp/post/151745126882/aws-opsworks%E3%81%AEtime-based)でやろうとしていた、
**「OpsWorksのTime-based instancesを日付ベースで設定できるようにする」**
という試みを、今の自分の知識でやってみようかと思います。

ものすごい成長したというわけではないですが、さすがに１年前の自分には勝てるでしょう。（というか記事を読み返すと、正直すごくイケてない）

OpsWorksでこんな事をやって建設的なのかどうかは余所へ置いておいて、
実際問題としてTime-based設定をするのに、マウスをクリックし続けるのは御免です。

# やりたいこと

本記事のタイトルの通り、OpsworksのTime-basedを日付ベースで稼働設定できるようにすることです。要点は以下。

- 特定のインスタンスを、毎月イベントがある3、１３、２３日だけ稼働させる。
    - 厳密にはイベント前日23時から、イベント翌日11時まで動かす。
    - それ以外は停止。 

身も蓋もなく言えば、cronでその日にインスタンスをSTARTさせるシェルでも作れば良いかもしれませんが、肝心のその日にシェルが動かなかったりといった失敗した時も考えて、敢えてTime-basedに拘ってみます。

###今回のポイント
- Time-basedは曜日ベースでしか稼働設定できない。
- Lambdaでやる。（時代はサーバーレス）
- Pythonが自分的にアツイ気がするので採用。(今回初めて書く）
- S3に設定ファイルを置く。

これに加え、１年前は時間と知識と技術が足りてなくて手が回ってなかったところも極力カバーできるように、ざっくりLambda関数を作って見ました。


# 実践

### ■Lambdaで使用するロールに、必要なポリシーを追加

```json
        {
            "Effect": "Allow",
            "Action": [
                "lambda:InvokeFunction"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": "arn:aws:s3:::xxx/*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "opsworks:*"
            ],
            "Resource": "*"
        }
```
- s3のResourceは、設定ファイルを配置するバケットを指定。（バケットの作成はここでは省略）
- opsworksの権限は緩めに設定しているが、本来は対象のスタックに限定するべき。

### ■s3バケットに設定ファイルをアップロード 
今回設定ファイルとして使うのは以下の３つ。
これらを任意のバケットにアップロードします。

1.インスタンスを動かす対象日の設定

```json:event_calendar.json
[3, 13, 23]
```

2.対象のインスタンスIDリスト

- ここで指定するのは「EC2 instance ID」ではなく、「OpsWroks ID」なので注意
- 一応環境の違いに対応するため、ファイル名の頭に「dev」をつけている。

```json:dev_instance_list.json
[
 "abcdefgh-1234-43aa-9876-22222ce7733c",
 "stuvwxyz-5678-43bb-1234-33333f165c08"
]
```

3.Time-basedに設定する稼働スケジュール

- 「イベント前日23時から、イベント翌日11時まで動かす。」
   - これを実現するために、イベントとその前後3日分のスケジュールを記載。
   - 「Before」がイベント前日、「Target」がイベント当日、「After」がイベント翌日

```json:schedule.json
{
 "Before": {
  "0": "off",
  "1": "off",
  "2": "off",
  "3": "off",
  "4": "off",
  "5": "off",
  "6": "off",
  "7": "off",
  "8": "off",
  "9": "off",
  "10": "off",
  "11": "off",
  "12": "off",
  "13": "off",
  "14": "on",
  "15": "on",
  "16": "on",
  "17": "on",
  "18": "on",
  "19": "on",
  "20": "on",
  "21": "on",
  "22": "on",
  "23": "on"
 },
 "Target": {
  "0": "on",
  "1": "on",
  "2": "on",
  "3": "on",
  "4": "on",
  "5": "on",
  "6": "on",
  "7": "on",
  "8": "on",
  "9": "on",
  "10": "on",
  "11": "on",
  "12": "on",
  "13": "on",
  "14": "on",
  "15": "on",
  "16": "on",
  "17": "on",
  "18": "on",
  "19": "on",
  "20": "on",
  "21": "on",
  "22": "on",
  "23": "on"
 },
 "After": {
  "0": "on",
  "1": "on",
  "2": "on",
  "3": "off",
  "4": "off",
  "5": "off",
  "6": "off",
  "7": "off",
  "8": "off",
  "9": "off",
  "10": "off",
  "11": "off",
  "12": "off",
  "13": "off",
  "14": "off",
  "15": "off",
  "16": "off",
  "17": "off",
  "18": "off",
  "19": "off",
  "20": "off",
  "21": "off",
  "22": "off",
  "23": "off"
 }
}
```

### ■Lambda関数を作成

1.Labmda関数を新規作成

- ランタイムは今回は「Python 3.6」を指定。
- ロールは最初にポリシーを設定したものを使用。

![スクリーンショット 2017-12-22 10.55.53.png](https://qiita-image-store.s3.amazonaws.com/0/148768/f475d393-7750-8e83-6625-8e105a501fe1.png)

2.環境変数を定義。

今回、環境変数として設定するのは以下の4つです。

- S3_BUCKET ・・・ 設定jsonファイルをアップロードしているバケット名
- EVENT_SCHEDULE_JSON ・・・ 上記作った「Time-basedに設定する稼働スケジュール」のファイル名
- ENV ・・・ 実行される環境。今回は上記の「対象のインスタンスIDリスト」作成の際で触れたように「dev」環境とする。
- EVENT_CALENDAR ・・・ 対象となるイベント日リストのjsonファイル

![スクリーンショット 2017-12-22 13.56.15.png](https://qiita-image-store.s3.amazonaws.com/0/148768/a941606d-a5a1-8814-fe66-bb9f946d2f89.png)

3.コード本体

初Pythonなので、ツッコミどころ満載だとは思いますが、少なくとも動くはずです。

```python:time-based-ctl.py
import os
import boto3
import json
import datetime

def lambda_handler(event, context):
    # Time-basedは曜日指定で設定なので、それ用の配列を用意
    weekday_list = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
    
    # s3とopsworksのclient
    s3 = boto3.resource('s3')
    s3_client = s3.meta.client
    ops_works_client = boto3.client('opsworks', region_name='us-east-1')
    
    # s3のbucket名を環境変数から取得
    bucket_name = os.environ['S3_BUCKET']

    # 設定jsonファイルのオブジェクトキーを環境変数から取得    
    obj_key_event = os.environ['EVENT_SCHEDULE_JSON']
    obj_key_event_calendar = os.environ['EVENT_CALENDAR']
    obj_key_instance_list = os.environ['ENV'] + "_instance_list.json"
    
    # 設定json取り込み(tryでやってみる)
    try:
        # 稼働スケジュールの設定を取得
        response_event = s3_client.get_object(Bucket = bucket_name, Key = obj_key_event)
        schedule_json_event = json.loads(response_event['Body'].read())
        
        # 対象のインスタンスを取得
        response_instance_list = s3_client.get_object(Bucket = bucket_name, Key = obj_key_instance_list)
        instance_list = json.loads(response_instance_list['Body'].read())

        # 対象日リストを取得        
        event_calendar = s3_client.get_object(Bucket = bucket_name, Key = obj_key_event_calendar)
        event_calendar_list = json.loads(event_calendar['Body'].read())
    
    except Exception as e:
        print(e)

    
    # 今日の日付を取得。UTCなので、日本時間にする。(他に方法あるかも)
    today = datetime.datetime.now() + datetime.timedelta(hours = 9)
    # この変数の意味は後述
    skip = False

    # ここからは個別に解説
    # [A]forループ
    for target_idx in range(2, 6): 
        # 対象日の曜日を特定しセット
        target_day = today + datetime.timedelta(days = target_idx)
        tareget_weekday = weekday_list[target_day.weekday()]
        
        # [B] １つ前のループで対象日の稼働スケジュール設定をしていたらスキップ
        if skip and target_day.day not in event_calendar_list:
            skip = False
            continue
        elif target_day.day in event_calendar_list:
            # 対象日の前日と翌日を特定
            before_day = target_day - datetime.timedelta(days = 1)
            after_day = target_day + datetime.timedelta(days = 1)

            if skip:
                scuedule_setting = {
                    weekday_list[target_day.weekday()]: schedule_json_event['Target'],
                    weekday_list[after_day.weekday()]: schedule_json_event['After']
                }
            else:
                scuedule_setting = {
                    weekday_list[before_day.weekday()]: schedule_json_event['Before'],
                    weekday_list[target_day.weekday()]: schedule_json_event['Target'],
                    weekday_list[after_day.weekday()]: schedule_json_event['After']
                }
            
            # インスタンスの分だけ、Time-baseのセットを実施
            for target_instance in instance_list:
                response = ops_works_client.set_time_based_auto_scaling(
                                                         InstanceId = target_instance,
                                                         AutoScalingSchedule = scuedule_setting,
                                                         )
            # 対象日前後の稼働スケジュールをいじっているので、次ループはスキップさせる。
            skip = True
        else:
            # 対象日以外は停止させて置く必要があるので、そのように設定。
            for target_instance in instance_list:
                response = ops_works_client.set_time_based_auto_scaling(
                                                                         InstanceId = target_instance,
                                                                         AutoScalingSchedule = { weekday_list[target_day.weekday()]: {}}
            skip = False

```

[A]の補足

```python
    for target_idx in range(2, 6): 
        # 対象日の曜日を特定しセット
        target_day = today + datetime.timedelta(days = target_idx)
        tareget_weekday = weekday_list[target_day.weekday()]
```        
**rangeを「2,6」で回している理由**
Time-basedの設定ベースが曜日ベースである都合上、１週間以上先は設定できないこと、今回は対象日だけではなくその前後日の稼働スケジュールも操作することを踏まえ、2日後〜6日後としています。
何かの拍子で、この処理が1日や2日動かなかったとしても、リカバリはできるようになっています。
※7日後は実行当日に当たる曜日の稼働スケジュールをいじることになる為、含めない。


[B]の補足

```python
        # [B] １つ前のループで対象日の稼働スケジュール設定をしていたらスキップ
        if skip and target_day.day not in event_calendar_list:
            skip = False
            continue
        elif target_day.day in event_calendar_list:
            # 対象日の前日と翌日を特定
            before_day = target_day - datetime.timedelta(days = 1)
            after_day = target_day + datetime.timedelta(days = 1)

            # 前ループが対象日だった場合、稼働スケジュール設定の対象から外す
            if skip:
                scuedule_setting = {
                    weekday_list[target_day.weekday()]: schedule_json_event['Target'],
                    weekday_list[after_day.weekday()]: schedule_json_event['After']
                }
            else:
                scuedule_setting = {
                    weekday_list[before_day.weekday()]: schedule_json_event['Before'],
                    weekday_list[target_day.weekday()]: schedule_json_event['Target'],
                    weekday_list[after_day.weekday()]: schedule_json_event['After']
                }
```
**スキップの意味**
対象日の設定が2日連続だった場合の対応です。スキップしないと、稼働スケジュールがイベントの日ように設定したものが、完全停止状態に上書きされてしまいます。

elifでもスキップ判定をみているのも同様の理由です。(この場合は、イベント当日設定の２４時間稼働が、イベント前日の設定に上書きされてしまう)

### ■lambda関数を動かす
あとはトリガーを[CloudWatch Events]で、cron設定にて1日1回動くように設定するだけです。

# まとめ
とまぁ、駆け足で実装をしてみました。
これでOpsworksのTime-basedで、イベント日にほぼ確実に自動(？)スケールアウトができるようになります。

Python自体は初心者どころか、ほぼ初見のド素人な訳ですが、印象的には書きやすかったので、もう少し綺麗なコードが書けるように勉強してみようかと思います。

次はニッチな用途ではなく、普通に役立つ何かを紹介したいです・・・。