Slack bot が 3 秒で切られるので Lambda を受付と処理に分けた
Slack にエピソードを書き込むと 4 コマ漫画を生成して X に投稿する bot を、Bolt for JavaScript と AWS Lambda の Function URL で動かしています。この構成では、Slack へのレスポンスが全部の処理の完了後にしか返らず、Slack の 3 秒制限を毎回超えていました。それを、再送を無視する分岐で凌いでいた時期があります。この記事は、その凌ぎ方が何を壊していたかと、Lambda を「受付」と「処理」の 2 役に分けて直した構成の記録です。
症状: レスポンスが返るのは画像生成が終わったあと
Slack の Events API は、こちらが 3 秒以内に HTTP 2xx を返さないと配信失敗とみなし、最大 3 回再送します。1 回目はほぼ即時、2 回目は 1 分後、3 回目は 5 分後です。再送には x-slack-retry-num(何回目か)と x-slack-retry-reason(理由)のヘッダが付き、3 秒を超えた場合の理由は http_timeout です。
一方、bot の処理は 3 秒では終わりません。シナリオ生成に LLM を呼び、画像生成に 10〜20 秒かかります。Bolt の ack() を呼べば Slack に応答が返ると思っていましたが、Lambda の Function URL と `AwsLambdaReceiver` の組み合わせでは、HTTP レスポンスはハンドラ関数が return したときに返ります。リスナーの中で await ack() のあとに重い処理を続けている限り、レスポンスはその処理の完了後です。
app.action("generate_image", async ({ ack, body, client }) => {
await ack(); // ここで Slack に返るわけではない
const imageBuffer = await generateImage(episode.scenario); // 10〜20 秒
// ... 保存、Slack への投稿
});設計の段階では「lazy リスナーで ack を即返す」と書いていました。lazy リスナーは Bolt for Python が FaaS 向けに持っている仕組みで、Bolt for JavaScript にはありません。設計書を書いた時点で、私は 2 つの Bolt の違いを確かめていませんでした。
凌ぎ方: http_timeout の再送を握りつぶす
最初に入れた対処は、再送のうち理由が http_timeout のものだけを、処理せずに 200 を返して捨てることでした。
const retryNum = event.headers?.["x-slack-retry-num"];
const retryReason = event.headers?.["x-slack-retry-reason"];
if (Number(retryNum) > 0 && retryReason === "http_timeout") {
return { statusCode: 200, body: "Timeout retry ignored" };
}初回のリクエストは 3 秒を超えても Lambda の中で処理を続けているので、再送を捨てれば「一応 1 回だけ処理される」形にはなります。ただしこの分岐には、当時の私が自分で書いたコメントがあります。
初回リクエストがタイムアウト後に処理失敗した場合、再送も無視されるためイベントが消失する。
初回の処理が途中で落ちたとき、Slack はそれを知らないので再送してくれますが、その再送をこちらが捨てる。つまり、エピソードは静かに消えます。分離前に実際にイベントが消えた例があったかは、記録を残していないので分かりません。ただ、消える構造であることはコメントに書いたとおり分かっていて、放置していました。
直し方: Lambda を「受付」と「処理」に分ける
関数は 1 つのまま、イベントの種類で役割を分けました。
src/
app.ts # 入口。Slack イベント / EventBridge cron / 自己 invoke を分岐
handlers/
episode.ts # 受付: ガード → 冪等化 → status 遷移 → 進捗表示 → 自己 invoke
thread.ts
actions.ts # 受付: ack → 条件付き遷移 → 自己 invoke
services/
invoker.ts # 自分自身を InvocationType: "Event" で呼ぶ
processor.ts # 処理: シナリオ生成・画像生成・X 投稿。Bolt を通らない
store.ts # DynamoDB。イベントの冪等化と status 遷移
terraform/
lambda.tf # 実行ロールに自分自身への lambda:InvokeFunction を追加受付は、Slack からのリクエストに対して 3 秒以内に終わることだけをやります。署名検証、ガード判定、イベントの冪等化、status の条件付き遷移、スレッドへの進捗メッセージ。そして自分自身を InvocationType: "Event"(非同期)で invoke して、すぐ 200 を返します。ペイロードは最小限にし、状態は処理側で DynamoDB から読み直します。
処理は、その非同期 invoke を受けて重い仕事をします。Bolt を通らないので、Slack への投稿は bot token から作った @slack/web-api の WebClient で行います。処理が失敗したら、必ずスレッドにエラーを投稿して status を巻き戻します。受付側で invoke 自体が失敗したときも同じです。
分けた境界がそこである理由
受付と処理の境目は「Slack が 3 秒で見ているものは何か」で決めました。Slack が見ているのは HTTP の応答だけで、処理の結果は見ていません。だから、応答を返すのに必要な最小限(このイベントを受け取った、重複ではない、処理を始めた)を同期で済ませ、結果に関わるものは全部非同期に押し出す。SQS を挟む、Step Functions にする、という選択肢もありましたが、部品を増やさず関数 1 つで済む自己 invoke を選びました。捨てたのは、キューが持つ再試行や滞留の可視化です。
再送の握りつぶしを消せた理由
分離後は受付が 3 秒以内に返るので、http_timeout の再送はそもそも来ません。それでも万一来たときに二重処理にならないのは、受付の冒頭でイベント ID をキーにしたマーカーを DynamoDB に attribute_not_exists の条件付きで書き、書けた側だけが先へ進むようにしてあるからです(この冪等化と、投稿側の二重防止は X API への自動投稿が二重になる経路を入口と出口で塞ぐ に書きました)。再送を「捨てる」のではなく「受けても安全にする」ことで、握りつぶす分岐を削除できました。
なお Slack には、こちらから「このイベントは再送しないでほしい」と伝える方法もあります。2xx 以外の応答に x-slack-no-retry: 1 ヘッダを付ける、と公式に書かれています。私はこれを使いませんでした。再送を止めるより、再送されても壊れない側に倒したかったからです。
検証
e2e テストを、受付と処理の 2 つの経路に合わせて作り直しました。受付が非同期 invoke を呼ぶこと、処理側が生成と投稿を行うこと、処理失敗時にスレッドへ通知して status が巻き戻ること。この PR の時点で 63 件です。
どこで止まるか
- Lambda の非同期 invoke は、関数がエラーで終わると最大 2 回まで再実行されます(AWS の仕様)。処理側が 2 回走っても壊れないのは、status の遷移が条件付きで、投稿は
postingに遷移してから行うからで、処理側の冪等性もこの遷移に依存しています - 受付と処理を分けたぶん、進捗の表示は「受け取りました」と「できました」の 2 回になります。個人で使う bot なので、この 2 段の見え方は許容しました
- 分離前にイベントが実際に消えた回数は分かりません。消えていたとしても、それを検知する仕組みが無かったからです。今は処理失敗が必ずスレッドに出るので、少なくとも「静かに消える」ことはありません
この構成で気をつけること
- Bolt for JavaScript に lazy リスナーは無い。
ack()は Slack への応答ではなく、Function URL のレスポンスはハンドラの return で決まる - 「3 秒以内に返す」と「処理を終える」は別の仕事。前者だけを同期で、後者は非同期に押し出す
- 再送は捨てない。受けても安全にする。捨てる分岐は、初回の失敗を沈黙させる
- 非同期 invoke には AWS 側の再実行がある。処理側も冪等でなければならない
参考リンク
- Slack Events API(3 秒の応答、再送の回数と間隔、x-slack-no-retry)
- Bolt for JavaScript: AWS Lambda へのデプロイ(AwsLambdaReceiver)
- Bolt for Python: Lazy リスナー
- AWS Lambda: 非同期呼び出し(エラー時の再試行)
- AWS Lambda: 関数 URL の呼び出し