AWS Lambda functionと EC2 Run Commandで、かんたんSlash Commandデプロイ

SlackのSlash Commandを使ったデプロイの実現方法は色々ありますが、今回はAWS Lambda functionとEC2 Run Commandを使ったやり方を紹介します。

# 仕組み


![スクリーンショット 2018-08-13 15.09.37.png](https://qiita-image-store.s3.amazonaws.com/0/50184/f2f2f9a7-c90d-9ef3-209f-679bf152ca5d.png)


この方式のメリットは以下になります。

* 仕組みが簡単
  * 要はSlackとLamda経由でEC2上のデプロイコマンドを叩くだけなので、わかりやすい
* AWSだけで完結する
  * Slack以外、外部サービスに依存しない(GitHubとか使ってなくてもOK)
* ローコスト
  * bot用にEC2インスタンスを用意する必要がなく、必要なときにLambda functionを呼び出すだけ
* デプロイ可能なユーザの管理をSlackだけで制限できる
  * 特定チャンネルのみでコマンド実行可能にできるので、プライベートチャンネルを作ってそこにデプロイできるユーザを集めれば、擬似的なアクセス管理が簡単に行える

**「今まで毎回手動でSSHログインしてデプロイしていたのを、Slack経由で行えるようにしたい」**という場合におすすめです。

# 1. Slash Commandを追加

まず、SlackにSlash Commandを追加します。

![スクリーンショット 2018-08-13 14.22.40.png](https://qiita-image-store.s3.amazonaws.com/0/50184/dbc4c54a-59fd-b6fd-025a-691ff19e7055.png)


`https:/{ワークスペース名}/apps/A0F82E8CA-slash-commands`にアクセスし、適当な名前でslash-commandを作成してください。ここでは`/deploy`としました。

![スクリーンショット 2018-08-10 13.18.11.png](https://qiita-image-store.s3.amazonaws.com/0/50184/7d772dd7-9957-51a7-c9a5-142f8a5684db.png)
![スクリーンショット 2018-08-10 13.18.23.png](https://qiita-image-store.s3.amazonaws.com/0/50184/8c676282-a622-30ab-94d7-ae055be48af0.png)

URLにはこの時点では適当にダミーを入れておけばOKです。
発行されたトークンを利用しますので、控えておいてください。

# 2. AWS KMSの設定

1.で作成したSlash Commandのtokenがバレると好き放題通知を叩かれてしまうので[^1]、tokenを暗号化するためにAWS KMSキーを作成します。

[^1]: 絶対に必要というよりはLambdaのBluePrint Templateに合わせてる感じでしょうか。。。

![スクリーンショット 2018-08-13 14.23.22.png](https://qiita-image-store.s3.amazonaws.com/0/50184/5fe4cd43-7f7a-3e34-5407-30732913b75f.png)


## キーの作成

「東京リージョン」を指定して「キーの作成」を行います。[^2]

[^2]: デフォルトがus-east-1になっているのでご注意ください。

![スクリーンショット 2018-08-10 13.30.09.png](https://qiita-image-store.s3.amazonaws.com/0/50184/2e12a961-1e49-c935-85d6-e42642679a27.png)
![スクリーンショット 2018-08-10 13.30.33.png](https://qiita-image-store.s3.amazonaws.com/0/50184/853b09b5-a231-c7ed-001e-83a306c4229f.png)


## 動作確認

awscliを使い、作成したキーでtokenをきちんと暗号化/復号化できることを確認します。

```
$ SLASH_COMMAND_TOKEN=cT0Uwi1eltqho4itj5h2QyXy
$ AWS_KMS_KEY=SampleSlackDeployKey
$ AWS_REGION=ap-northeast-1

// 暗号化
$ echo $SLASH_COMMAND_TOKEN
cT0Uwi1eltqho4itj5h2QyXy

$ ENCRYPT=`aws kms encrypt --region ${AWS_REGION} --key-id alias/${AWS_KMS_KEY} --plaintext ${SLASH_COMMAND_TOKEN} --query CiphertextBlob --output text`
$ echo $ENCRYPT
AQICAHj1B6d60FLb+iijclctcD7cMERl79RaxvmJ61C2WHitoQHfo1YdhI08K1qJhMfjjq/QAAAAdjB0BgkqhkiG9w0BBwagZzBlAgEAMGAGCSqGSIb3DQEHATAeBglghkgBZQMEAS4wEQQM3PoBE7CUolclOAqYAgEQgDMdPzkI1RGGBK64zdYTTEOrXIOk99Jwq4X8fE4ZljcB8g0npv/xmCSlF/yBSvY7WdWTCo4=

// 復号化
$ DECRYPT=`aws kms decrypt --region ${AWS_REGION} --ciphertext-blob fileb://<(echo ${ENCRYPT}|base64 --decode) | jq .Plaintext --raw-output |base64 --decode`
$ echo $DECRYPT
cT0Uwi1eltqho4itj5h2QyXy
```

# 3. Lambda Function、API Gatewayの作成

BluePrint[^3]をベースにLambda FunctionとAPI Gatewayを同時に作成します。

![スクリーンショット 2018-08-13 14.30.18.png](https://qiita-image-store.s3.amazonaws.com/0/50184/e218e309-152e-0ff8-4a53-5621614291b8.png)

[^3]: 設計図と和訳されてて微妙ですが…

AWS Lambda > 関数 > 「関数の作成」 から、`slack-echo-command`BluePrintを選択します。

![スクリーンショット 2018-08-10 13.46.53.png](https://qiita-image-store.s3.amazonaws.com/0/50184/f675e8c9-418a-943f-6e84-22122fbb0bb4.png)

個人的にはPython(slack-echo-command-python)がよかったんですが、LambdaのWISYWIGエディタがよくわかんないインデントエラーを出してこけたのでnodejs(slack-echo-command)にしました。

続いて、以下のように入力します。

![スクリーンショット 2018-08-10 13.49.33.png](https://qiita-image-store.s3.amazonaws.com/0/50184/a3150035-c35c-ed13-1379-0b9eb9aaea76.png)
![スクリーンショット 2018-08-10 13.50.29.png](https://qiita-image-store.s3.amazonaws.com/0/50184/6cc85b2f-c56f-001a-595b-e9f6cb4e3b89.png)

* ロールは自動作成
  * 後でIAM設定を追加しますので、ロール名を覚えておいてください
* 新規APIの作成
  * 対応するAPI Gatewayを自動作成
  * Slackから叩かれるのでセキュリティは「オープン」
  * API名とかは適当に
* kmsEncryptTokenの値を置換
  * KMSキーの動作確認で利用した、**暗号化後のtoken**を指定

## 疎通確認

ここまで出来たら、slack経由で実際にLambda functionが呼び出せるか確認しておきます。
![スクリーンショット 2018-08-13 14.32.28.png](https://qiita-image-store.s3.amazonaws.com/0/50184/181b1ed9-a48d-aed5-14cf-171405a7f85d.png)

「API Gateway」を選択すると対応するAPIエンドポイントが表示されるので、このURLを1.で作成したSlash Commandに登録します。

![スクリーンショット 2018-08-10 13.51.44.png](https://qiita-image-store.s3.amazonaws.com/0/50184/fec46fa1-690b-1b3c-a5c9-9975c64c689d.png)
![スクリーンショット 2018-08-10 13.53.18.png](https://qiita-image-store.s3.amazonaws.com/0/50184/5e1da8fa-f9d4-e250-43a1-cbc9f7cd37a8.png)

あとは実際にSlackから動作確認を行い、以下のようにecho出力されていればOKです。

![スクリーンショット 2018-08-10 14.58.28.png](https://qiita-image-store.s3.amazonaws.com/0/50184/f5a58120-c34d-4333-f7b1-8a975f81a536.png)


# 4. EC2 Run Commandの動作確認

続いてEC2上のデプロイコマンドを、直接叩けるようにします。方法は色々ありますが、ここではLambdaから呼び出すことを想定し、EC2 Run Commandを利用します。

![スクリーンショット 2018-08-13 14.34.13.png](https://qiita-image-store.s3.amazonaws.com/0/50184/cf0eb1c1-bb45-d4b4-78d8-c698739f30b0.png)


EC2 Run Commandを使うにはSSM権限が必要なので、EC2インスタンスに付与するIAM Roleを作成します。

AWS IAM > ロール > 「ロールの作成」 

![スクリーンショット 2018-08-10 14.01.39.png](https://qiita-image-store.s3.amazonaws.com/0/50184/d420d77f-56fa-a995-1ac8-61d3430103f4.png)

「SSM」で検索し、`AmazonEC2RoleforSSM`を付与。

![スクリーンショット 2018-08-10 14.02.05.png]
(https://qiita-image-store.s3.amazonaws.com/0/50184/60bec268-71f1-54b6-c3e2-98561347dd06.png)

![スクリーンショット 2018-08-10 14.03.08.png](https://qiita-image-store.s3.amazonaws.com/0/50184/c80cc467-b290-5dce-ec10-42b669721efe.png)

作成したIAM Roleを、実行対象のEC2に割当てます。

![スクリーンショット 2018-08-10 14.04.08.png](https://qiita-image-store.s3.amazonaws.com/0/50184/1c2c55a1-e4f0-497d-983b-221d371b629b.png)

Amazon Linux AMIならamazon-ssm-agentがデフォルトで起動していますが、付与したRoleを有効化するにはssm-agentの再起動が必要です。

以下のように手動でssm-agentを再起動するか、EC2インスタンスごと再起動してください。

```
[ec2-user@ip-172-31-3-110 ~]$ sudo restart amazon-ssm-agent
amazon-ssm-agent start/running, process 28419
```

## 動作確認

EC2コンソールから動作確認を行います。ここでは`hostname`コマンドを実行させてみます。

AWS EC2 > コマンドの実行 > 「コマンドを実行」

![スクリーンショット 2018-08-10 14.10.45.png](https://qiita-image-store.s3.amazonaws.com/0/50184/3bd9be6c-4f41-d8ad-615b-4f1992f1dc6f.png)

結果は以下のようにして確認します。

![スクリーンショット 2018-08-10 14.13.35.png](https://qiita-image-store.s3.amazonaws.com/0/50184/1f309f17-3000-72df-cab2-77ad779f5909.png)
![スクリーンショット 2018-08-10 14.13.54.png](https://qiita-image-store.s3.amazonaws.com/0/50184/f8bb350b-5f7a-2453-90d4-eaa2a46d81a7.png)

2500文字しか出力されないということなので、場合によっては以下のようにS3に吐き出させることになるかもしれません。

![スクリーンショット 2018-08-10 14.16.27.png](https://qiita-image-store.s3.amazonaws.com/0/50184/886f104e-47c5-b405-0b8e-5aa5cf719d7b.png)
![スクリーンショット 2018-08-10 14.17.11.png](https://qiita-image-store.s3.amazonaws.com/0/50184/a1c87626-c64c-7c14-089d-6b0e275b8883.png)

なお、コマンドはrootユーザで実行されるのでご注意ください。デプロイ対象によっては適宜`/sbin/runuser -l $user`等を使う必要がありそうです。

このように試行錯誤する場合、awscliでやった方が楽かもしれません。お好みで。

```shell
// whoamiコマンドをec2-userで実行する例
$ AWS_REGION=ap-northeast-1
$ EC2_INSTANCE_ID=i-002c4696d9983f352
$ PARAMETERS=$'{"commands":["/sbin/runuser -l ec2-user -c \'whoami\'"],"executionTimeout":["3600"]}'

// 実行
$ COMMAND_ID=`aws ssm send-command \
 --document-name "AWS-RunShellScript" \
 --instance-ids $EC2_INSTANCE_ID  \
 --parameters $'{"commands":["/sbin/runuser -l ec2-user -c \'whoami\'"],"executionTimeout":["3600"]}'  \
 --timeout-seconds 600 \
 --region $AWS_REGION \
  | jq -r .Command.CommandId`

$ echo $COMMAND_ID
1cf72cca-8a22-4d45-91ee-4e0c9550ef62

// 結果確認
$ aws ssm list-command-invocations --command-id $COMMAND_ID --region $AWS_REGION --details \
  | jq .CommandInvocations[0].CommandPlugins[0].Output
"ec2-user\n"
```

# 5. Lambda FunctionからEC2 Run Commandの呼び出し

## Lambda FunctionへのSSM権限付与

あとはLambda Functionから上記のEC2 Run Commandを使うだけなのですが、Lambda FunctionにSSMへの権限を与える必要があります。

![スクリーンショット 2018-08-13 14.35.43.png](https://qiita-image-store.s3.amazonaws.com/0/50184/fedf7311-4680-da98-1062-98cc2864b929.png)

Lambda Function作成時に作成したロール(`SampleSlackDeployRole`)を選択し、以下のようなインラインポリシーを追加します。

AWS IAM > ロール > SampleSlackDeployRole

```policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ssm:SendCommand"
            ],
            "Resource": "*"
        }
    ]
}
```

![スクリーンショット 2018-08-13 15.23.39.png](https://qiita-image-store.s3.amazonaws.com/0/50184/c8a2474e-3590-0978-c4f1-774fb36df375.png)


## AWS SDKによるEC2 Run Commandの処理を追加

ここまで出来たら、こんな感じでLambda Functionを書き換えればOKです。

```lambda-function.js
(略)
const AWS = require('aws-sdk');
const qs = require('querystring');

const kmsEncryptedToken = process.env.kmsEncryptedToken;
let token;

// EC2 Run Commandを追加
function processEvent(event, callback) {
    const params = qs.parse(event.body);
    const requestToken = params.token;
    if (requestToken !== token) {
        console.error(`Request token (${requestToken}) does not match expected`);
        return callback('Invalid request token');
    }

    const user = params.user_name;          // slash-commandの実行ユーザ
    const command = params.command;         // slash-command名
    const channel = params.channel_name;    // slash-commandが実行されたチャンネル名
    const commandText = params.text;        // slash-commandのコマンド名以降({$stage}を想定)

    // 特定のチャンネル以外で実行された場合
    if (channel !== 'private-deploy') {
        callback(null, '「private-deploy」以外のチャンネルでは実行できません。');
    }
    
    // EC2 Run Commandの実行ターゲット
    const target_instance_ids = ['i-002c4696d9983f352'];

    // 実行コマンド組み立て
    const cmd = `echo "/opt/deploy.sh ${commandText}"`; // FIXME: remove echo 
    const ec2_run_command = `/sbin/runuser -l ec2-user -c \'${cmd}\'`;

    // EC2 Run Command 設定
    const ssm = new AWS.SSM();
    const ssm_send_command_params = {
        DocumentName: 'AWS-RunShellScript',
        InstanceIds: target_instance_ids,
        Parameters: {
            'commands': [
                ec2_run_command,
            ],
        },
        TimeoutSeconds: 3600    // 1 hour
    };

    ssm.sendCommand(ssm_send_command_params, function(err, data) {
        if (err) {
            console.log(err, err.stack); // an error occurred
            return callback('SSM sendCommand Failed.');
        } 
        console.log(data);           // successful response
        const url = `https://ap-northeast-1.console.aws.amazon.com/systems-manager/run-command/${data.Command.CommandId}?region=ap-northeast-1`
        callback(null, `デプロイコマンドを実行しました! url=${url}`);
    });
}

// Original Blue Template
exports.handler = (event, context, callback) => {
(略)
```

ここでは実行結果への参照用URLを生成し、それを返却しています。

あとはslackからSlash Commandを入力し、デプロイ先のサーバで指定したコマンドが呼び出されていればOKです。お疲れ様でした！

<img width="1050" alt="スクリーンショット 2018-08-13 14.53.13.png" src="https://qiita-image-store.s3.amazonaws.com/0/50184/d87a469c-2126-b4f9-5902-7df1c986e335.png">

![スクリーンショット 2018-08-13 14.55.41.png](https://qiita-image-store.s3.amazonaws.com/0/50184/85958df1-8b0b-951b-6893-3f7b1d783fe9.png)

# ハマりどころ

## Slash Commandの3秒制限

Slash Commandの仕様として、指定したurlにPOSTしたあと、3秒以内にレスポンスが帰ってこないとタイムアウトになります。

Lambda functionのデフォルト設定メモリ128MBだと、初回起動時だけ[^4]、このタイムアウトに引っかかることがあるので、256MBにしたらとりあえず大丈夫になりました。Lambda functionのタイムアウトの値もこの制限に合わせて3秒になっている感じ。

[^4]: Lambdaのウォームアップとかその辺周りっぽい

![スクリーンショット 2018-08-13 14.49.08.png](https://qiita-image-store.s3.amazonaws.com/0/50184/123eae06-0908-e82b-1c6a-7ed62f3a5010.png)

通常、デプロイコマンドの実行には3秒以上かかると思うので、Lambda functionではデプロイコマンドの呼び出しだけを責務とし、処理結果の通知等を行う場合はLambda functionではなくデプロイコマンド側で行う必要があります。ご注意ください。(ssm list-command-invocations相当の処理をLambda上でやると簡単にタイムアウトになります)

---

参考:

* [【新機能】AWS LambdaにSlack連携のBluePrintが登場。ChatOpsがより手軽に ｜ Developers\.IO](https://dev.classmethod.jp/cloud/aws/slack-integration-blueprint-for-aws-lambda/)
* [東京リージョンでEC2 Run Command \(Linux\)を使ってみました \| AWSやシステム・アプリ開発の最新情報をお届け｜クロスパワーブログ](http://xp-cloud.jp/blog/2016/01/27/1248/)