API GatewayとLambdaでSlackのbotを運用する

以前はHubotをHeroku等のサーバーを使ってbotを運用していましたが、最近はAWSのAPI GatewayとLambdaを利用してサーバーレスでbotを作る方法が出てきてます。今回下記の記事を参考にbotを作ってみました。

サーバーレスで運用するとサーバーを建てた場合と比べてコストがかからなくなるので、botなどでは積極的に利用できると良いと思います。

今回作ったbotの内容

OpsWorksの任意のレシピを実行するbotです。任意と言っても自由にレシピを実行できてしまうとまずいので今回はNginx上のキャッシュファイルを削除するレシピを実行するようにしています。

AWSでOpsWorksを使っている人はこの方法を応用するとレシピを用意できればSlackからなんでも実行できるのでオペレーションを簡易化できます。レシピは複数のサーバーで同時に実行できるのでなかなか重宝します。

SlackのIncoming Webhookを設定する

Slackの設定画面でIncoming Webhookを設定します。

NewImage

Labmda Functionを作成する

package.jsonは下記のような感じです。package.jsonのあるディレクトリでnpm installして予め必要なNPMをインストールしておきます。

{
  "name": "slack-cache-cleaner",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "aws-sdk": "^2.2.41",
    "bluebird": "^3.3.4",
    "node-env-file": "^0.1.8"
  }
}

index.jsは下記のような感じになりました。

var https   = require('https');
var util    = require('util');
var Promise = require('bluebird');
var AWS     = require('aws-sdk');
var env     = require('node-env-file');

env(__dirname + '/.env');
var slack = {
    channelName: process.env['SLACK_CHANNEL_NAME'],
    incomingWebhookURL: process.env['SLACK_INCOMING_WEBHOOK_URL'],
    userName: process.env['SLACK_USER_NAME'],
    iconEmoji: process.env['SLACK_ICON_EMOJI']
};
var stackIds = {
    hoge_stack:   process.env['STACK_ID_HOGE']
};

AWS.config.update({
    accessKeyId: process.env['BOT_AWS_ACCESS_KEY_ID'],
    secretAccessKey: process.env['BOT_AWS_SECRET_ACCESS_KEY'],
    region: 'us-east-1'
});
var opsworks = new AWS.OpsWorks();

exports.handler = function(event, context) {

    var stackId = '';
    switch (event.text) {
        case 'clean hoge':
            stackId = stackIds.hoge_stack;
            break;
        default:
            break;
    }

    if (stackId) {

        var params = {
            Command: {
                Name: 'execute_recipes',
                Args: {
                    recipes: [
                        'nginx::purge_cache', // レシピ名を記述
                    ]
                }
            },
            StackId: stackId
        };
        opsworks.createDeployment(params, function(err, data) {

            if (err) {
                send('error').then(function(){
                    console.log('ENV', process.env);
                    context.done(err);
                });
            } else {
                send('success').then(function(){
                    context.done();
                });
            }

        });

    } else {
        context.done();
    }

};

function send(text){
    return new Promise(function(resolve, reject){
        var options = {
            method: 'POST',
            hostname: 'hooks.slack.com',
            port: 443,
            path: slack.incomingWebhookURL
        };

        var data = {
            "channel": "#" + slack.channelName,
            "username": slack.userName,
            "text": text,
            "icon_emoji": slack.iconEmoji
        };

        var req = https.request(options, function (res) {
            res.setEncoding('utf8');
            res.on('data', function (chunk) {
                resolve(null, 'success');
            });
        });

        req.on('error', function (e) {
            reject(e);
        });

        req.write(util.format("%j", data));
        req.end();
    });
}

node-env-fileで利用する環境変数用のファイルです。ハードコーディングしたくないシークレット類などをこの.envファイルに記述しておきます。

SLACK_INCOMING_WEBHOOK_URL='https://hooks.slack.com/services/xxxxx' # slackで設定したIncoming Webhook URL
SLACK_CHANNEL_NAME='bot' # シャープなしのchannel名
SLACK_USER_NAME='bot1' botの名前
SLACK_ICON_EMOJI=':dog:' # botのアイコンにしたい絵文字コード
BOT_AWS_ACCESS_KEY_ID='xxxxx'
BOT_AWS_SECRET_ACCESS_KEY='xxxxx'
STACK_ID_HOGE='xxxxx' # OpsWorksのStackのID

