SlackからEC2の起動/停止をサーバレスでしてみる

# 目的
EC2はずっと起動しているとやはりお金がかかってくるので、こまめに起動したり停止したりしたいものです。
ただAWSコンソールからとなると面倒です。
そのため今回、EC2の起動停止をSlackのスラッシュコマンドから操作してみようと思います。
サーバレスでEC2操作している記事が見つからなかったので、API GatewayとLambdaを使ってやってみました。

# 構成
![3E074EE3-79A8-4A98-90A3-434FE1368B2C.jpeg](https://qiita-image-store.s3.amazonaws.com/0/14266/8f3f266d-b4cb-1eb2-419f-79cf711dbbf5.jpeg)
Slackから投げたリクエストは、早くレスポンスしてやらないとタイムアウトになってしまいます。そのため、1つ目のLambdaで2つ目のLambdaを非同期でコールし、200を返しています。2つ目のLambdaがEC2の操作をした後、結果をIncoming WebhookでSlackに返します。

# 構築
## Slackアプリケーションの作成
Slackにログインした状態で、[Slack Api](https://api.slack.com/)から`Your Apps`、`Create New App`と進んでいけば作成できます。細かい内容は割愛します。
これで`Verification Token`が取得できます。
次に、左のリストの`Features`から`Incoming Webhooks`を選択し、Onにすることで`Webhook URL`を取得します。

## Lambda用のポリシー、ロールの作成
Lambdaで利用するためのロールを作成します。
適当な名前で、下記のポリシーを作成します。

```json

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:*",
                "lambda:*"
            ],
            "Resource": "*"
        }
    ]
}
```
次に、`ロールの作成`から、`Lambda`を選択し、アクセス権限で先ほど作成したポリシーにチェックをつけて、ロールを作成します。

## KMSの作成
Lambdaで利用するTokenなどを暗号化したいので、KMSを利用します。
カスタマー管理型のキーを適当なエイリアス名で作成します。
`キーの管理アクセス許可を定義`と`キーの使用アクセス許可を定義`には、AWSログイン用の自身のユーザと先ほど作成したロールを選択しておきます。
![kms-admin,user.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14266/ca229ab6-893d-bd4f-0dda-63c29840e5b5.png)

## Lambdaの作成(API Gateway受け)
まず、API Gatewayから直接リクエストを受けるLambdaを作成します。
`関数の作成`から`一から作成`を選択し、情報を埋めて関数を作成します。
Pythonは`3.7`、実行ロールは先ほど作成したロールを選択します。
![lambda-make.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14266/71de7d57-06dd-a2be-aeec-53441186fc90.png)

関数の画面に遷移したとき、デザインは下記のようになっているかと思います。
[2019/04/27修正]
![api-design.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14266/88f1a2b5-8626-e856-ef42-9a7c25e9a8c7.png)
トリガーがまだない状態なので、`トリガーの追加`から`API Gateway`を選択してください。
すると、`トリガーの設定`が出てくるので、下記のように設定して、右下の追加を押してください。

| 項目| 設定値|
| --- | --------|
| API | 新規APIの作成|
|セキュリティ | オープン|
|API名 | slack-ec2(任意)|
|デプロイされるステージ | v1(任意)|

この状態で、一旦保存をしておきます。

次に、DesignerからこのLambda関数を選択し、関数を設定します。(上記デザインの場合、slack-ec2)
関数はインラインだと、使えないパッケージがあったり見づらいので、zipアップロードを利用します。
コードはgithubに置いてあります。
https://github.com/hirano00o/serverlessSlacktoEC2/blob/master/lambda_function.py

### 使い方

```bash

$ git clone https://github.com/hirano00o/serverlessSlacktoEC2.git
$ cd serverlessSlacktoEC2
$ pip install boto3 -t .
$ zip -r lambda_function.zip *
```

上記で作成した`lambda_function.zip`をアップロードします。

次に、環境変数を設定します。コード中で`SLACK_TOKEN`を利用していますが、これが、最初に作成したSlackのVerification Tokenとなります。

```python

ENCRYPTED_TOKEN = os.environ['SLACK_TOKEN']
```
`キー`に`SLACK_TOKEN`、`値`にVerification Tokenを入れます。
キーの暗号化は、`暗号化の設定`を開き、`伝送中の暗号化のためのヘルパーの有効化`にチェックを付けて、先ほど作成したKMSのエイリアスを選択します。
また、`保管時に暗号化する AWS KMS キー`に`カスタマーマスターキーの使用`を選択し、同様にエイリアスキーを選択します。
そして、先ほど入力したキーの右にある`暗号化`ボタンを押して、キーを暗号化します。
![SLACK_TOKEN.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14266/a879c419-69ff-1f3a-8a01-e53c1f8b7c6a.png)


あとは保存すれば、このLambdaに関しては終わりです。

ざっくりと、このLambdaでやっていることの概要です。

```python
def lambda_handler(event, context):
    reqBody = event['body']
    params = urllib.parse.parse_qs(reqBody)
    token = params['token'][0]
```
`event['body']`にSlackからのリクエストボディが入っています。
これをパースして必要な情報を取得します。
今回は、自分が設定したSlackのAppからのリクエストかどうかを判断するために、トークンを取得しておきます。
このトークンと、暗号解除した環境変数に設定したトークンとが合致するかを判定します。
合致しなかったら、さようなら。

```python
if token != DECRYPTED_TOKEN:
    logger.error("Request token (%s) does not match exptected", token)
    raise Exception("Invalid request token")
```
そして、プロセスIDを作成、テキストやチャンネルIDを取得して、非同期でもう1つのLambda(EC2操作)を呼び出します。

```python
awsLambda = boto3.client('lambda')
response = awsLambda.invoke(
    FunctionName='operateEc2',
    InvocationType='Event',
    Payload= json.dumps({
        "text": commandText,
        "channel_id": channelId,
        "process_id": processId,
        "start_unix_time": startUnixTime
    })
)
```
`FunctionName`は、呼び出すLambdaの関数名です。
`InvocationType`は、Lambdaをどう呼び出すかの設定です。`Event`は非同期での呼び出しになります。
`Payload`は、呼び出すLambdaに渡したいキーと値をJSONで設定します。
InvocationTypeについては、[クラスメソッドさんの記事](https://dev.classmethod.jp/cloud/aws/lambda-idempotency/)に詳しく書いてあります。

ちなみに、プロセスIDやチャンネルIDは何に利用するかというと、後ほどEC2の操作が完了した後に、Slackに先に返したメッセージ↓を削除するためです。

```python
return {
    "text": "実行中です...\nID:" + processId,
    "response_type": "in_channel"
}
```

## Lambdaの作成(EC2操作)
[2019/04/29修正]
基本的には先ほどと同じように作成しますが、さっきと異なる点として、トリガーは設定しません。
ちょうど、上で示したデザインの画像の通りになります。
[2019/05/12追記]
また、Lambda関数名は、API Gateway受けのLambda関数内で`operateEc2`を起動するように指定しているので、`operateEc2`という名前で作成しない場合は、
[serverlessSlacktoEC2/lambda_function.py at master · hirano00o/serverlessSlacktoEC2](https://github.com/hirano00o/serverlessSlacktoEC2/blob/master/lambda_function.py#L31)
の`operateEc2`を適宜変えてアップロードし直してください。

EC2操作のLambda関数の全体のコードはこちらになります。
https://github.com/hirano00o/serverlessLambdatoEC2/blob/master/lambda_function.py

環境変数は、4つ設定します。
まずは、Incoming WebhookのURL(`https://hooks.slack.com/services/Txxxxxx/Byyyyyyy/zzzzzzzzz`)の、Tから始まる値、Bから始まる値、そして一番最後の値です。
これらをそれぞれ、`T_TOKEN`、`B_TOKEN`、`WEBHOOK_TOKEN`という変数名で設定しKMSで暗号化します。(`https://hooks.slack.com/services/T_TOKEN/B_TOKEN/WEBHOOK_TOKEN`という形になります。)

次に、Slackのメッセージを削除するために必要な`LEGACY_TOKEN`を取得して設定します。
`LEGACY_TOKEN`は、https://api.slack.com/custom-integrations/legacy-tokens から取得できます。
これらも先ほどと同様にKMSによる暗号化を施して、関数を保存します。

上記より、環境変数の箇所は下記のようになります。# 順番は気にしないでください。。
![EC2操作.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14266/0d5ea5a5-3a22-b17d-cab7-a77ca4b616a0.png)


コードについて簡単に説明しますと。。。
最初にSlackから必要なコマンドが送られてきているか確認しています。
例えばSlackで`/ec2 stop i-xxxx`と入力すると、`command_text = event["text"]`には`stop i-xxxx`が入ります。

`Command(command_text)`でインスタンスを生成するとともに[コンストラクタ(33-39行目)](https://github.com/hirano00o/serverlessLambdatoEC2/blob/master/lambda_function.py#L33-L39)を呼び出し、`command_text`をスペースで区切り、クラス内変数の`command`と`param`にそれぞれ代入します。
先の例でいうと、`command`に`stop`、`param`に`i-xxxx`が入ります。

クラス内変数`command`が`commandList`にあるか確認を`commandProcess.ok_command()`で行います。`commandList`にあれば、`command`に沿った処理を行い、なければ`help`メソッドを実行します。

boto3については、[AWS SDK for Python | AWS](https://aws.amazon.com/jp/sdk-for-python/)を確認ください。

```python
commandProcess = Command(command_text) # command_textをスペースで分割し、クラス内変数commandとparamを設定
if commandProcess.ok_command() == False: # クラス内変数commandがグローバル変数commandListにあるか確認
    commandProcess.help_command() # なければヘルプコマンドとしてを処理
else:
    ec2 = boto3.client('ec2')
    if commandProcess.command == 'help':
        commandProcess.help_command()
    elif commandProcess.command == 'stop':
        commandProcess.stop_command(ec2)
```

stop処理`commandProcess.stop_command(ec2)`が呼ばれた場合です。クラス内変数`param`をインスタンスIDとしてEC2の停止処理`ec2.stop_instances()`を実行します。
停止処理が失敗した場合、`except`以下が実行され、ログにエラー内容を残すとともに、エラーメッセージをSlackに投稿(IncomingWebhook)します。
成功した場合は、`else`以下が実行され、成功時のメッセージをSlackに投稿(IncomingWebhook)します。

ec2でどのようなメソッドがあるかは、[EC2 — Boto 3 Docs 1.9.137 documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2.html)を確認ください。

```python
def stop_command(self, ec2):
    if self.param == '':
        return 'put instance id. ex stop i-xxxxxxxxx.'
    try:
        status = ec2.stop_instances(InstanceIds=[self.param])
    except ClientError as e:
        logger.error(e.response["Error"])
        return postSlack(e.response["Error"]["Message"])
    else:
        return postSlack(self.param + " is stopped.\nstatus is " + str(status["ResponseMetadata"]["HTTPStatusCode"]))
```

Slackへの投稿(IncomingWebhook)はこの通りです。
気を付けないといけないのは、リクエストで送るJSONをエンコードしておかないといけないところですかね。エンコードしないとエラーとなります。

```python
def postSlack(text):
    request = urllib.request.Request(
        WEBHOOK_URL,
        json.dumps({
            'text': text
        }).encode(),
        {
            'Content-Type': 'application/json'
        }
    )
    with urllib.request.urlopen(request) as response:
        returnData = response.read()
        logger.info(returnData)
```


ここまでが主な処理で、あとは最初に返したメッセージを削除`deletePrePost()`するだけです。

まず、チャンネルのヒストリをチャンネルヒストリAPI([channels.history method | Slack](https://api.slack.com/methods/channels.history))で取得します。
そして、取得したヒストリのメッセージを1つずつ確認し、プロセスIDをもとに、Lambda(API Gateway受け)でSlackに返却したメッセージを検索します。
該当のメッセージが見つかれば、削除API([chat.delete method | Slack](https://api.slack.com/methods/chat.delete))を利用して、メッセージを削除します。
これらのAPIを利用するために、Slackの`LEGACY_TOKEN`が必要になります。

チャンネルヒストリAPI([channels.history method | Slack](https://api.slack.com/methods/channels.history))を利用するときに、`params`に`oldest`を設定すると、指定した時間以降のメッセージを取得できるそうです。
これを設定しないとチャンネルのメッセージ全てを取得してしまうので、処理時間が長くなってしまいます。

```python
params = {
    'channel': channel,
    'token': DECRYPTED_LEGACY_TOKEN,
    'oldest': startUnixTime
}
```

## API Gatewayの作成
[2019/04/27修正]
API Gatewayは、Lamda関数でトリガーを設定したときに作成されている状態です。
しかしこのままだと、Slackからの値をLambda関数でうまく拾えないので、設定を変更していきます。

リソースの`ANY`から、`統合リクエスト`を選択します。
`Lambda プロキシ統合の使用`にチェックが入っているので、外します。このときにダイアログが2回ほど出てきますが、すべてOKで進めます。
チェックを外すと項目がいくつか増えます。一番下の`マッピングテンプレート`を選択し、`テンプレートが定義されていない場合 (推奨)`をチェックします。
`マッピングテンプレートの追加`から、`application/x-www-form-urlencoded`を入力してチェックマークで保存します。
application/x-www-form-urlencodedを選択し、`{ "body": $util.urlDecode($input.json("$")) }`を入力し、保存します。
![mapping.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14266/95db7d49-ab35-4d63-06d9-4a83fd97344d.png)

最終的に下記のようなリソースツリーとなっているかと思います。
![resource.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14266/a1be42dc-24bc-f476-a1f0-cc256f8250aa.png)

あとは、アクションからデプロイを選択し、ステージを選択してデプロイします。
デプロイしたときに出力されたURLは、スラッシュコマンドのリクエストURLに設定するときに利用します。
実際に設定するURL体系は、上記例を基にすると`https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/[ステージ名]/slack-ec2`となります。

## スラッシュコマンド作成
最初のSlackアプリケーション作成でアクセスしたページの`Slash Commands`から`Create New Command`で作成します。
ここの`Request URL`に、先ほどのURL(`https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/[ステージ名]/slack-ec2`)を入力します。
細かい内容は割愛します。

# 完成
こんな感じになります。
stop状態でstopしても200で返ってくるそうです。
![playgif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14266/723c39ea-d4a2-b846-fdf5-b54202bae7a1.gif)