ブログ一覧

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-apiWebClient で行います。処理が失敗したら、必ずスレッドにエラーを投稿して 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 側の再実行がある。処理側も冪等でなければならない

参考リンク

関連

この記事をシェア