意図せず起動したままのEC2インスタンスをSlackで警告する

# はじめに

下記の投稿で、起動したEC2インスタンスに作成者を自動タグ付けできるようになりました。

[AWS LambdaでEC2インスタンスに作成者を自動タグ付け](http://qiita.com/khiraiwa/items/2e43d0f07b582ccb3e78)

次のステップとして、意図せず起動しているEC2インスタンスについて、警告通知をSlackにPostするようにしてみました。実行についてはAWS Lambdaで自動化しています。

例えば、下記のようなメッセージをPostするようにしました。

```
起動しているインスタンスでTypeタグがdev, もしくは存在しないものをリストアップしています。
$1 = 120円換算です。
-----------------------------
CreatedUser: xxxxxxxxxx
Name: xxxxxxxxxxxxxxxxx
InstanceId: i-xxxxxxxx
InstanceType: m3.medium
Region: us-east-1
Cost:
      $ 23
      2772 円
-----------------------------
-----------------------------
CreatedUser: xxxxxxxxxx
Name: xxxxxxxxxxxxxxxxx
InstanceId: i-xxxxxxxx
InstanceType: m3.medium
Region: us-east-1
Cost:
      $ 4
      43 円
-----------------------------
```

# コード

下記のようになりました。

```js
var Log4js = require('log4js');
Log4js.configure('log-config.json');
var systemLogger = Log4js.getLogger('system');

var AWS = require('aws-sdk');
var Promise = require('bluebird');
var request = require('request');

var AWS_REGIONS = [
    'us-west-1',
    'us-west-2',
    'us-east-1'
];

var INSTANCE_TYPE_PRICES = {
    "us-west-1" : {
        "m1.small": 0.047,
        "m1.medium": 0.095,
        "m1.large": 0.19,

        "t2.micro": 0.017,
        "t2.small": 0.034,

        "m3.medium": 0.077
    },
    "us-west-2" : {
        "m1.small": 0.044,
        "m1.medium": 0.087,
        "m1.large": 0.175,

        "t2.micro": 0.013,
        "t2.small": 0.026,

        "m3.medium": 0.067
    },
    "us-east-1" : {
        "m1.small": 0.044,
        "m1.medium": 0.087,
        "m1.large": 0.175,

        "t2.micro": 0.013,
        "t2.small": 0.026,

        "m3.medium": 0.067
    }
};

var arrayMerge = function() {
    if (arguments.length === 0) {
        return false;
    }

    var i, len, key, result = [];
 
    for (i = 0, len = arguments.length;i < len; i++) {
        if (typeof arguments[i] !== 'object') {
            continue;
        }
        for (key in arguments[i]) {
            if (isFinite(key)) {
                result.push(arguments[i][key]);
            } else {
                result[key] = arguments[i][key];
            }
        }
    }
    return result;
};

var describeInstances = function(resolve, reject, ec2, params) {
    ec2.describeInstances(params, function(err, data){
        if (err) {
            reject();
        }
        var nextToken = data.NextToken;
        if (nextToken !== undefined && nextToken !== null) {
            params.NextToken = nextToken;
            describeInstances(resolve, reject, ec2, params);
        }
        return resolve(data);
    });
};

var promisedDescribeInstances = function(ec2) {
    var params = {
        Filters: [
            {
                Name: 'instance-state-name',
                Values: [
                    "running"
                ]
            }
        ]
    };
    return new Promise(function(resolve, reject){
        describeInstances(resolve, reject, ec2, params);
    });
};

exports.handler = function(event, context) {
    Promise.all(AWS_REGIONS.map(function(region) {
        systemLogger.info('次のリージョンのrunningインスタンス取得開始: ', region);
        var ec2 = new AWS.EC2({region: region, maxRetries: 15});
        return promisedDescribeInstances(ec2, null);
    })).then(function(data) {
        var reservations = {};
        for (i_d in data) {
            reservations = arrayMerge(reservations, data[i_d].Reservations);
        }
        return Promise.all(reservations.map(function(reservation) {
            systemLogger.info('タグのフィルタ開始: ');
            var typeIsNotExist = true;
            var typeIsNG = false;
            for (i_t in reservation.Instances[0].Tags) {
                if (reservation.Instances[0].Tags[i_t].Key == 'Type') {
                    typeIsNotExist = false;
                    if (reservation.Instances[0].Tags[i_t].Value == 'dev') {
                        typeIsNG = true;
                        break;
                    }
                }
            }

            if (typeIsNotExist) {
                typeIsNG = true;
            }
            if (typeIsNG) {
                return Promise.resolve(reservation.Instances[0]);
            }
            return Promise.resolve();
        }));    
    }).then(function(data){
        systemLogger.info('終了すべきインスタンスリストを取得。');
        var slackMessages = '';
        for (i_d in data) {
            if (data[i_d] !== undefined && data[i_d] !== null) {
                slackMessages += '-----------------------------\n';
                for (i_t in data[i_d].Tags) {
                    if (data[i_d].Tags[i_t].Key == 'Name') {
                        slackMessages += 'Name: ' + data[i_d].Tags[i_t].Value + '\n';
                    }
                    if (data[i_d].Tags[i_t].Key == 'createUserArn') {
                        slackMessages += 'CreatedUser: ' + data[i_d].Tags[i_t].Value + '\n';
                    }
                }

                var instanceId = data[i_d].InstanceId;
                var instanceType = data[i_d].InstanceType;
                var availabilityZone = data[i_d].Placement.AvailabilityZone;
                var launchTime = data[i_d].LaunchTime;
                slackMessages += 'InstanceId: ' + instanceId + '\n';
                slackMessages += 'InstanceType: ' + instanceType + '\n';

                var date_today = new Date();
                var date_launchTime = new Date(launchTime);
                var hours = (date_today - date_launchTime) / 1000.0 / 3600.0 ;

                var region = availabilityZone.substr(0, availabilityZone.length-1);
                slackMessages += 'Region: ' + region + '\n';

                var cost = hours * INSTANCE_TYPE_PRICES[region][instanceType];
                slackMessages += 'Cost:\n';
                slackMessages += '      ' + '$ ' + Math.round(cost) + '\n';
                slackMessages += '      ' + Math.round(cost * 120) + ' 円' + '\n';
                slackMessages += '-----------------------------\n';
            }
        }
        return Promise.resolve(slackMessages);
    }).then(function(messages) {
        systemLogger.info('Slackに送信します。');
        if (messages.length > 0) {
            messages = '$1 = 120円換算です。\n' + messages;
            messages = '起動しているインスタンスでTypeタグがdev, もしくは存在しないものをリストアップしています。\n' + messages;
        }
        systemLogger.info(messages);
        var options = {
            url: 'https://hooks.slack.com/services/xxxxxxx',
            form: 'payload={"text": "' + messages + '", "link_names": "1"}',
            json :true
        };
        return new Promise(function(resolve, reject){
            request.post(options, function(err, response, body){
                if (err) {
                    reject(err);
                }
                resolve();
            });
        });
    }).then(function(messages) {
        systemLogger.info('正常終了しました。');
        context.succeed();
    }).catch(function(err){
        systemLogger.error('エラー終了しました。');
        systemLogger.error(err, err.stack);
        context.fail();
    });;
};

// ローカル実行用
if (!module.parent) {
    var hoge = (function() {
        var hoge = function() {};
        var p = hoge.prototype;
        p.succeed = function() {};
        p.done = function() {};
        return hoge;
    })();
    var mockedContext = new hoge();
    exports.handler(null, mockedContext);
}
```

# 簡単な説明

実際にSlackにPostするときは下記のurlを正しいものに変える必要があります。

```js
        var options = {
            url: 'https://hooks.slack.com/services/xxxxxxx',
            form: 'payload={"text": "' + messages + '", "link_names": "1"}',
            json :true
        };
```

また、'us-west-1'、'us-west-2'、'us-east-1'以外のリージョンを監視するには下記にそのリージョンを追加してください。

```js
var AWS_REGIONS = [
    'us-west-1',
    'us-west-2',
    'us-east-1'
];
```

このコードではフィルタの処理を下記で行っています。
Typeというタグがdevという値のインスタンス、もしくはそのタグ自体無いものを抜き出しています。
AWS SDKのdescribeInstancesメソッドにはタグのFilterを指定できるのですが、指定したタグが無いものを抜き出すために、このようにしました。
ここを変えるだけで、それぞれの使用環境に対応できる。。。と思います。
(もう少し変更しやすい作りにしたらよかったですorz)

```js
            for (i_t in reservation.Instances[0].Tags) {
                if (reservation.Instances[0].Tags[i_t].Key == 'Type') {
                    typeIsNotExist = false;
                    if (reservation.Instances[0].Tags[i_t].Value == 'dev') {
                        typeIsNG = true;
                        break;
                    }
                }
            }
```

すごく微妙なところとして、
コストを算出したほうが面白いと思い、LaunchTimeを元に算出してみたのですが、
それぞれのリージョンごとのインスタンスタイプのコストが下記のように手打ちになっています。
いずれ自動で取ってくるようにしたいです。

```js
var INSTANCE_TYPE_PRICES = {
    "us-west-1" : {
        "m1.small": 0.047,
        "m1.medium": 0.095,
        "m1.large": 0.19,

        "t2.micro": 0.017,
        "t2.small": 0.034,

        "m3.medium": 0.077
    },
```

# 実行結果

下記、実行結果です。
xxxxxの文字列はマスクしたものです。

```
bash-3.2$ node aws-check-running-instances.js
[2016-03-21 23:37:15.933] [INFO] system - 次のリージョンのrunningインスタンス取得開始:  us-west-1
[2016-03-21 23:37:16.000] [INFO] system - 次のリージョンのrunningインスタンス取得開始:  us-west-2
[2016-03-21 23:37:16.005] [INFO] system - 次のリージョンのrunningインスタンス取得開始:  us-east-1
[2016-03-21 23:37:17.539] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.540] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.540] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.540] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.541] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.541] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.541] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.542] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.542] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.542] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.542] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.543] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.543] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.544] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.605] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.606] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.606] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.606] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.606] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.607] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.607] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.607] [INFO] system - タグのフィルタ開始: 
[2016-03-21 23:37:17.608] [INFO] system - 終了すべきインスタンスリストを取得。
[2016-03-21 23:37:17.609] [INFO] system - Slackに送信します。
[2016-03-21 23:37:17.610] [INFO] system - 起動しているインスタンスでTypeタグがdev, もしくは存在しないものをリストアップしています。
$1 = 120円換算です。
-----------------------------
CreatedUser: xxxxxxxxxx
Name: xxxxxxxxxxxxxxxxx
InstanceId: i-xxxxxxxx
Region: us-west-1
Cost:
      $ 4
      431 円
-----------------------------
-----------------------------
CreatedUser: xxxxxxxxxx
Name: xxxxxxxxxxxxxxxxx
InstanceId: i-xxxxxxxx
Region: us-west-1
Cost:
      $ 4
      467 円
-----------------------------
    (以下省略)
```

# AWS Lambdaで実行

下記、GitHubにコードを上げました。
README.md参照です。

https://github.com/khiraiwa/aws-ec2-check-running-instances

# 終わりに

この投稿に続いて、作成者のタグも付いているのでSlackに投稿するときは@のメンションも送るようにしたいと思っています。