cockscomblog?

cockscomb on hatena blog

AWS LambdaでSlackアプリを動かす

プライベートな用事でサーバサイドで何かやりたい場合、サーバレスな構成が第一選択になる。規模が十分に小さい場合、サーバレスにした方が安い。常にインスタンスが立ち上がっているような構成は(たとえ冗長構成を取らなくても)プライベートな用事程度では大げさになる。またサーバレスな構成は放置しやすいのも魅力である。

f:id:cockscomb:20220307094357p:plain
Lambdaで動くcockscombot

最近、サーバサイドで何かしたあとの通知先としてSlackを使っている。Slackはちょっとしたユーザーインターフェースの代わりになる。その延長線上でSlackアプリを作ってみようと考えた。Slackが提供しているBoltというのを使うと、Slackアプリが簡単に作れる。

Slack | Bolt for JavaScript | Bolt 入門ガイド

BoltはAWS Lambdaにデプロイできるようになっている。ドキュメントではServerless Frameworkが使われているが、要するにAPI Gateway経由でLambdaを呼び出しているだけだ。

Slack | Bolt for JavaScript | AWS Lambda へのデプロイ

AWS CDKでSlackアプリを構築する

Serverless Frameworkでもいいけど、AWS CDKでもすぐにできる。API GatewayにNode.jsランタイムのLambdaをくっつけるのは、次のように書け、これでPOST /slack/eventsでLambdaが呼び出されるようになる。

import * as apigateway from '@aws-cdk/aws-apigatewayv2-alpha'
import * as apigateway_integrations from '@aws-cdk/aws-apigatewayv2-integrations-alpha'
import { Stack, StackProps } from 'aws-cdk-lib'
import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'
import { Construct } from 'constructs'

export class SlackBotStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props)

    const botFunction = new lambda.NodejsFunction(this, 'BotFunction', {
      entry: './slack/src/bot.ts',
      handler: 'handler',
    })

    const botIntegration = new apigateway_integrations.HttpLambdaIntegration('BotIntegration', botFunction)

    const api = new apigateway.HttpApi(this, 'BotAPI')
    api.addRoutes({
      path: '/slack/events',
      methods: [apigateway.HttpMethod.POST],
      integration: botIntegration,
    })
  }
}

API GatewayをHTTP APIにしたかったので、experimentalの@aws-cdk/aws-apigatewayv2-alpha Construct Libraryを使っているが、これくらいなら大きな問題にはならないだろう。

aws-cdk-lib/aws-lambda-nodejsのおかげで、TypeScriptで書いたSlackアプリのコードがesbuildでいい具合にトランスパイルされ、手間要らずだ。

肝心のSlackアプリ(./slack/src/bot.ts)はBoltを使って次のように書ける。

import bolt from '@slack/bolt'
const { App, AwsLambdaReceiver } = bolt

const SLACK_BOT_TOKEN = ??? // TODO
const SLACK_SIGNING_SECRET = ??? // TODO

const awsLambdaReceiver = new AwsLambdaReceiver({
  signingSecret: SLACK_SIGNING_SECRET,
})
const app = new App({
  token: SLACK_BOT_TOKEN,
  receiver: awsLambdaReceiver,
})

app.message('Hello', async ({ message, say }) => {
  await say('Hi')
})

export const handler: ReturnType<typeof awsLambdaReceiver.toHandler> = async (event, context, callback) => {
  const handler = await awsLambdaReceiver.start()
  return handler(event, context, callback)
}

AwsLambdaReceiverというのが用意されているおかげで、Lambdaと繋ぎこむところはほとんど何もしなくていい。

シークレットをParameter Storeから読み取る

SLACK_BOT_TOKENSLACK_SIGNING_SECRETのふたつのシークレットを使っている。これらはハードコードしたくないので、AWS Systems Manager Parameter Storeに入れておいたものをLambdaから読み取らせることにする。

Parameter Storeにそれぞれ/slack/bot/SLACK_BOT_TOKEN/slack/bot/SLACK_SIGNING_SECRETの名前で、シークレットとして保存しておく。これらをLambdaから読み取るために、スタックを次のように書き換える。

import * as apigateway from '@aws-cdk/aws-apigatewayv2-alpha'
import * as apigateway_integrations from '@aws-cdk/aws-apigatewayv2-integrations-alpha'
import { Stack, StackProps } from 'aws-cdk-lib'
import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'
import * as ssm from 'aws-cdk-lib/aws-ssm'
import { Construct } from 'constructs'

export class SlackBotStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props)

    const slackBotToken = ssm.StringParameter.fromSecureStringParameterAttributes(this, 'SlackBotToken', {
      parameterName: '/slack/bot/SLACK_BOT_TOKEN',
    })
    const slackSigningSecret = ssm.StringParameter.fromSecureStringParameterAttributes(this, 'SlackSigningSecret', {
      parameterName: '/slack/bot/SLACK_SIGNING_SECRET',
    })
    const botFunction = new lambda.NodejsFunction(this, 'BotFunction', {
      entry: './slack/src/bot.ts',
      handler: 'handler',
      environment: {
        SLACK_BOT_TOKEN_NAME: slackBotToken.parameterName,
        SLACK_SIGNING_SECRET_NAME: slackSigningSecret.parameterName,
      },
      bundling: {
        target: 'node14.14', // Top-level awaitが使えるバージョン
        tsconfig: './slack/tsconfig.json',
        format: lambda.OutputFormat.ESM,
        nodeModules: ['@aws-sdk/client-ssm', '@slack/bolt'],
      },
    })
    slackBotToken.grantRead(botFunction)
    slackSigningSecret.grantRead(botFunction)

    const botIntegration = new apigateway_integrations.HttpLambdaIntegration('BotIntegration', botFunction)

    const api = new apigateway.HttpApi(this, 'BotAPI')
    api.addRoutes({
      path: '/slack/events',
      methods: [apigateway.HttpMethod.POST],
      integration: botIntegration,
    })
  }
}

