概要
今更ですがEC2インスタンスの起動停止をスケジュールに従って実行する方法について
以下の課題の解決を目指しました。
- インスタンスごとに起動・停止の時間を設定したい
- 曜日も指定したい
- 複数のアカウントに対して実施したい
実装としては、インスタンスごとにタグを追加し、タグの値によって起動・停止を制御することにしました。
構成
Cloud Watch Event + Step Functions + Lambda (+ IAM)
- Cloud Watch event
- instanceBootControler
- Step Functions
- instanceBootControler
- Lambda
- assumeRole
- describeInstances
- startInstances
- stopInstances
Cloud Watch event
instanceBootControler
毎時Step FunctionsのinstanceBootControlerを実行(名前変えればよかった・・・)
Step Functions
instanceBootControler
State Machineを実行
Lambda
assumeRole
各種アカウントのアクセスキーを取得
describeInstances
任意のタグのEC2インスタンスのリストを取得
startInstances
インスタンスを起動
stopInstances
インスタンスを停止
IAM設定
Lambdaを動作させるメインアカウントと、インスタンスの制御を行うサブアカウントそれぞれでIAMのロール作成を行います。
メインアカウントのIAM設定
メインアカウント内のEC2インスタンスを対象とするか否かでポリシーが変わります。
ここではメインアカウント内のEC2も対象とします。
また、各種Lambda関数を1つのポリシーで運用していますので、細かく設定する場合は適宜変更してください。
- メインアカウントにサインイン
- IAM>ロール>ロールの作成
- 信頼関係:AWSサービス>Lambda
- ポリシーは割り当てず任意のロール名で作成
- 作成したロールのARNを覚えておく
- 作成したロールにインラインポリシーを追加
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
},
{
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:StartInstances",
"ec2:StopInstances"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"sts:AssumeRole"
],
"Resource": [
"*"
]
}
]
}
サブアカウントのIAM
外部のLambdaからのアクセスを許可するロールを作成します。
- サブアカウントにサインイン
- IAM>ロール>ロールの作成
- 信頼関係:別のAWSアカウント>メインアカウントのID入力
- ポリシーは割り当てず任意のロール名で作成
- 作成したロールのARNを覚えておく
- (オプション)信頼関係>信頼関係の編集でメインアカウントで作成したロールのARNに変更
- 作成したロールにインラインポリシーを追加
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:StartInstances",
"ec2:StopInstances"
],
"Resource": [
"*"
]
},
{
"Effect": "Allow",
"Action": [
"sts:AssumeRole"
],
"Resource": [
"*"
]
}
]
}
Lambda設定
assumeRole
名前の通りassumeRoleを実行する関数
後続の処理に必要なアクセスキーを取得しています。
- ランタイム:Node.js 6.10
- 実行ロール:先ほど作成したロールを設定
- タイムアウト:10秒(適当)
- (備考)入力:StepFunctionsから渡されるARN
- (備考)出力:credentials(+etc)
'use strict';
const AWS = require('aws-sdk');
AWS.config.region = 'ap-northeast-1';
const sts = new AWS.STS();
exports.handler = function(event, context, callback) {
const params = {
RoleArn: event,
RoleSessionName: "getCredentials_" + Date.now()
};
sts.assumeRole(params, function (err, data) {
console.log(err || JSON.stringify(data));
if(err) callback(err);
else callback(null, data);
});
};
describeInstances
名前の通り(ry
起動対象のインスタンスIDと停止対象のインスタンスIDのリストを作成しています。
TAGKEY(環境変数)の値は変更可能
ここで設定したTAGKEYの値が制御したいインスタンスに設定するTAGKEYとなります。
- ランタイム:Node.js 6.10
- 実行ロール:先ほど作成したロールを設定
- タイムアウト:10秒(適当)
- 環境変数
- STARTTAGKEY:startInstance
- STOPTAGKEY:stopInstance
- OPERATIONDAYTAGKEY:operationDay
- (備考)入力:credentials
- (備考)出力:起動・停止インスタンスIDリスト
'use strict';
process.env.TZ = "Asia/Tokyo";
const STARTTAGKEY = process.env.STARTTAGKEY;
const STOPTAGKEY = process.env.STOPTAGKEY;
const OPERATIONDAYTAGKEY = process.env.OPERATIONDAYTAGKEY;
const AWS = require('aws-sdk');
exports.handler = function(event, context, callback) {
AWS.config.update({
accessKeyId: event.Credentials.AccessKeyId,
secretAccessKey: event.Credentials.SecretAccessKey,
sessionToken: event.Credentials.SessionToken,
region: 'ap-northeast-1'
});
const ec2 = new AWS.EC2();
const now = new Date();
const hour = ("0" + now.getHours()).slice(-2);
const day = now.getDay();
const generator = (function* (){
const startInstances = yield instanceList(STARTTAGKEY, hour, generator);
const stopInstances = yield instanceList(STOPTAGKEY, hour, generator);
const targetMap = {
"StartInstances" : {
"StartInstanceIds" : startInstances,
"Count" : startInstances.length,
},
"StopInstances" : {
"StopInstanceIds" : stopInstances,
"Count" : stopInstances.length,
},
};
console.log(targetMap);
callback(null, targetMap);
})();
const instanceList = (tag, value, generator) => {
const params = {
Filters: [
{
Name : 'tag:' + tag,
Values: ['*' + value + '*']
}
]
};
ec2.describeInstances(params, function(err, data) {
// console.log(err || JSON.stringify(data));
if(err) new Error(err, err.stack);
// インスタンス一覧取得
const instances = [];
data.Reservations.forEach( (num) => {
// 曜日タグを確認
const opeAry = num.Instances[0].Tags.filter( (tagobj) => tagobj.Key == OPERATIONDAYTAGKEY );
if( opeAry.length ){
// 曜日指定タグが存在する場合は対象か確認
if(operationCheck(opeAry[0].Value, day)) instances.push(num.Instances[0].InstanceId);
}
// 曜日指定タグがない場合は対象
else{
instances.push(num.Instances[0].InstanceId);
}
});
generator.next(instances);
});
};
// start
generator.next();
};
const operationCheck = (operationStr, day) => {
const operationList = [];
let errorFlg = false;
operationStr = operationStr.replace(/ /g, '');
if(operationStr == '*') return true;
if(!operationStr.match(/^[0-6]$|^([0-6][,\-]){1,6}[0-6]$/)) return false;
for(let char of operationStr.split(',')){
// ハイフン処理
if(char.match(/^[0-6]-[0-6]$/)){
const firstChar = parseInt(char.charAt(0), 10);
const lastChar = parseInt(char.charAt(2), 10);
// 前の数値が後ろの数値より大きい場合はエラー
if( firstChar >= lastChar ){
errorFlg = true;
break;
}
Array.prototype.push.apply(operationList, rangeConv(firstChar, lastChar));
}
// 数値処理
else{
operationList.push(parseInt(char, 10));
}
}
// 書式が間違っているのでNG
if(errorFlg) return false;
// 存在しないのでNG
if(operationList.indexOf(day) == -1) return false;
// 値重複はNG
if( operationList.filter( (x, i, self) => self.indexOf(x) !== self.lastIndexOf(x)).length ) return false;
return true;
};
const rangeConv = (first,last) => {
if(isNaN(first) || isNaN(last)) return false;
if(first >= last ) return false;
const ary = [];
for(var i = first; i <= last; i++){
ary.push(i);
}
return ary;
};
startInstances
名(ry
受け取ったIDリストのインスタンスを起動します。
- ランタイム:Node.js 6.10
- 実行ロール:先ほど作成したロールを設定
- タイムアウト:10秒(適当)
- (備考)入力:credentials + 起動・停止インスタンスIDリスト
- (備考)出力:startInstancesコマンドの実行結果
'use strict';
const AWS = require('aws-sdk');
exports.handler = (event, context, callback) => {
AWS.config.update({
accessKeyId: event.Credentials.AccessKeyId,
secretAccessKey: event.Credentials.SecretAccessKey,
sessionToken: event.Credentials.SessionToken,
region: 'ap-northeast-1'
});
const ec2 = new AWS.EC2();
const params = {
InstanceIds: event.InstanceIds.StartInstances.StartInstanceIds
};
ec2.startInstances(params, function(err, data) {
console.log(err || JSON.stringify(data));
if(err) callback(err);
else callback(null, data);
});
};
stopInstances
受け取ったIDリストのインスタンスを停止します。
- ランタイム:Node.js 6.10
- 実行ロール:先ほど作成したロールを設定
- タイムアウト:10秒(適当)
- (備考)入力:credentials + 起動・停止インスタンスIDリスト
- (備考)出力:stopInstancesコマンドの実行結果
'use strict';
const AWS = require('aws-sdk');
exports.handler = (event, context, callback) => {
AWS.config.update({
accessKeyId: event.Credentials.AccessKeyId,
secretAccessKey: event.Credentials.SecretAccessKey,
sessionToken: event.Credentials.SessionToken,
region: 'ap-northeast-1'
});
const ec2 = new AWS.EC2();
const params = {
InstanceIds: event.InstanceIds.StopInstances.StopInstanceIds
};
ec2.stopInstances(params, function(err, data) {
console.log(err || JSON.stringify(data));
if(err) callback(err);
else callback(null, data);
});
};
タグ設定
EC2インスタンスにタグを設定し、任意の値を入力します。
LambdaのdescribeInstances関数の環境変数で設定した値をTAGKEYとして設定していきます。
タグ名 | 用途 | 入力可能な値 | 例 |
---|---|---|---|
startInstance | インスタンス起動時間 | 00~24、複数可 | 06, 08 |
stopInstance | インスタンス停止時間 | 00~24、複数可 | 07, 22 |
operationDay | 起動・停止の曜日 | 0~6、-*可 | 0-2, 4, 6 |
上の例では[日・月・火・木・土]の6時に起動・7時に停止・8時に起動・22時の停止 |
- operationDayを設定した場合は日を跨いだ設定は正常に動作しません
- operationDayを設定した場合は0時に停止できません
- operationDayを指定しない場合は毎日と同義
- operationDayに7は使えないので注意
- startInstanceとstopInstanceに同じ値を設定した場合はLambdaキックのタイミングで運ゲー(たぶん)
Step Functions設定
アカウント数の変化に対応したかったのですが力技で対応しています。
もし対応する場合はState Machineを生成するLambdaを作ってキックする構成になるかと思います。
State Machineが生成され続けるのを残しておく場合、毎日24コ作成されてしまう。
State Machineの削除まで行う場合、ログが消失する。
⇒ 力技でいいや・・・。
ここでは対象アカウントが3つの場合のState Mashineのコードを記載します。
ビジュアルワークフロー
コード
各種アカウントで生成したIAMロールのARNを記載する必要があります。
ARNのアカウントID部分は0でマスクしてます。
- 8行目のarn配列にIAMロールのarnを記載
- arnの数だけ分岐部分のコード(20行目~104行目)をコピー
- 配列への要素追加なのでカンマを追加して、ペーストして数字部分($arn[0]も忘れずに)を変更
{
"Comment": "EC2 instances boot controler",
"StartAt": "arnSet",
"States": {
"arnSet": {
"Type": "Pass",
"Result": {
"arn": [
"arn:aws:iam::000000000000:role/XXXXXXXXXXXXXXXX",
"arn:aws:iam::000000000000:role/XXXXXXXXXXXXXXXX",
"arn:aws:iam::000000000000:role/XXXXXXXXXXXXXXXX"
]
},
"Next": "Parallel"
},
"Parallel": {
"Type": "Parallel",
"End": true,
"Branches": [
{
"StartAt": "assumeRoles_0",
"States": {
"assumeRoles_0": {
"Type": "Task",
"InputPath": "$.arn[0]",
"Resource": "arn:aws:lambda:ap-northeast-1:000000000000:function:assumeRole",
"TimeoutSeconds": 60,
"HeartbeatSeconds": 30,
"Next": "describeInstances_0"
},
"describeInstances_0": {
"Type": "Task",
"ResultPath": "$.InstanceIds",
"Resource": "arn:aws:lambda:ap-northeast-1:000000000000:function:describeInstances",
"TimeoutSeconds": 60,
"HeartbeatSeconds": 30,
"Next": "StartStopBranches_0"
},
"StartStopBranches_0": {
"Type": "Parallel",
"End": true,
"Branches": [
{
"StartAt": "ChoiceStartInstances_0",
"States": {
"ChoiceStartInstances_0": {
"Type": "Choice",
"Choices": [
{
"Not": {
"Variable": "$.InstanceIds.StartInstances.Count",
"NumericEquals": 0
},
"Next": "StartInstances_0"
}
],
"Default": "EndOfStartInstances_0"
},
"StartInstances_0": {
"Type" : "Task",
"Resource": "arn:aws:lambda:ap-northeast-1:000000000000:function:startInstances",
"TimeoutSeconds": 60,
"HeartbeatSeconds": 30,
"Next": "EndOfStartInstances_0"
},
"EndOfStartInstances_0": {
"Type": "Pass",
"End": true
}
}
},
{
"StartAt": "ChoiceStopInstances_0",
"States": {
"ChoiceStopInstances_0": {
"Type": "Choice",
"Choices": [
{
"Not": {
"Variable": "$.InstanceIds.StopInstances.Count",
"NumericEquals": 0
},
"Next": "StopInstances_0"
}
],
"Default": "EndOfStopInstances_0"
},
"StopInstances_0": {
"Type" : "Task",
"Resource": "arn:aws:lambda:ap-northeast-1:000000000000:function:stopInstances",
"TimeoutSeconds": 60,
"HeartbeatSeconds": 30,
"Next": "EndOfStopInstances_0"
},
"EndOfStopInstances_0": {
"Type": "Pass",
"End": true
}
}
}
]
}
}
},
{
"StartAt": "assumeRoles_1",
"States": {
"assumeRoles_1": {
"Type": "Task",
"InputPath": "$.arn[1]",
"Resource": "arn:aws:lambda:ap-northeast-1:000000000000:function:assumeRole",
"TimeoutSeconds": 60,
"HeartbeatSeconds": 30,
"Next": "describeInstances_1"
},
"describeInstances_1": {
"Type": "Task",
"ResultPath": "$.InstanceIds",
"Resource": "arn:aws:lambda:ap-northeast-1:000000000000:function:describeInstances",
"TimeoutSeconds": 60,
"HeartbeatSeconds": 30,
"Next": "StartStopBranches_1"
},
"StartStopBranches_1": {
"Type": "Parallel",
"End": true,
"Branches": [
{
"StartAt": "ChoiceStartInstances_1",
"States": {
"ChoiceStartInstances_1": {
"Type": "Choice",
"Choices": [
{
"Not": {
"Variable": "$.InstanceIds.StartInstances.Count",
"NumericEquals": 0
},
"Next": "StartInstances_1"
}
],
"Default": "EndOfStartInstances_1"
},
"StartInstances_1": {
"Type" : "Task",
"Resource": "arn:aws:lambda:ap-northeast-1:000000000000:function:startInstances",
"TimeoutSeconds": 60,
"HeartbeatSeconds": 30,
"Next": "EndOfStartInstances_1"
},
"EndOfStartInstances_1": {
"Type": "Pass",
"End": true
}
}
},
{
"StartAt": "ChoiceStopInstances_1",
"States": {
"ChoiceStopInstances_1": {
"Type": "Choice",
"Choices": [
{
"Not": {
"Variable": "$.InstanceIds.StopInstances.Count",
"NumericEquals": 0
},
"Next": "StopInstances_1"
}
],
"Default": "EndOfStopInstances_1"
},
"StopInstances_1": {
"Type" : "Task",
"Resource": "arn:aws:lambda:ap-northeast-1:000000000000:function:stopInstances",
"TimeoutSeconds": 60,
"HeartbeatSeconds": 30,
"Next": "EndOfStopInstances_1"
},
"EndOfStopInstances_1": {
"Type": "Pass",
"End": true
}
}
}
]
}
}
},
{
"StartAt": "assumeRoles_2",
"States": {
"assumeRoles_2": {
"Type": "Task",
"InputPath": "$.arn[2]",
"Resource": "arn:aws:lambda:ap-northeast-1:000000000000:function:assumeRole",
"TimeoutSeconds": 60,
"HeartbeatSeconds": 30,
"Next": "describeInstances_2"
},
"describeInstances_2": {
"Type": "Task",
"ResultPath": "$.InstanceIds",
"Resource": "arn:aws:lambda:ap-northeast-1:000000000000:function:describeInstances",
"TimeoutSeconds": 60,
"HeartbeatSeconds": 30,
"Next": "StartStopBranches_2"
},
"StartStopBranches_2": {
"Type": "Parallel",
"End": true,
"Branches": [
{
"StartAt": "ChoiceStartInstances_2",
"States": {
"ChoiceStartInstances_2": {
"Type": "Choice",
"Choices": [
{
"Not": {
"Variable": "$.InstanceIds.StartInstances.Count",
"NumericEquals": 0
},
"Next": "StartInstances_2"
}
],
"Default": "EndOfStartInstances_2"
},
"StartInstances_2": {
"Type" : "Task",
"Resource": "arn:aws:lambda:ap-northeast-1:000000000000:function:startInstances",
"TimeoutSeconds": 60,
"HeartbeatSeconds": 30,
"Next": "EndOfStartInstances_2"
},
"EndOfStartInstances_2": {
"Type": "Pass",
"End": true
}
}
},
{
"StartAt": "ChoiceStopInstances_2",
"States": {
"ChoiceStopInstances_2": {
"Type": "Choice",
"Choices": [
{
"Not": {
"Variable": "$.InstanceIds.StopInstances.Count",
"NumericEquals": 0
},
"Next": "StopInstances_2"
}
],
"Default": "EndOfStopInstances_2"
},
"StopInstances_2": {
"Type" : "Task",
"Resource": "arn:aws:lambda:ap-northeast-1:000000000000:function:stopInstances",
"TimeoutSeconds": 60,
"HeartbeatSeconds": 30,
"Next": "EndOfStopInstances_2"
},
"EndOfStopInstances_2": {
"Type": "Pass",
"End": true
}
}
}
]
}
}
}
]
}
}
}
Cloud Watch event設定
毎時StepFunctionsのinstanceBootControlerを実行するルールを作成します
- スケジュール:0 * * * ? *
- ターゲット:Step Functions ステートマシン instanceBootControler
- ロール:新しいロールを作成
- 名前:instanceBootControler(お好きにどうぞ)
終わりに
Step Functionsは初めての利用でしたが夢が広がるサービスの予感がしますね!
Node.jsについては普段ほとんど書かないのですが、Generatorを利用することでcallback地獄から開放された感があります。
今後jsを書く機会があったら積極的に利用していこうと思いました。
同様の構成でシングルAZのRDSインスタンス起動停止も実装しようと思います。
注意点
リージョンをap-northeast-1に固定しているので、他リージョンのインスタンスを操作したい場合は利用できません。
時間しかみていないので分単位で制御したい場合には向きません。
エラー制御や失敗時のアラート発砲などは行っていません。ただしLambda自体のエラー制御は働きます。
参考
基本的な構成は下記の記事を参考にしました
https://qiita.com/d-yamanaka/items/c8ce55aa136d228c8fa2