これらをnode_modulesもろともZIPにまとめてAWSのLamdaの画面からアップロードし登録します。

$ zip -r dist.zip .env index.js node_modules

API Gatewayを設定する

Lamdaの設定画面は省略します…。API Gatewayの設定としてはエンドポイント(URL + HTTP Method)を作成し、実際の処理はLambda Functionや他のHTTPのエンドポイントに等にひも付けを行う形になります。今回は/POSTに先ほど作成したLambda Functionをひも付けました。

NewImage

ひも付けの際にMappging Templateという設定でVelocity Template Language (VTL) という言語を使ってAPI Gatewayが受け取ったデータを変換してLambda Function等に渡すことができます。今回のSlackの場合、SlackのOutbound WebhookからデータがPOSTパラメーターの形式(MIMEタイプはapplication/x-www-form-urlencoded)でやってくるので、JSONの形式にしてLambdaに渡します。

NewImage

## convert HTML POST data or HTTP GET query string to JSON
 
## get the raw post data from the AWS built-in variable and give it a nicer name
#if ($context.httpMethod == "POST")
 #set($rawAPIData = $input.path('$'))
#elseif ($context.httpMethod == "GET")
 #set($rawAPIData = $input.params().querystring)
 #set($rawAPIData = $rawAPIData.toString())
 #set($rawAPIDataLength = $rawAPIData.length() - 1)
 #set($rawAPIData = $rawAPIData.substring(1, $rawAPIDataLength))
 #set($rawAPIData = $rawAPIData.replace(", ", "&"))
#else
 #set($rawAPIData = "")
#end
 
## first we get the number of "&" in the string, this tells us if there is more than one key value pair
#set($countAmpersands = $rawAPIData.length() - $rawAPIData.replace("&", "").length())
 
## if there are no "&" at all then we have only one key value pair.
## we append an ampersand to the string so that we can tokenise it the same way as multiple kv pairs.
## the "empty" kv pair to the right of the ampersand will be ignored anyway.
#if ($countAmpersands == 0)
 #set($rawPostData = $rawAPIData + "&")
#end
 
## now we tokenise using the ampersand(s)
#set($tokenisedAmpersand = $rawAPIData.split("&"))
 
## we set up a variable to hold the valid key value pairs
#set($tokenisedEquals = [])
 
## now we set up a loop to find the valid key value pairs, which must contain only one "="
#foreach( $kvPair in $tokenisedAmpersand )
 #set($countEquals = $kvPair.length() - $kvPair.replace("=", "").length())
 #if ($countEquals == 1)
  #set($kvTokenised = $kvPair.split("="))
  #if ($kvTokenised[0].length() > 0)
   ## we found a valid key value pair. add it to the list.
   #set($devNull = $tokenisedEquals.add($kvPair))
  #end
 #end
#end
 
## next we set up our loop inside the output structure "{" and "}"
{
#foreach( $kvPair in $tokenisedEquals )
  ## finally we output the JSON for this pair and append a comma if this isn't the last pair
  #set($kvTokenised = $kvPair.split("="))
 "$util.urlDecode($kvTokenised[0])" : #if($kvTokenised[1].length() > 0)"$util.urlDecode($kvTokenised[1])"#{else}""#end#if( $foreach.hasNext ),#end
#end
}

この設定ができたらAPIをデプロイします。デプロイするとステージ名付きのURLが生成されます。このURLからAPI GatewayのAPIを呼び出すことができます。

SlackのOutbound Webhookを設定する

Slackの設定画面でOutbound Webhookを設定します。これは任意のチャネルでメッセージが書かれたら設定したURL(今回はAPI GatewayのURL)にPOSTでパラメーターを飛ばすための設定です。

NewImage

これで設定が完了しました。ぼくの環境ではこの構成でSlackに特定のキーワードを打つと目的のOpsWorksのStackでキャッシュ削除のレシピが実行されます。

API Gateway + Lambdaの練習に最適

今回API Gatewayはじめて触りました。API Gateway + Lamdaで簡単に本当にサーバーレスでAPIが作れてしまいます。これは便利。ちょうど今日LambdaからRDS等のリソースにもアクセスできるようになったようなので、サーバーレスアーキテクチャが現実味を帯びてきたと思います。

LambdaファンクションからのVPC内リソースへのアクセス

独立した単機能のAPIだったらAPI Gateway + Lambdaは既に実用レベルで実装方法の選択肢に入ります。botは良い練習になったと思います。