Node.js
AWS
lambda
serverless
stepfunctions

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

More than 1 year has passed since last update.

はじめに

  • Serverless Advent Calendar 2016の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

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

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

自動起動停止構成図.png

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

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

Lambdaの設定

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

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

startStepsの作成

設定値

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

権限

権限は以下を与えます

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

環境変数

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

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

コード

コードは以下の通りです

'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

権限

権限は以下を与えます

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

環境変数

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

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

コード

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

'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

権限

権限は以下を与えます

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

環境変数

なし

コード

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

'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

権限

権限は以下を与えます

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

環境変数

なし

コード

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

'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

項目 設定値
Name ScheduledBoot
Code 以下を入力
{
  "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

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

stepfunctions_009.PNG

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

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

stepfunctions_006.PNG

これで作成は完了です。

stepfunctions_010.PNG

CloudWatch Eventsの設定

ルールの作成

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

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

CloudWatch_Management_Console.png

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

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

CloudWatch_Management_Console.png

名前は適当に決めて、

CloudWatch_Management_Console.png

設定完了です!

CloudWatch_Management_Console.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時に停止

EC2_Management_Console.png

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

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

EC2_Management_Console.png

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

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

EC2_Management_Console.png

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

EC2_Management_Console.png

注意事項

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

終わりに

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