AWS
TypeScript
Slack
webpack
serverless

TypeScript, webpack, serverlessでSlackのスラッシュコマンドを実装する

この記事は ハンズラボ Advent Calendar 2017 15日目の記事です。

まえおき

devとopsが異なるチームでopsが不定期にデータを抽出・集計したい時にopsが利用できるツールやアプリがあれば依頼が無くなりお互い幸せですよね

・・・というのはわかっていたものの中々腰が上がらなかったのですが、serverlessフレームワークを利用してみたかったので丁度よい題材という事で今回Slackのスラッシュコマンドを作成した話です。

Why スラッシュコマンド

SPAなどのWebアプリケーションを作成するのも一つの手ですが、Slackのスラッシュコマンドでどんなスラッシュコマンドを実行したのかも含めて結果を表示できれば、チームメンバーは他のメンバーがどういったコマンドを実行してタスクをこなしているのかを見る事が出来ます。

これにより他のメンバーも、使い方を理解してそのコマンドを使っていくことが出来ます。
背中を見て育つって寸法です。

立ちはだかる3秒タイムアウト

serverlessを利用してAWSという事で構成はAPI Gateway + Lambdaです。

今回始めてスラッシュコマンドを作成したのですが、スラッシュコマンドは3秒以内に応答を返さなければタイムアウトになってしまいますが、LambdaからDynamoやS3のデータを処理するにしても大体3秒以上かかるものです。

タイムアウトになってしまうと、スラッシュコマンドの実行者がどんなコマンドを実行したのかが、他の人には見えてきませんので、今回の目的としては3秒以内に応答を返す必要があります。

参考: https://api.slack.com/slash-commands

絶対3秒以上かかるんだけど、どうすんのってことですがSlackからのリクエストデータに response_url パラメータがあり、このURLへデータを送信する事で3秒が経過した後にも応答を表示する事が出来ます。

これを利用して、API Gatewayに接続されているLambdaから他のLambda functionを非同期で呼び出し、3秒以内にレスポンスを返し、非同期実行されたfunction側で response_url へ処理結果を返す形にすると目的が達成出来ます。

(3秒以内にコールバックを呼び出して処理を継続して response_url に応答すればいいんじゃないかと思って最初実装していたのですが、Contextが終了しないとLambdaはAPI Gatewayに応答しなかったのでLambda functionを分けて非同期呼び出しにしてやる必要があります)

http://docs.aws.amazon.com/ja_jp/lambda/latest/dg/nodejs-prog-model-context.html

デフォルトでは、コールバックは Node.js ランタイムのイベントループが空になるまで待機してから
処理を停止し、呼び出し元に結果を返します。

図にするとこんな感じになります。

AWS Icons.png

npm_modulesブラックホール

今回の構成だとAPI Gatewayから起動されるLambdaは他のLambdaを呼び出し、3秒以内にレスポンスを返すだけですので、ほぼnpmモジュールが必要ありませんし3秒以内に応答するためにコンテナの起動速度は可能な限り高速化したいのでサイズを小さくしたい欲求があります。

というわけで理想はそのfunctionにはnpmモジュール無しで、各function毎に必要なモジュールのみをひっぱってきてzipして欲しいのですが、serverlessフレームワークはデフォルトだと node_modules をそのまま全functionにぶっこんできます。ひどい。

serverlessフレームワークのissueで過去に議論され、packageセクションに individually オプションを追加し、各functionでinclude, excludeを追加できるような対応となっていました。ですが、最近の node_modules のディレクトリ構成は階層がフラットなのでfunction毎に依存するモジュールだけを列挙するのは現実的ではありません。(そうじゃなくても現実的ではないです)

救世主webpack plugin

「各function毎に必要なモジュールを解決してまとめて欲しい」・・・そう、最早フロントエンドには欠かせないモジュールバンドラの出番ですね。

webpackでバンドルして、それをserverlessでアップロードして〜というタスクを書いてもいいんですが丁度良くserverlessのwebpackプラグインというのがあります。

individually: true と共にserverless-webpackプラグインを利用すると各function handler毎に依存しているモジュールをバンドルしてくれます。これで各functionのpackageディレクティブには何も記述する必要はなくなりました。やったね!

serverless.yml
plugins:
  - serverless-webpack

package:
  individually: true

functions:
  index: # <- API Gatewayから呼ばれるfunc
    handler: src/index/index.handler
    description: "endpoint of /checkpos"
    events:
      - http:
          path: checkpos
          method: post
  barcode: # <- indexからinvokeされるfunc
    handler: src/subcommand/barcode.handler
    memorySize: 832
    timeout: 300
    description: "barcod subcommand of /checkpos"
  announce: # <- indexからinvokeされるfunc
    handler: src/subcommand/announce.handler
    memorySize: 384
    timeout: 300
    description: "announce subcommand of /checkpos"

aws-sdkバンドルしちゃう問題

必要なモジュールを解決してバンドルしてくれるのは良いのですが、AWS Lambdaはaws-sdkパッケージはLambda側に入っているのでバンドルする必要はありません。

はい!externalsですね!ここの設定に ["aws-sdk"] と書いておけば除外されます。
参照: https://webpack.js.org/configuration/externals/

webpack.config.js
  externals: ["aws-sdk"],

webpack使うんならLoaderでTypeScriptコンパイルしようぜ

