AWS SAM Local を利用して Slackにメッセージを送信するアプリを テスト・デプロイする #serverless #adventcalendar

2017.12.17

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。齋藤です。 記事を書いている際に麻婆豆腐の匂いがしてきてお腹が空きました。

今日の記事は クラスメソッド社員がお届けする AWSサーバーレス Advent Calendar 2017 の 17日目の記事です。

昨日の記事は 西田さん による 「LambdaからカスタムサブセグメントをX-Rayに送信する」 でした。

以前作った SAM Local で テストしたアプリケーションをベースに手を加えて slackへの通知をするLambdaを AWS SAM Localでテストしつつ デプロイまでしてみます。

今回は webpack で node 向けにトランスパイルしてデプロイしてみます。

今回の記事の内容は次のような内容です。

  • アプリケーションを用意する
    • slack の通知を行うコードを TypeScriptで書く
    • SAM ベースの cloudformation の template を書く
    • webpack で bundleする
    • AWS SAM Localでテストしてみる
  • デプロイ
    • アプリケーションのデプロイのために s3 bucket の 作成をする
    • sam コマンド経由で アプリケーションの package をする
    • sam コマンド経由で アプリケーションの deploy をする
    • 実際に API Gateway経由で 動作を確認する

今回は次のような構成で動作を確認しました。

  • AWS SAM Local 0.2.4
  • aws-cli/1.14.10 Python/3.6.3 Darwin/17.3.0 botocore/1.8.14
  • npm 5.6.0
  • node 9.2.1
  • docker
$ docker version
Client:
 Version:      17.09.1-ce
 API version:  1.32
 Go version:   go1.8.3
 Git commit:   19e2cf6
 Built:        Thu Dec  7 22:22:25 2017
 OS/Arch:      darwin/amd64

Server:
 Version:      17.09.1-ce
 API version:  1.32 (minimum version 1.12)
 Go version:   go1.8.3
 Git commit:   19e2cf6
 Built:        Thu Dec  7 22:28:28 2017
 OS/Arch:      linux/amd64
 Experimental: true

slack の通知を行うコードを TypeScriptで書く

やっぱり型は欲しいので TypeScriptです。 書きました。

環境変数から slackのアクセストークンを受け取るようにしています。

import Lambda from "aws-lambda"

import { WebClient } from "@slack/client"

const token = process.env.SLACK_TOKEN!
const slackClient = new WebClient(token)

function postMessage(event: Lambda.APIGatewayEvent, _: Lambda.Context, callback: Lambda.ProxyCallback) {
  interface RequestBody {
    channel: string
    message: string
  }
  if (event.body == null) {
    callback(null, { statusCode: 400, body: "Bad Request" })
    return
  }

  try {
    const body: Partial<RequestBody> = JSON.parse(event.body)
    if (body.channel == null)
      throw new Error("Cannot read .channel from request body")
    if (body.message == null)
      throw new Error("Cannot read .message from request body")

    slackClient.chat.postMessage(body.channel, body.message, (e, r) => {
      if (e != null || r.ok == false) {
        callback(null, { statusCode: 500, body: "error occured when message post" })
        return
      }
      callback(null, { statusCode: 200, body: "message is posted" })
    })
  } catch (e) {
    callback(null, { statusCode: 400, body: e.message })
  }
}

module.exports = { postMessage }

slack の client の 型定義はなかったので こちらも追加しました。必要なものだけです。

declare module "@slack/client" {
  interface OkResult {
    ok: true,
  }
  interface ErrorResult {
    ok: false,
    error: string
  }
  type Result = OkResult | ErrorResult

  interface WebChatApi {
    postMessage(channel: string, text: string, callback: (e: Error, result: Result) => void): void;
  }
  class WebClient {
    constructor(
      token: string
    )
    chat: WebChatApi
  }
  export { WebClient }
}

これで大体アプリケーションのコードはできました。 typescriptの設定については こちらをご覧ください。

次は AWS SAM Local を使ってテストをしてみましょう。

SAM ベースの cloudformation の template を書く

AWS SAM Local で作成した APIを定義する サーバーレスアプリケーションモデル (SAM) の テンプレートファイルを書きました。

Parameterで slackのアクセストークンを受け取って Serverless::Function の環境変数に設定しています。

今回は CodeUriを使って bundle したファイルだけパッケージするようにしています。

CodeUriの挙動に関してはこちらの記事をご覧ください。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: SAM Local test
Parameters:
  SlackToken:
    Type : String
    Description : Enter slack token for bot.
Resources:
  HelloWorld:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: target/
      Handler: index.postMessage
      Runtime: nodejs6.10
      Environment:
        Variables:
          SLACK_TOKEN: !Ref SlackToken
      Events:
        GetResource:
          Type: Api
          Properties:
            Path: /message
            Method: post

