プライベートな用事でサーバサイドで何かやりたい場合、サーバレスな構成が第一選択になる。規模が十分に小さい場合、サーバレスにした方が安い。常にインスタンス が立ち上がっているような構成は(たとえ冗長構成を取らなくても)プライベートな用事程度では大げさになる。またサーバレスな構成は放置しやすいのも魅力である。
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_TOKEN
とSLACK_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' ,
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 Pricing Calculator
ここから、Lambdaのアーキテクチャ をx86 からArmにすることでLambdaの費用を2/3に抑えられる可能性がある。そもそもLambdaの無料枠が残っていればそれに収まる。
このSlackアプリはほとんど意味のあることをしていないが、ちゃんと実装して処理時間が呼び出しあたり100ms増えたとしても月々$0.10程度に収まるだろうし、データベースにDynamoDBを使ってもたかがしれていると思う。
イベントの購読
費用の計算で、「app.message()
は、Slackアプリが参加しているチャンネルのメッセージすべてに対して呼び出される」ことを前提にした。app.message()
を使うにはmessage.channels
、message.groups
、message.im
、message.mpim
のようなmessage.*
イベントを購読する 。しかし例えば、Slackアプリに対してのメンションにだけ反応すればいいなら、app.event()
でapp_mention
イベントだけを購読すればいい。呼び出し回数を大きく抑えられ、費用が下がる。
いかがでしたか
Slackアプリをサーバレスで構築するのは簡単で、趣味で使う範囲では十分に安い。Serverless Frameworkだと(ngrokを使えば)ローカルで開発しやすいが、そこに目を瞑ればCDKでも普通に開発できる。DynamoDBを組み合わせるときなどはCDKの方が楽だと思う。開発時にはCDK Watch やdeploy --hotswap
などを使うことでデプロイを速く楽にできる。
みんなもやってみてね。