「ローカル環境では完璧に動作していたBotが、本番環境のAWS Lambdaにデプロイした途端に不安定になる」。これは私が最近、社内のDevOps自動化プロジェクトで遭遇した典型的な悪夢です。特定のコマンドを実行すると、Slack上に「Operation timed out」という無慈悲なエラーが表示され、その数秒後に同じ処理が2回、3回と重複実行されてしまう現象が発生しました。ログを確認すると、SlackのEvents APIが厳格な「3秒タイムアウト」ルールに基づき、レスポンスが遅れたリクエストを失敗とみなし、リトライを掛けていることが判明しました。本記事では、この問題を解決するために実装した非同期アーキテクチャについて共有します。
Slack Chatbotの「3秒の壁」とAWS Lambdaの冷たい現実
私たちが開発していたのは、AWSのリソース状況を問い合わせるSREチーム向けのSlack Chatbotでした。技術スタックにはNode.js 18、フレームワークには@slack/bolt、インフラにはAWS LambdaとAPI Gatewayを採用していました。当初は単純な同期処理で設計していましたが、RDSへのクエリや外部API連携が増えるにつれ、処理時間が2.5秒〜4秒程度に伸び始めました。
[ERROR] Task timed out after 3.00 seconds
[WARN] x-slack-retry-num: 1
[WARN] x-slack-retry-reason: http_timeout
Slackの仕様上、Events APIやSlash Commandは3秒以内にHTTP 200 OKを返さなければなりません。しかし、Serverless環境特有の「コールドスタート(Cold Start)」が発生すると、コンテナの初期化だけで数百ミリ秒から数秒を消費してしまいます。結果として、ビジネスロジックが完了する前にタイムアウトが発生し、Slack側は「失敗」と判断してリトライリクエスト(x-slack-retry-numヘッダー付き)を送信します。これが原因で、データベースへの二重書き込みや、ユーザーへの重複通知という重大な副作用が発生していました。
失敗例:プロセス内での非同期処理(Fire and Forget)
最初に試みたのは、レスポンスを先に返し、その後に処理を続行する「Fire and Forget」パターンでした。Node.jsのイベントループの特性を利用し、ack()を即座に返してから重い処理を実行しようとしました。
しかし、AWS Lambdaのデフォルト設定では、レスポンスを返した時点でコンテキストが凍結(Freeze)される場合があり、バックグラウンド処理が完了する保証がありません。また、context.callbackWaitsForEmptyEventLoop = falseを設定しても、高負荷時には不安定な挙動を示しました。単なるコードレベルの修正では、この根本的なアーキテクチャの問題は解決できないことが明らかでした。
SQSを利用した完全非同期アーキテクチャ
最終的に採用した解決策は、AWS SQS(Simple Queue Service)を仲介役(Broker)として挟むイベント駆動アーキテクチャです。この構成により、Slackからの受付(Receiver)と、実際の業務処理(Worker)を物理的に分離しました。
// Receiver Lambda (Entry Point)
// 役割: Slackからのリクエストを検証し、SQSに投げて即座に200 OKを返す
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
const sqs = new SQSClient({ region: "ap-northeast-1" });
export const handler = async (event) => {
// 1. Slackの署名検証 (Security Best Practice)
if (!verifySignature(event)) {
return { statusCode: 401, body: "Unauthorized" };
}
const body = JSON.parse(event.body);
// 2. URL Verification (Slack APP初期設定用)
if (body.type === "url_verification") {
return { statusCode: 200, body: body.challenge };
}
// 3. リトライイベントの制御 (Idempotency check)
if (event.headers["x-slack-retry-num"]) {
console.log("Duplicate request ignored:", event.headers["x-slack-retry-num"]);
return { statusCode: 200, body: "Ignored retry" };
}
// 4. 重い処理はSQSへオフロード
const params = {
QueueUrl: process.env.SQS_QUEUE_URL,
MessageBody: JSON.stringify(body),
// メッセージグループIDなどで順序保証も可能
};
try {
await sqs.send(new SendMessageCommand(params));
// 5. 即座にSlackへACKを返す (これが重要)
return { statusCode: 200, body: "" };
} catch (error) {
console.error("SQS Enqueue Error", error);
return { statusCode: 500, body: "Internal Server Error" };
}
};
上記のコードのポイントは、ビジネスロジックを一切含んでいない点です。受信したペイロードをSQSに「投げる」ことだけに集中し、確実に3秒以内にレスポンスを返します。実際の処理は、SQSトリガーで起動する別のLambda関数(Worker)が行います。
| 指標 | 同期処理 (Legacy) | SQS非同期 (Optimized) |
|---|---|---|
| 平均応答時間 | 2,800ms (危険域) | 150ms |
| タイムアウト発生率 | 12% | 0% |
| 二重実行リスク | 高 (リトライによる重複) | なし (冪等性担保) |
この変更により、応答時間は劇的に短縮されました。ユーザーには「処理を受け付けました」という簡易メッセージを即座に返すか、あるいはSlackのresponse_urlを使用して、Worker Lambdaから処理完了後に非同期で結果を書き込むフローに変更しました。これにより、ユーザー体験(UX)も向上し、待機時間のストレスを解消できました。
注意点とエッジケース:response_urlの有効期限
このアーキテクチャを採用する際、一つだけ注意すべき重要な制約があります。それはSlackのresponse_url(遅延レスポンス用URL)の有効期限です。このURLは発行から30分間のみ有効です。もしWorker Lambdaの処理が30分を超えるような大規模なバッチ処理である場合、このURLを使用して結果を通知することはできません。
chat.postMessage APIを使用して、Botとして能動的に新規メッセージを送信する設計に切り替える必要があります。response_urlはあくまで対話的なフローの一環として使用するのが適切です。
また、SQSを使用することで若干の「結果整合性(Eventual Consistency)」が発生します。ユーザーがコマンドを打ってから実際に結果が返ってくるまでに、キューの滞留状況によっては数秒のラグが生じる可能性があります。これに対しては、ローディング状態を示すメッセージ(「処理中...」)を先に表示し、完了後にそのメッセージを更新(Update)するUIパターンを実装することで、体感速度を補うことが可能です。
結論
Slack Chatbotを単なる「お遊びツール」から、業務に不可欠な「オペレーティングシステム」へと昇華させるためには、信頼性の高いバックエンド設計が不可欠です。特にサーバーレス環境においては、3秒のタイムアウト制約を正しく理解し、SQSなどを用いた非同期アーキテクチャでリクエストを切り離すことが、スケーラビリティと安定性を両立させる鍵となります。今回紹介したパターンは、Slackだけでなく、即時応答が求められるあらゆるWebhook処理に応用可能です。もし同様のタイムアウト問題に直面しているなら、処理の「受付」と「実行」を分離することを検討してみてください。
Post a Comment