webpack で bundleする

webpack 類をインストールしておきます。

npm i webpack ts-node typescript ts-loader -D

webpack.config.ts はここでは次のような物を用意しました。 テストのために uglify はオフにしています。

"use strict"
import * as path from "path"

import * as webpack from "webpack"

const config: webpack.Configuration = {
  devtool: "source-map",
  entry: "./index.ts",
  output: {
    path: path.resolve("./target"),
    filename: "index.js"
  },
  target: "node",
  resolve: {
    extensions: [".json", ".tsx", ".ts", ".js"]
  },
  plugins: [
    // new webpack.optimize.UglifyJsPlugin()
  ],
  module: {
    rules: [
      {
        test: /\.ts?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: "ts-loader",
          },
        ],
      },
    ],
  },
}

module.exports = config

bundleしてみます。 warning がいっぱい出ますが ここでは気にせずやっていきます。

$ npx webpack
Hash: f5ccf8f32bf275c58f3f
Version: webpack 3.10.0
Time: 3377ms
       Asset     Size  Chunks                    Chunk Names
    index.js  2.35 MB       0  [emitted]  [big]  main
index.js.map  2.92 MB       0  [emitted]         main
  [56] (webpack)/buildin/module.js 517 bytes {0} [built]
 [129] ./index.ts 1.12 kB {0} [built]
 [177] ./node_modules/colors/lib 160 bytes {0} [optional] [built]
 [194] ./node_modules/pkginfo/lib ^.*\/package\.json$ 160 bytes {0} [optional] [built]
    + 337 hidden modules

WARNING in ./node_modules/colors/lib/colors.js
127:29-43 Critical dependency: the request of a dependency is an expression

WARNING in ./node_modules/ws/lib/BufferUtil.js
Module not found: Error: Can't resolve 'bufferutil' ...

WARNING in ./node_modules/ws/lib/Validation.js
Module not found: Error: Can't resolve 'utf-8-validate' ...

bundleできました。 target 配下に index.js が出力されているはずです。

AWS SAM Local でテストしてみる

では、以前の記事を見ながら AWS SAM Local でテストをしてみます。

次のようなJSONファイルを用意しておきます。

{
  "HelloWorld": {
    "SLACK_TOKEN": "<your-slack-token>"
  }
}

template.yml ファイルが存在するディレクトリで 次のコマンドを使うと 定義してある Serverless::Function を動かすことが可能です。

sam local start-api --env-vars env.json

curl を使って動かしてみます。

$ curl -XPOST http://localhost:3000/message -d '{"channel":"bot", "message":"test"}'
{ "message": "Internal server error" }

動きません。

AWS SAM Localを起動しているターミナルに次のようなログが出ていました。 モジュールの初期化に失敗しています。

START RequestId: a63f73b8-d205-185a-7374-0a4e56a9b905 Version: $LATEST
module initialization error: Error
    at Function.module.exports.pkginfo.find (/var/task/index.js:51534:11)
    at Function.module.exports.pkginfo.read (/var/task/index.js:51561:22)
    at module.exports.module.exports (/var/task/index.js:51507:21)
    at Object.<anonymous> (/var/task/index.js:51568:1)
    at Object.module.exports.webpackEmptyContext.keys (/var/task/index.js:51574:30)
    at __webpack_require__ (/var/task/index.js:21:30)
    at Object.<anonymous> (/var/task/index.js:26083:39)
    at Object.module.exports.winston (/var/task/index.js:26138:30)
    at __webpack_require__ (/var/task/index.js:21:30)
    at Object.module.exports.old (/var/task/index.js:31491:40)
    at __webpack_require__ (/var/task/index.js:21:30)
    at Object.module.exports.module.exports.ctor.super_ (/var/task/index.js:41412:21)
    at __webpack_require__ (/var/task/index.js:21:30)
    at Object.<anonymous> (/var/task/index.js:41370:14)
    at __webpack_require__ (/var/task/index.js:21:30)
    at Object.<anonymous> (/var/task/index.js:41333:16)
END RequestId: a63f73b8-d205-185a-7374-0a4e56a9b905
REPORT RequestId: a63f73b8-d205-185a-7374-0a4e56a9b905  Duration:206.88 ms       Billed Duration: 0 ms   Memory Size: 0 MB       Max Memory Used: 57 MB

bundleされたソースを調べたところ、元のソースを見ると 次のようなコードが書かれていました。

...
var pkginfo = require('pkginfo')(module, 'version', 'name'); // eslint-disable-line no-unused-vars
...

slackのクライアントのライブラリは pkginfo というモジュールを使って package.json のデータを読んでいます。 これでは1枚のファイルに bundleできません。

この処理自体は静的に解決できるはずですね。 babelを使って解決しましょう。

pkginfo の呼び出しを静的にするために babel pluginを書く

