APIGateway+Lambdaを使って、slackからEC2インスタンス操作する

社内でslackから開発機とか起動できたら楽だよね。という会話をしていて実際に作ってみました。

## やること
* slackの[Outgoing Webhooks](https://api.slack.com/outgoing-webhooks)を使って特定のチャンネルからEC2インスタンスの一覧取得、起動、停止を行う。
* 起動と停止は開発機など一部のインスタンスに限定する。

## 動作環境
* Serverless Framework v1.7.0
* nodejs v4.3.2
* slack

## Outgoing Webhooksの設定

https://your-team.slack.com/apps/manage/custom-integrations
のOutgoing WebhooksからAdd Configurationで作成。

* Channel：投稿を取得するチャンネル
* Trigger Word(s)：webhooksを呼び出す単語。今回は `$server` としています
* URL(s)：送信先になるURL。一旦は仮置きで問題ありません。

を設定します。
Tokenは今回作成したIntegrationから送信されたものかを確認するためのものになります。
POSTされるデータは

```
token=1IA4G7C3ay7CRVdZi0kGGkwO
team_id=T0001
team_domain=example
channel_id=C2147483705
channel_name=test
timestamp=1355517523.000005
user_id=U2147483697
user_name=Steve
text=googlebot: What is the air-speed velocity of an unladen swallow?
trigger_word=googlebot:
```
といった形式になります。tokenの検証とtextのパースを行えば良さそうです

## APIGateway+Lambdaの設定
serverless frameworkを使ってこれらを構築します。

serverless.yml に受信するためのLambdaを記述します。

```serverless.yml
functions:
  slack:
    handler: slack/handler.slack
    events:
      - http:
          path: slack
          method: post
```
今回は `/slack` というパスでリクエストを受け付けることにします。
`handler` に記述したように、 `slack/handler.slack` というファイルを作成します。

```slack/handler.js
module.exports.slack = (event, context, cb) => {
  return cb(null, JSON.stringify(event));
}
```
ここまでで一度デプロイしてendpointを作成します。

```
% serverless deploy -s dev
```

このように作成されるので、作成されたURLをOutgoing WebhooksのURL(s)に設定します。

![スクリーンショット 2017-02-21 16.31.01.png](https://qiita-image-store.s3.amazonaws.com/0/63155/e70cab12-eb04-b819-aa90-68e93c30432c.png)

## Lambdaの実装

aws-sdkを使ってec2インスタンスの一覧取得、起動、停止をする処理を記述していきます。

### インスタンス一覧の取得

```ec2.js
'use strict'

const aws = require('aws-sdk');
const ec2 = new aws.EC2({region: 'ap-northeast-1'});

module.exports.describeInstances = function*() {
  try {
    const data = yield ec2.describeInstances().promise();
    return data;
  } catch (err) {
    throw err;
  }
}
```

これでインスタンスの取得ができます。
戻り値そのままだと余計な情報も多いのでインスタンスID、Nameタグ、stateのみを取得することにします。

```
function parseInstanceObj(data) {
  let instances = [];
  data.Reservations.forEach(reservation => {
    reservation.Instances.forEach(instance => {
      let name = 'unknown server';
      if (instance.Tags.length > 0) {
        name = instance.Tags.reduce((prev, current) => {
          return current.Key === 'Name' ? current.Value : prev;
        }, instance.Tags[0].Value);
      }
      console.log('server name: ', name);
      instances.push({
        instanceId: instance.InstanceId,
        state: instance.State.Name,
        name : name
      });
    });
  });
  return instances;
}
```

これで、インスタンスID、Nameタグ、インスタンスの状態（running、stopped、pendding）のみを取得できました。

### インスタンス一覧を返すレスポンスの作成

インスタンスの情報が取得できたので、slack側へ返すレスポンスを作成します。
Responding に書いてあるように

```
{
"text": "African or European?"
}
```
形式のレスポンスを返します。

```slack/handler.js
'use strict';

const co = require('co');
const _ = require('lodash');
const ec2 = require('ec2');

module.exports.slack = (event, context, cb) => {

  co(function*() {
    const result = yield ec2.describeInstances();
    let text = '';
    result.forEach(instance => {
      text = `${text}${instance.name}: ${instance.state}\n`;
    });
    const response_body = {
        text: text
    };
    const response = {
      statusCode: 200,
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Content-Type': 'application/json;charset=UTF-8'
      },
      body: JSON.stringify(response_body)
    }
    return cb(null, response);
  });
}
```

このLambdaをデプロイ後、slack上で `$server` と発言すると、

```
dev-web01： running
```
などとslack上で結果が得られます。

### 起動、停止の権限制御
一覧が取得できるようになったので、サーバ名ベースで起動や停止を行えるようにします。
ただ、slack上から気軽に本番環境のインスタンスなどを停止されてしまうと困るので、
`slack` というタグがついているインスタンスのみを起動、停止の対象とします。
以下のようにすることで、Lambdaに制限をかけられます。

```serverless.yml
  iamRoleStatements:
    - Effect: "Allow"
      Action:
        - "ec2:describe*"
      Resource:
        - "*"
    - Effect: "Allow"
      Action:
        - "ec2:RebootInstances"
        - "ec2:StartInstances"
        - "ec2:StopInstances"
      Resource:
        - "arn:aws:ec2:*"
      Condition:
        "ForAnyValue:StringEquals":
          "ec2:ResourceTag/slack": ""
```

Conditionで `ec2:ResourceTag` を指定してやることでタグ単位で操作の制御が可能になります。

### Nameタグベースでのインスタンス情報取得
インスタンスIDをPOSTすれば処理は楽なのですが、インスタンスIDを覚えるのは大変なのでNameタグベースで起動、停止を行えるようにします。

```ec2.js
module.exports.describeInstancesByName = function*(name) {
  if (!name) throw new Error();
  const params = {
    Filters: [
      {
        Name: 'tag-key',
        Values: ['Name']
      },
      {
        Name: 'tag-value',
        Values: [name]
      },
      {
        Name: 'tag-key',
        Values: ['slack']
      }
    ],
    DryRun: false
  }
  try {
    const data = yield ec2.describeInstances(params).promise();
    return parseInstanceObj(data);
  } catch (err) {
    throw err;
  }
}
```

こうしてやることで、Nameタグに指定された値を持ち、slackというタグがついているインスタンス情報の取得ができます。
あとはこの戻り値を元に `startInstances`、`stopInstances` を呼出してあげればいいことになります。

## 確認

<img width="238" alt="スクリーンショット 2017-02-21 17.14.27.png" src="https://qiita-image-store.s3.amazonaws.com/0/63155/f0f6a396-2b43-25d4-4015-e4be934f0af0.png">

slack上で `$server status` と発言することでインスタンスの一覧を返してくれるようになりました。
あとはこの一覧ベースで `$server [server name] start` などをしてあげれば任意のサーバの起動などもできます。

コマンドの使い方は
`$server (status | help | [server name] start | [server name] stop)`
といった風にしてあります。

## 最後に
かなり飛び足でしたが、slack経由でec2インスタンスを操作する方法を紹介しました。
サーバレスアーキテクチャに触れるための第一歩としてこういったツール群を作ってみても面白いかもしれません。

今回作成したコードはgithubでも公開していますので、何かの参考になればと思います。

[github](https://github.com/quartette/slack-ec2-sample)