ssm.StringParameter.fromSecureStringParameterAttributes()でパラメータを参照し、slackBotToken.grantRead(botFunction)のようにしてLambdaに読み取り権限を与える。また環境変数を介してパラメータの名前を渡しておく。

さらにlambda.NodejsFunctionのオプションにbundlingを増やし、esbuildにオプションを渡す。./slack/tsconfig.jsonも少し調整してある。

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "target": "es2019",
    "module": "es2022",
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true
  }
}

面倒な感じになっているが、こうするとTypeScriptからES modulesが生成され、top-level awaitが使えるようになる。AWS LambdaではNode.js 14ランタイムでtop-level awaitがサポートされており、特にLambdaのProvisioned Concurrencyを利用している場合にコールドスタート時のレイテンシが改善するとされている。

後は次のようにシークレットを読み取る関数を用意しておけばよい。

import { GetParametersCommand, SSMClient } from '@aws-sdk/client-ssm'
import { strict as assert } from 'assert'

export async function getSlackSecrets(): Promise<{ token: string; signingSecret: string }> {
  const [SlackBotTokenName, SlackSigningSecretName] = [
    process.env.SLACK_BOT_TOKEN_NAME,
    process.env.SLACK_SIGNING_SECRET_NAME,
  ]
  assert(SlackBotTokenName, 'SLACK_BOT_TOKEN_NAME is not set')
  assert(SlackSigningSecretName, 'SLACK_SIGNING_SECRET_NAME is not set')

  const ssmClient = new SSMClient({
    region: process.env.AWS_REGION,
  })
  const parametersResponse = await ssmClient.send(
    new GetParametersCommand({
      Names: [SlackBotTokenName, SlackSigningSecretName],
      WithDecryption: true,
    })
  )
  const parameters = parametersResponse.Parameters
  assert(parameters, 'Parameter Store values not found')
  const [{ Value: token }, { Value: signingSecret }] = parameters
  assert(token, 'Parameter Store value for SLACK_BOT_TOKEN is not set')
  assert(signingSecret, 'Parameter Store value for SLACK_SIGNING_SECRET is not set')

  return {
    token,
    signingSecret,
  }
}

シークレットの取得はtop-level awaitを使って次の1行で済む。

const { token: SLACK_BOT_TOKEN, signingSecret: SLACK_SIGNING_SECRET } = await getSlackSecrets()

ここまでやってはみたが、別にLambdaのProvisioned Concurrencyを使うわけではない。Top-level awaitを使おうとするとES modulesにする必要があって、現状のエコシステムでは少し複雑になる。これくらいならシークレットをそのまま環境変数に入れてしまった方がいいかもしれない。

費用

試しに実行してみた感じでは、128MBのメモリ割り当てで、コールドスタートなら平均的に250msくらい、ウォームスタートなら2msくらいのBilled Durationになった。メッセージに反応するくらいなら一瞬で処理できる。

app.message()は、Slackアプリが参加しているチャンネルのメッセージすべてに対して呼び出される。実際に処理する必要があるかどうかは問わない。例えばSlackアプリが参加しているチャンネルに毎日1,000件のメッセージが投稿されるとすると、これに伴って呼び出されるLambdaはこれくらいの頻度であれば毎回コールドスタートになるだろうから、東京リージョンでは月々$0.03かかる。API Gatewayも呼び出し毎に課金され、月々にして$0.04かかる。合わせて月々$0.07になった。お小遣いで払えそうでよかった。

サービス 費用(月々)
AWS Lambda $0.03
Amazon API Gateway $0.04
合計 $0.07

AWS Pricing Calculator

ここから、Lambdaのアーキテクチャx86からArmにすることでLambdaの費用を2/3に抑えられる可能性がある。そもそもLambdaの無料枠が残っていればそれに収まる。

このSlackアプリはほとんど意味のあることをしていないが、ちゃんと実装して処理時間が呼び出しあたり100ms増えたとしても月々$0.10程度に収まるだろうし、データベースにDynamoDBを使ってもたかがしれていると思う。

イベントの購読

費用の計算で、「app.message()は、Slackアプリが参加しているチャンネルのメッセージすべてに対して呼び出される」ことを前提にした。app.message()を使うにはmessage.channelsmessage.groupsmessage.immessage.mpimのようなmessage.*イベントを購読する。しかし例えば、Slackアプリに対してのメンションにだけ反応すればいいなら、app.event()app_mentionイベントだけを購読すればいい。呼び出し回数を大きく抑えられ、費用が下がる。

いかがでしたか

Slackアプリをサーバレスで構築するのは簡単で、趣味で使う範囲では十分に安い。Serverless Frameworkだと(ngrokを使えば)ローカルで開発しやすいが、そこに目を瞑ればCDKでも普通に開発できる。DynamoDBを組み合わせるときなどはCDKの方が楽だと思う。開発時にはCDK Watchdeploy --hotswapなどを使うことでデプロイを速く楽にできる。

みんなもやってみてね。