デプロイ手法を tsc && serverless deploy にするんでもいいんですが、どうせwebpack使うんなら ts-loader でコンパイルさせようって思いますよね。はい!コチラです!

webpack.config.js
  module: {
    rules: [
      {
        test: /\.ts(x?)$/,
        loader: "ts-loader",
      },
    ],
  },

tsconfig.json のオプションにうっかり "allowJs": true と書くとtscではコンパイル通るのにts-loaderでは挙動が変わってビルドに失敗したりしました。全部TypeScriptで書けってお達しなんでしょうか。

完成!

完成です!コチラが設定ファイル群です!(Lambdaの実コードは特に面白い事はないので割愛)

各種設定パラメータをまとめたconfig.jsonがステージングの時と本番の時で異なるのでdeploydeploy:prodで分けています。
yarn run deploy あるいは yarn run deploy:prodでデプロイしてます。

package.json
{
  "name": "slashcheckpos",
  "version": "1.0.0",
  "description": "",
  "main": "handler.js",
  "license": "ISC",
  "private": true,
  "scripts": {
    "lint": "tslint -c tslint.json src/**/*.ts",
    "lint:fix": "tslint -c tslint.json --fix src/**/*.ts",
    "build": "tsc",
    "build:watch": "tsc -w",
    "config": "cp ../config.json.stg ./src/config.json",
    "deploy": "yarn run config &&  sls deploy -v",
    "config:prod": "cp ../config.json.prod ./src/config.json",
    "deploy:prod": "yarn run config:prod && sls deploy -v --stage prod"
  },
  "dependencies": {
    "axios": "^0.17.1",
    "debug": "^3.1.0",
    "luxon": "^0.2.3"
  },
  "devDependencies": {
    "@types/aws-lambda": "0.0.21",
    "@types/node": "^8.0.20",
    "aws-sdk": "^2.157.0",
    "json-d-ts": "^1.0.1",
    "prettier": "^1.7.4",
    "serverless": "^1.24.1",
    "serverless-webpack": "^4.1.0",
    "ts-loader": "^3.1.1",
    "tslint": "^5.6.0",
    "tslint-config-prettier": "^1.5.0",
    "tslint-plugin-prettier": "^1.3.0",
    "typescript": "^2.4.2",
    "webpack": "^3.8.1",
    "webpack-node-externals": "^1.6.0"
  },
  "prettier": {
    "printWidth": 120,
    "trailingComma": "all"
  }
}

利用するaws cliプロファイルを分けています。本番の時はステージ変数を--stageでprodに指定すれば諸々切り替わるcustomセクションを書いてます。IAM権限はありもののRoleで良い感じのものがあるので、SAMで作成せずにそれを利用するようにしました。厳密に権限設定するならSAMで書いたほうがよいでしょう。

今回の構成上、index関数から別の関数をinvokeする必要があり名前が必要なのですが、serverlessによってLambda関数名が<service名>-<stage名>-<function名>になるのでLambdaの環境変数としてservice名とstage名を設定するようにして取得しています。

serverless.yml
service: slashcheckpos

frameworkVersion: ">=1.19.0 <2.0.0"

plugins:
  - serverless-webpack

provider:
  name: aws
  runtime: nodejs6.10
  stage: ${opt:stage, self:custom.defaultStage}
  profile: ${self:custom.profiles.${self:provider.stage}}
  role: ${self:custom.roles.${self:provider.stage}}
  region: ap-northeast-1
  memorySize: 128
  timeout: 30
  versionFunctions: false
  environment:
    service: ${self:service}
    stage: ${self:provider.stage}

custom:
  defaultStage: dev
  profiles:
    dev: hands-pos-stg
    prod: hands-pos-prod
  roles:
    dev: arn:aws:iam::XXXXXXXXXXXX:role/backend
    prod: arn:aws:iam::XXXXXXXXXXXX:role/backend 

package:
  individually: true

functions:
  index:
    handler: src/index/index.handler
    description: "endpoint of /checkpos"
    events:
      - http:
          path: checkpos
          method: post
  barcode:
    handler: src/subcommand/barcode.handler
    memorySize: 832
    timeout: 300
    description: "barcod subcommand of /checkpos"
  announce:
    handler: src/subcommand/announce.handler
    memorySize: 384
    timeout: 300
    description: "announce subcommand of /checkpos"

webpack.
全く追ってないですがserverless-webpackのlib.entriesでhandlerをリストアップしてくれるんだろうと思ってます。

webpack.config.js
const path = require("path");
const slsw = require("serverless-webpack");

module.exports = {
  entry: slsw.lib.entries,
  resolve: {
    extensions: [".js", ".json", ".ts", ".tsx"],
  },
  output: {
    libraryTarget: "commonjs",
    path: path.join(__dirname, ".webpack"),
    filename: "[name].js",
  },
  externals: ["aws-sdk"],
  target: "node",
  module: {
    rules: [
      {
        test: /\.ts(x?)$/,
        loader: "ts-loader",
      },
    ],
  },
};

まとめ

serverless

開発のAWSアカウントでテストした後に本番のAWSアカウントにデプロイして、スラッシュコマンドの向き先を本番のAPI Gatewayに変更したあとは開発側の構成リソースが不要になるので、serverlessフレームワーク使っていると sls removeでまとめてサクッと削除できて便利でした。

VSCode

Atomも好きなんですがTypeScript書くならVSCodeだな、となりました。