EC2インスタンスのスケジュール起動停止をサーバーレスで実現する

# はじめに

- [Serverless Advent Calendar 2016](http://qiita.com/advent-calendar/2016/serverless)の23日目です。
- 主にAWS Lambdaを利用したサーバーレスにまつわる記事を書いています。
- 本記事では、EC2のスケジュール起動停止をLambdaで実現する方法を記載したいと思います。

# 要件

無駄なコストを省くために、インスタンスの自動起動停止を行いたい
具体的な要件は以下の通り

- ステージ環境など、日中だけ起動しておけばいいインスタンスは自動的に起動及び停止させたい
- 土日は起動しないなどの設定もできたら嬉しい
- 上記をそれぞれのインスタンスに対して柔軟に適応したい

# 解決策

各インスタンスごとに柔軟に設定したいとのことだったので、今回は各インスタンスの**タグ**で
制御しようと考えました。
具体的には、以下のような具合です。

|TagKey      |TagValue    |備考                |
|------------|------------|--------------------|
| Start      | 10         |10時に起動           |
| Stop       | 22         |22時に停止           |
| Operation  | Mon-Fri    |月曜日から金曜日に起動|

各タグがない場合や、タグのValueが設定されていない場合は、無視されるようにします。

# 構成

今年、AWSから「AWS Step Function」というサービスがリリースされました。
これまではLambdaの処理をシーケンシャルに行いたい場合は、Lambdaから別のLambdaを
呼び出したり、SNSで繋げたりしなければなりませんでしたが、このサービスを利用すると
複数のLambdaをStep実行できるようです。

[Step Function](https://aws.amazon.com/jp/blogs/news/new-aws-step-functions-build-distributed-applications-using-visual-workflows/)

よって、今回はこれを利用してやってみようと思います。

今回考えた構成は以下の通りです。

![自動起動停止構成図.png](https://qiita-image-store.s3.amazonaws.com/0/40136/2e010a46-a387-0fbf-4d8e-15135a48c6ef.png)

1. まずCloudWatch Eventsにて1時間おきにstate machineを実行するLambdaをキック
2. state machineが実行される
3. EC2インスタンスのタグをスキャンし、対象のインスタンスをリスト化する
4. 対象インスタンスをスタート及びストップする

早速作成していきます！
今回はバージニア北部リージョンにて全て作成しました。
※StepFunctionは東京リージョンでも動くので東京で作成しても大丈夫です。

# Lambdaの設定

まず、Lambdaファンクションを作成していきます
今回作成するLambdaファンクションは以下の４つです

|ファンクション名      |役割                         |
|---------------------|----------------------------|
| startSteps          | State Machineをキックする   |
| describeInstances   | 対象インスタンスをリスト化する|
| startInstances      | 対象インスタンスをスタートする|
| stopInstances       | 対象インスタンスをストップする|

## startStepsの作成

### 設定値

|項目                 |設定値                       |
|---------------------|----------------------------|
| Runtime             | Node.js 4.3                |
| Handler             | index.handler              |
| Memory              | 128 MB                     |
| Timeout             | 10 sec                     |

### 権限

権限は以下を与えます

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1481002278000",
            "Effect": "Allow",
            "Action": [
                "states:StartExecution"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}
```

### 環境変数

環境変数には以下を設定します。

|変数名               |設定値                       |説明                        |
|---------------------|----------------------------|----------------------------|
| STATEMACHINE        | ScheduledBoot              | 実行するState Machine名     |

### コード

コードは以下の通りです

```js
'use strict';

const AWS = require('aws-sdk'),
      stepfunctions = new AWS.StepFunctions();

exports.handler = (event, context, callback) => {

    const metaList  = context.invokedFunctionArn.split(':'),
          params = {
            stateMachineArn: 'arn:aws:states:' + metaList[3] + ':' + metaList[4] + ':stateMachine:' + process.env.STATEMACHINE
          };

    stepfunctions.startExecution(params, function(err, data) {
        if (err) {
            console.log(err, err.stack);
        } else {
            console.log('success');
        }
    });
};
```


## describeInstancesの作成

### 設定値

|項目                 |設定値                       |
|---------------------|----------------------------|
| Runtime             | Node.js 4.3                |
| Handler             | index.handler              |
| Memory              | 128 MB                     |
| Timeout             | 1 min                      |

### 権限

権限は以下を与えます

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1481002174000",
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeInstances"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}
```

### 環境変数

環境変数には以下を設定します。

|変数名               |設定値                       |説明                        |
|---------------------|----------------------------|----------------------------|
| STARTTAGKEY         | Start                      | 自動起動用タグのKey名        |
| STOPTAGKEY          | Stop                       | 自動停止用タグのKey名        |
| OPERATIONDAYTAGKEY  | Operation                  | 曜日指定用タグのKey名        |

### コード

コードは以下の通りです。

```js
'use strict';

const AWS = require('aws-sdk'),
      ec2 = new AWS.EC2();

exports.handler = (event, context, callback) => {

    const generator = (function* () {
        try {
            const now = new Date();
            // UTC→JST
            now.setHours(now.getHours()+9);
            const currentHours       = now.getHours().toString(),
                  currentDay         = now.getDay(),
                  weekDayList        = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
                  startInstanceIds   = [],
                  stopInstanceIds    = [],
                  STARTTAGKEY        = process.env.STARTTAGKEY,
                  STOPTAGKEY         = process.env.STOPTAGKEY,
                  OPERATIONDAYTAGKEY = process.env.OPERATIONDAYTAGKEY;
            let   startInstancesList = [],
                  stopInstancesList  = [];

            // 現在時刻のStartタグが付いているインスタンスのリストを取得
            startInstancesList = yield listTaggedInstances(currentHours, STARTTAGKEY, generator);

            // 現在時刻のStopタグが付いているインスタンスのリストを取得
            stopInstancesList  = yield listTaggedInstances(currentHours,  STOPTAGKEY, generator);

            // スタート対象インスタンスリスト作成
            if(startInstancesList.length) {
                for(let i = 0, len = startInstancesList.length; i < len; i++) {
                    // 曜日の指定があるか確認
                    let dayTagPoint = arrayObjectIndexOf(startInstancesList[i].Tags, OPERATIONDAYTAGKEY, 'Key');

                    if(dayTagPoint < 0) {
                        // 曜日の指定がない場合は、そのままスタート対象インスタンスリストに追加
                        startInstanceIds.push(startInstancesList[i].InstanceId);
                    } else {
                        // 曜日の指定がある場合、現在曜日が指定期間内であれば、スタート対象リストに追加
                        let runday   = startInstancesList[i].Tags[dayTagPoint].Value,
                            startDay = runday.split('-')[0],
                            endDay   = runday.split('-')[1];

                        if(weekDayList.indexOf(startDay) <= currentDay &&
                           currentDay                    <= weekDayList.indexOf(endDay) ) {
                               startInstanceIds.push(startInstancesList[i].InstanceId);
                           }
                    }
                }
            }

            // ストップ対象インスタンスリスト作成
            if(stopInstancesList.length) {
                // そのままストップ対象インスタンスリストに追加
                for(let i = 0; i < stopInstancesList.length; i++) {
                    stopInstanceIds.push(stopInstancesList[i].InstanceId);
                }
            }

            const targetMap = {
                "StartInstances" : {
                    "count"            : startInstanceIds.length,
                    "StartInstanceIds" : startInstanceIds
                },
                "StopInstances"  : {
                    "count"            : stopInstanceIds.length,
                    "StopInstanceIds"  : stopInstanceIds
                }
            };

            callback(null, targetMap);


        } catch (e) {
            callback(e);
        }
    })();

    /* 処理開始 */
    generator.next();

};

// 任意の値でタグ付けされているインスタンスのリストを作成
const listTaggedInstances = (value, tag, generator) => {
    const params = {
        Filters: [
            {
                Name : 'tag:' + tag,
                Values: [
                    value
                ]
            }
        ]
    };

    ec2.describeInstances(params, function(err, data) {
        if(err) {
            console.log(err, err.stack);
            generator.throw(new Error(err, err.stack));
        } else {
            if(data.Reservations[0]) {
                generator.next(data.Reservations[0].Instances);
            } else {
                generator.next(data.Reservations);
            }
            
        }
    });
};

// 連想配列を要素とする配列から任意の値の位置を返却
const arrayObjectIndexOf = (array, searchTerm, property) => {
    for(let i = 0, len = array.length; i < len; i++) {
        if(array[i][property] === searchTerm) {
            return i;
        }
    }
    return -1;
};
```

## startInstancesの作成

### 設定値

|項目                 |設定値                       |
|---------------------|----------------------------|
| Runtime             | Node.js 4.3                |
| Handler             | index.handler              |
| Memory              | 128 MB                     |
| Timeout             | 1 min                      |

### 権限

権限は以下を与えます

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1481002211000",
            "Effect": "Allow",
            "Action": [
                "ec2:StartInstances"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}
```

### 環境変数

なし

### コード

コードは以下の通りです。

```js
'use strict';

const AWS = require('aws-sdk'),
      ec2 = new AWS.EC2();

exports.handler = (event, context, callback) => {

    const params = {
        InstanceIds: event.StartInstanceIds
    };

    ec2.startInstances(params, function(err, data) {
        if (err) {
            callback(err);
        } else {
            callback(null, 'success');
        }
    });
};
```

## stopInstancesの作成

### 設定値

|項目                 |設定値                       |
|---------------------|----------------------------|
| Runtime             | Node.js 4.3                |
| Handler             | index.handler              |
| Memory              | 128 MB                     |
| Timeout             | 1 min                      |

### 権限

権限は以下を与えます

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1481002240000",
            "Effect": "Allow",
            "Action": [
                "ec2:StopInstances"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}
```

### 環境変数

なし

### コード

コードは以下の通りです。

```js
'use strict';

const AWS = require('aws-sdk'),
      ec2 = new AWS.EC2();

exports.handler = (event, context, callback) => {

    const params = {
        InstanceIds: event.StopInstanceIds
    };

    ec2.stopInstances(params, function(err, data) {
        if (err) {
            callback(err);
        } else {
            callback(null, 'success');
        }
    });
};
```

# Step Functionの設定

State Machinesの作成を行います。

[Create a State Machine]ボタンをクリックして新しく作成します。

以下画面で値を入力します。
※Codeの各Resourceは適宜修正してください。

![stepfunctions_001.PNG](https://qiita-image-store.s3.amazonaws.com/0/40136/d8a2f472-dcf1-565f-0a91-0495526f9033.png)

|項目              |設定値                  |
|------------------|-----------------------|
| Name             | ScheduledBoot         |
| Code             | 以下を入力             |

```json
{
  "Comment": "Scheduled Boot Instances",
  "StartAt": "1 DescribeInstances",
  "States": {
    "1 DescribeInstances": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:000000000000:function:describeInstances",
      "Next": "2 Scheduled Boot"
    },

    "2 Scheduled Boot": {
      "Type": "Parallel",
      "End": true,
      "Branches": [
          {
              "StartAt": "2_1_1 Starting Choices",
              "States": {
                  "2_1_1 Starting Choices": {
                      "Type": "Choice",
                      "Choices": [
                          {
                              "Not": {
                                  "Variable": "$.StartInstances.count",
                                  "NumericEquals": 0
                              },
                              "Next": "2_1_2 Start Instances"
                          }
                      ],
                      "Default": "2_1_9 End of Startings"
                  },
                  "2_1_2 Start Instances": {
                      "Type": "Task",
                      "InputPath": "$.StartInstances",
                      "Resource": "arn:aws:lambda:us-east-1:000000000000:function:startInstances",
                      "Next": "2_1_9 End of Startings"
                  },
                  "2_1_9 End of Startings": {
                      "Type": "Pass",
                      "End": true
                  }
              }
          },
          {
              "StartAt": "2_2_1 Stopping Choices",
              "States": {
                  "2_2_1 Stopping Choices": {
                      "Type": "Choice",
                      "Choices": [
                          {
                              "Not": {
                                  "Variable": "$.StopInstances.count",
                                  "NumericEquals": 0
                              },
                              "Next": "2_2_2 Stop Instances"
                          }
                      ],
                      "Default": "2_2_9 End of Stoppings"
                  },
                  "2_2_2 Stop Instances": {
                      "Type": "Task",
                      "InputPath": "$.StopInstances",
                      "Resource": "arn:aws:lambda:us-east-1:000000000000:function:stopInstances",
                      "Next": "2_2_9 End of Stoppings"
                  },
                  "2_2_9 End of Stoppings": {
                      "Type": "Pass",
                      "End": true
                  }
              }
          }
      ]
    }
  }
}
```

![stepfunctions_008.PNG](https://qiita-image-store.s3.amazonaws.com/0/40136/ec4d4894-b8b0-06fe-aba0-b7fdce4e20d7.png)

ここで、Preview横のクルクルをクリックすると

![stepfunctions_009.PNG](https://qiita-image-store.s3.amazonaws.com/0/40136/40c5fc6c-c9f6-1dfe-2510-2b5e1a41f784.png)

このようなグラフが現れればOKです。
そのまま[Create State Machine]をクリックしてください。

以下画面では規定のRoleを選択し、OKをクリックします。

![stepfunctions_006.PNG](https://qiita-image-store.s3.amazonaws.com/0/40136/eff06181-5613-3622-b68a-f6a9e1961330.png)

これで作成は完了です。

![stepfunctions_010.PNG](https://qiita-image-store.s3.amazonaws.com/0/40136/040df0a1-ca77-1d8c-e79f-0d07dc3af7b7.png)

# CloudWatch Eventsの設定

## ルールの作成

CloudWatchでルールを作成します。

[ルールの作成]ボタンをクリック。

<img width="1266" alt="CloudWatch_Management_Console.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/4cc84f66-68a0-5c5f-b6da-9cc68def7964.png">

毎時間の0分ごとにState Machineを実行したいので、以下のように設定します。

|項目              |設定値                  |
|------------------|-----------------------|
| イベント          | スケジュール           |
| Cron式           | 0 * * * ? *           |
| ターゲット        | startSteps            |

<img width="1263" alt="CloudWatch_Management_Console.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/32e353f4-9cef-9f57-70a6-b2c0ddd55b61.png">

名前は適当に決めて、

<img width="1265" alt="CloudWatch_Management_Console.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/7bfc2b16-9fb0-3ce9-21b2-8ec79551477c.png">

設定完了です！

<img width="1266" alt="CloudWatch_Management_Console.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/27946db0-a4ca-27cc-1973-60db6548cd5b.png">

# 試してみる

これで準備は整ったので、早速試してみます。

## 対象インスタンスへのタグ付け

テスト用に5台のインスタンスを作成しました。
(クラウドって便利ですねー)
テスト用インスタンスに対して以下のようにタグ付けします。

|Name              |Operation   |Start          |Stop          |期待する動き                 |
|------------------|------------|---------------|--------------|----------------------------|
| startstoptest01  | Mon-Fri    | 10            |              |月曜から金曜の間、AM10時に起動|
| startstoptest02  |            |               | 10           |AM10時に停止                 |
| startstoptest03  | Mon-Thu    | 10            |              |月曜から木曜の間、AM10時に起動|
| startstoptest04  |            | 10            |              |AM10時に起動                |
| startstoptest05  | Mon-Thu    |               | 10           |AM10時に停止                |

<img width="896" alt="EC2_Management_Console.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/544c28c1-67eb-2714-bb5e-2dd8dc675941.png">

現在、**金曜日で現在時刻はAM9:58**です。
あと、2分で変化が現れるはず…

AM10:00になりました、インスタンスの状態はどうでしょう。

<img width="896" alt="EC2_Management_Console.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/2bf3b63c-0bda-256d-0f9d-7d6cf10054d0.png">

きちんと対象のインスタンスだけ変化がありました！

今日は金曜日なので、**startstoptest03**は反応してません。
また、曜日指定はあくまで**Start**にしか影響しないため、**startstoptest05**はきちんと停止しています。

<img width="896" alt="EC2_Management_Console.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/90321e6b-c724-d1fd-ec5c-d3202d724198.png">

普段はステージ環境に対して以下のような感じで指定して運用しています。

<img width="897" alt="EC2_Management_Console.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/d54b9d96-b835-b8ea-9286-2acf4a56a3e5.png">

# 注意事項

- 曜日の指定は['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']を組み合わせて行います。
- 現在、曜日の指定を**Fri-Sun**のように指定すると正常に動きません。Sun→Mon→…→Satの順番で指定する必要があります。
- StartとStopに同じ時間を入れると正常に動かないと思います。
- 時間の指定は日本時間で24H表記です。

# 終わりに

エラー処理等まだ改良の余地はありますが、このレベルであれば気軽に導入できるので、簡単にコスト削減できるのではないでしょうか。
Step Functionは使ってみたばかりですが、それぞれのLambdaを簡単に記述することができるので、これからもっと活用の幅が広がりそうです。