ブログ一覧

Slack bot が 3 秒で切られるので Lambda を受付と処理に分けた

更新: Web開発者向け

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 の処理はシナリオ生成に LLM を呼び、画像生成に 10〜20 秒かかるので、3 秒では終わりません。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 が見ている物で決めた

受付と処理の境目は「Slack が 3 秒で見ているものは何か」で決めました。Slack が見ているのは HTTP の応答だけで、処理の結果は見ていません。そこで、応答を返すのに必要な最小限(このイベントを受け取った、重複ではない、処理を始めた)を同期で済ませ、結果に関わる処理は全部非同期の invoke に回しました。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 リスナーは無く、Function URL のレスポンスは ack() ではなくハンドラの return で返る
  • 「3 秒以内に返す」と「処理を終える」を分け、3 秒以内に返す部分だけを同期で行い、処理は非同期に回す
  • 再送を捨てる分岐は初回の失敗を誰にも知らせずに消すので、再送は受けても安全にする
  • 非同期 invoke には AWS 側の再実行があるので、処理側も冪等にする

参考リンク

関連

この記事をシェア