babelをインストールしておきます。

$ npm i babel-core babel-loader -D

この時点で webpack.config.ts は次のような形になりました。

"use strict"
import * as path from "path"

import * as webpack from "webpack"

const config: webpack.Configuration = {
  devtool: "source-map",
  entry: "./index.ts",
  output: {
    path: path.resolve("./target"),
    filename: "index.js"
  },
  target: "node",
  resolve: {
    extensions: [".json", ".tsx", ".ts", ".js"]
  },
  plugins: [
    // new webpack.optimize.UglifyJsPlugin()
  ],
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        use: [
          {
            loader: "babel-loader",
          },
        ],
      },
      {
        test: /\.ts?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: "ts-loader",
          },
        ],
      },
    ],
  },
}

module.exports = config

.babelrc も用意しておきます。

{
  "plugins": [
    "./pkginfo.js"
  ]
}

では、pkginfoの呼び出しを静的にする babel pluginを書きましょう。

書きました。

// @ts-nocheck
"use strict"

const finder = require("find-package-json")

Object.defineProperty(exports, "__esModule", {
  value: true
});

exports.default = function (babel) {
  const { types: t } = babel;
  function isRequire(node) {
    return t.isCallExpression(node) && t.isIdentifier(node.callee) && node.callee.name == "require"
  }
  function isImportPkginfo(node) {
    return t.isStringLiteral(node.arguments[0]) && node.arguments[0].value === "pkginfo"
  }
  function literal(value) {
    if (typeof value === "string") {
      return t.stringLiteral(value)
    } else if (typeof value === "number") {
      return t.numberLiteral(value)
    }
    throw new Error("Unexpeted type: " + (typeof value))
  }
  return {
    visitor: {
      VariableDeclaration(path, state) {
        path.traverse({
          VariableDeclarator: (declPath) => {
            if (t.isCallExpression(declPath.node.init) == false) return;
            const init = declPath.node.init;
            const firstArg = init.arguments[0]
            if (t.isIdentifier(firstArg) == false && firstArg != "module") return;
            if (isRequire(init.callee) == false) return;
            if (isImportPkginfo(init.callee) == false) return;
            const refs = init.arguments.slice(1).map(id => id.value);
            const f = finder(state.file.opts.filenameRelative)
            const v = f.next().value
            refs.forEach(d => path.insertBefore(
              t.expressionStatement(
                t.assignmentExpression("=", t.identifier(`module.exports.${d}`), literal(v[d]))
              )
            ))
            declPath.remove()
          }
        });
      }
    }
  };
}

これで 簡単な pkginfo モジュールの呼び出しについては 静的に解決できるようになりました。 では、再度 bundleして テストしてみましょう。

$ npx webpack
$ sam local start-api --env-vars env.json

先ほどと同じように curlで叩いてみましょう。

$ curl -XPOST http://localhost:3000/message -d '{"channel":"bot", "message":"test"}'
{ "message": "Internal server error" }

動きません。

AWS SAM Local を起動したコンソールに 次のようなメッセージが出ています。

START RequestId: afdd68cb-fc3b-1b03-24f5-a9deec0f0a3b Version: $LATEST
Handler 'postMessage' missing on module 'index'
END RequestId: afdd68cb-fc3b-1b03-24f5-a9deec0f0a3b
REPORT RequestId: afdd68cb-fc3b-1b03-24f5-a9deec0f0a3b  Duration:307.11 ms       Billed Duration: 0 ms   Memory Size: 0 MB       Max Memory Used: 51 MB

Handlerがない、と怒られています。

ここまで辿り着いた皆さんならお分かりかもしれません。 webpack の設定が足りません。

libraryTarget を commonjs2 にしておきましょう。(commonjsでもいいです)

"use strict"
import * as path from "path"

import * as webpack from "webpack"

const config: webpack.Configuration = {
  devtool: "source-map",
  entry: "./index.ts",
  output: {
    path: path.resolve("./target"),
    filename: "index.js",
    libraryTarget: "commonjs2",
  },
  target: "node",
  resolve: {
    extensions: [".json", ".tsx", ".ts", ".js"]
  },
  plugins: [
    // new webpack.optimize.UglifyJsPlugin()
  ],
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        use: [
          {
            loader: "babel-loader",
          },
        ],
      },
      {
        test: /\.ts?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: "ts-loader",
          },
        ],
      },
    ],
  },
}

module.exports = config

では、気を取り直して再度 bundle して AWS SAM Local で動かします。

$ npx webpack
$ sam local start-api --env-vars env.json

curl でまた叩きます。

$ curl -XPOST http://localhost:3000/message -d '{"channel":"bot", "message":"test"}'
message is posted

動きました。

アプリケーションのデプロイのために s3 バケットを用意しておきます

AWS Lambdaのコードのデプロイのためには コードを zipで固めて s3 に配置する必要があります。

s3 バケットを用意しておきましょう。 バケット名の制約に引っかかると辛いので、次のコマンドをつかって bucket の suffix を生成しました。 バケット名の制約で 大文字が使えないので tr で 小文字にしています。

uuidgen | tr A-Z a-z | pbcopy # クリップボードにコピー

生成した uuid をsuffixにして bucketを作っておきます。

$ aws s3api create-bucket --bucket lambda-test-uuid-dsdsds-dsdsds-dsajdhas

アプリケーションの package をする

アプリケーションのパッケージを行いましょう。 sam package コマンドを使います。

sam package --template-file template.yml --output-template-file lambda-test.yml --s3-bucket lambda-test-uuid-dsdsds-dsdsds-dsajdhas

sam package コマンドは aws cloudformation packageの エイリアスです。 (そのため、AWS CLIが入ってないと怒られます。)

template.yml をベースに 次のような cloudformation の ymlが生成されます。

AWSTemplateFormatVersion: '2010-09-09'
Description: SAM Local test
Parameters:
  SlackToken:
    Description: Enter slack token for bot.
    Type: String
Resources:
  HelloWorld:
    Properties:
      CodeUri: s3://lambda-test-uuid-dsdsds-dsdsds-dsajdhas/<some-identifier>
      Environment:
        Variables:
          SLACK_TOKEN:
            Ref: SlackToken
      Events:
        GetResource:
          Properties:
            Method: post
            Path: /message
          Type: Api
      Handler: index.postMessage
      Runtime: nodejs6.10
    Type: AWS::Serverless::Function
Transform: AWS::Serverless-2016-10-31

このファイルを使って アプリケーションのデプロイをやっていきましょう。

アプリケーションの deploy をする

次のコマンドで デプロイ可能です。 --parameter-overrides で Slackのアクセストークンを渡しています。

$ sam deploy --template-file lambda-test.yml --stack-name lambda-test --parameter-overrides "SlackToken=<your-access-token>" --capabilities CAPABILITY_IAM

こちらも awscliのエイリアスです。

やっとデプロイできました。

実際に API Gateway経由で 動作を確認する

動かしてみます。

$ curl https://<your-gateway-id>.execute-api.ap-northeast-1.amazonaws.com/Prod/message
{"message":"Missing Authentication Token"}

初め、curl で間違えました。上記のようなログが出てきて、見たことのないメッセージにびっくりしました。 設定している APIの情報とあっていない為、次のようなログが出ています。 具体的にはリクエストボディとHTTPメソッドの指定が足りていません。 curl の場合 次のような形で リクエストボディを指定すると POSTになります。

$ curl https://<your-gateway-id>.execute-api.ap-northeast-1.amazonaws.com/Prod/message -d '{"channel":"bot", "message":"test"}'
message is posted

動きました。

まとめ

今回は slackに通知するアプリケーションを AWS SAM Local で事前にテストしつつ webpack で bundleしながら、sam コマンド経由で アプリケーションをデプロイしました。

まだまだ温もり溢れる手作業感はありますが 一通りデプロイまで動かすことができました。

yak を順調に刈りながら アドベントカレンダーの記事ができました。

今回は webpack を使って bundleしました。 webpack を使った理由としては、package.json に記述している devDependencies の モジュールが入るのが避けたかった と言うところです。 (ローカルの状況によっていくらでも入っちゃいます。) devDependencies のモジュールが入らないようにするには次のような方法が考えられると思います。

  • bundleしない
    • typescript は tsc で target/ 以下に トランスパイル
    • package.json も target/ 以下にコピー
    • target/ 以下で npm i --only=production で dependenciesだけインストール
  • bundleする + CodeUri でコードを指定
  • 諦める

bundleしない選択肢を取ると npm i をしないといけなくなり、インストールの時間がかかります。 今回作ったアプリケーションでは 6.578s でした。 bundle する場合は 開発時の依存関係をインストールした状態のまま、そのまま deploy まで持っていけます。 しかし、今回のケースの場合、bundle されたソース類の都合上 いくつかのモジュールが 解決できない状態になっています。

と言うわけで どっちもどっち感があります。 最近は npm install も早くなったので bundle しなくても気にならないかも。

この記事ではデプロイ面で非常に悩みが溢れる感じになってしまいました。 皆さんはどんな形でデプロイをしているのでしょうか。 コメントやシェアする際に教えていただけると幸いです。

ありがとうございました。

babel プラグインで解決するのは本来あまりよくないので できる限り避けましょう。

明日の記事は 西村さん による Elasticsearch と Lambda を絡めた記事だそうです。

楽しみですね。

それではこの記事はここでお終いです。 ご飯食べてきます。

今回作成したアプリケーションはこちらのリポジトリに置いてあります。

参考