AWS SQS を Node.js で動かして理解する(可視性タイムアウト・再試行・DLQ)

目次
SQS を初めて触ると、「送ったメッセージはいつ消えるのか」「失敗したらどうなるのか」が掴めないまま雰囲気で実装してしまいがちです。
先に結論を書くと、SQS の挙動はほぼこの2点に集約されます。
- メッセージは受信しただけでは消えない。消えるのは
DeleteMessageを呼んだときだけ - 受信すると可視性タイムアウトの間だけ「見えない」状態になり、削除されなければ復活して再配信される
つまり「成功したときだけ削除する」というコードを書けば、再試行は SQS が勝手にやってくれます。この記事では Node.js + @aws-sdk/client-sqs で最小の producer / consumer を書き、わざと失敗させて再受信と DLQ 移動を目で見るところまでやります。
題材にする構成
キューは AWS 側で1本作っておき(標準キュー)、コードは次の4本だけです。
| ファイル | 役割 |
|---|---|
sqs-client.js |
SQS クライアントとキュー URL の共有 |
producer.js |
メッセージを1件送る |
consumer.js |
ロングポーリングで受信 → 処理 → 成功時のみ削除 |
failure-consumer.js |
わざと失敗させて再配信・DLQ を観察する |
inspect-queue.js |
キューの状態(滞留数・in-flight 数など)を覗く |
package.json は ESM ("type": "module") にしておき、スクリプトを生やしておくと検証が速くなります。
{
"type": "module",
"scripts": {
"send": "node src/producer.js",
"consume": "node src/consumer.js",
"consume:failure": "node src/failure-consumer.js",
"inspect": "node src/inspect-queue.js"
}
}クライアントは1箇所にまとめる
キュー URL はアカウント ID を含むのでコードに埋めず、環境変数から読みます。認証情報は SDK のデフォルトの資格情報プロバイダチェーンに任せるので、new SQSClient({}) で十分です(~/.aws/credentials や環境変数、EC2/ECS/Lambda ならロールを自動で拾う)。
import "dotenv/config";
import { SQSClient } from "@aws-sdk/client-sqs";
export const queueUrl = process.env.SQS_QUEUE_URL;
if (!queueUrl) {
throw new Error("SQS_QUEUE_URLが設定されていません");
}
export const sqsClient = new SQSClient({});リージョンも環境変数(AWS_REGION)から解決されるので明示不要です。キー類を直接書かないので、そのまま Lambda に載せても動きます。
送信:本文は文字列、メタ情報は MessageAttributes
SQS の本文は文字列なので、オブジェクトは JSON にして渡します。本文とは別に、フィルタや分岐に使いたいメタ情報は MessageAttributes に載せられます。
import { randomUUID } from "node:crypto";
import { SendMessageCommand } from "@aws-sdk/client-sqs";
import { queueUrl, sqsClient } from "./sqs-client.js";
const message = {
eventId: randomUUID(),
eventType: "SEND_WELCOME_EMAIL",
userId: "user-001",
email: "test@example.com",
requestedAt: new Date().toISOString(),
};
const command = new SendMessageCommand({
QueueUrl: queueUrl,
// SQSのメッセージ本文は文字列なのでJSON文字列に変換する
MessageBody: JSON.stringify(message),
// 本文とは別に検索・判定などに使える属性
MessageAttributes: {
eventType: { DataType: "String", StringValue: message.eventType },
version: { DataType: "Number", StringValue: "1" },
},
});
const response = await sqsClient.send(command);
console.log({ messageId: response.MessageId, md5: response.MD5OfMessageBody });ポイントは3つ。
DataTypeはString/Number/Binaryの3種。Numberでも値はStringValueに入れる(型は宣言だけ)eventIdを送信側で発行しておく。後述の冪等性チェックのキーになる- レスポンスの
MD5OfMessageBodyは本文が壊れずに届いたかの検証用。基本はMessageIdをログに残せば十分
MessageAttributes は本文をパースせずに扱えるので、SNS のサブスクリプションフィルタや Lambda 側での早期振り分けに効きます。逆に、業務データを二重に持たせると本文とズレるので、置くのは「種別」「バージョン」程度に留めるのが安全です。
受信:ロングポーリングで待ち、成功したら削除する
consumer が SQS の本体です。ここだけは丁寧に見ます。
const receiveCommand = new ReceiveMessageCommand({
QueueUrl: queueUrl,
// 1回のAPI呼び出しで最大10件まで取得可能
MaxNumberOfMessages: 5,
// 最大20秒間、新しいメッセージを待つ(ロングポーリング)
WaitTimeSeconds: 20,
// このconsumerが受信してから30秒間は他のconsumerに見せない
VisibilityTimeout: 30,
MessageAttributeNames: ["All"],
// ApproximateReceiveCountなどのシステム属性を取得する
MessageSystemAttributeNames: ["ApproximateReceiveCount", "SentTimestamp"],
});WaitTimeSeconds:0 にしてはいけない
WaitTimeSeconds を指定しない(= 0)とショートポーリングになり、SQS はサーバーの一部だけをサンプリングして即座に返します。メッセージが実際にキューにあるのに空レスポンスが返る、という挙動はこれが原因です。1〜20(最大 20 秒)を指定してロングポーリングにすると、メッセージが来るまで待ってから返してくれます。
空振りリクエストが減るのでコストも下がります。SQS はリクエスト数課金なので、1秒ごとにポーリングするループと 20 秒待つループではリクエスト数が桁で違います(料金、無料枠は月100万リクエスト)。キュー属性 ReceiveMessageWaitTimeSeconds を設定しておけば、リクエスト側で指定を忘れてもキュー全体でロングポーリングになります。
VisibilityTimeout:処理時間より長くする
受信したメッセージは、可視性タイムアウトの間だけ他の consumer から見えなくなります(in-flight 状態)。デフォルトは 30 秒、最大 12 時間。
必ず「業務処理にかかる最大時間」より長くします。短すぎると、まだ処理中なのに別の consumer が同じメッセージを受け取り、二重処理が起きます。処理時間が読めない場合は ChangeMessageVisibility で延長できます。
削除は成功したときだけ
for (const message of messages) {
try {
const body = parseMessageBody(message.Body);
await processMessage(body);
/*
* 業務処理が成功した場合だけ削除する。
* 削除しなければ可視性タイムアウト後に再受信される。
*/
await deleteMessage(message.ReceiptHandle);
} catch (error) {
/*
* 失敗したメッセージは削除しない。
* 可視性タイムアウト後に再試行され、
* 最大受信数を超えるとDLQへ移動する。
*/
console.error("メッセージ処理に失敗しました", { messageId: message.MessageId });
}
}これが SQS を使う上でいちばん大事な形です。「例外が出たら削除しない」だけで再試行が実装できる。逆に、finally で削除したり受信直後に削除したりすると、失敗したメッセージが黙って消えます。
削除に使うのは MessageId ではなく ReceiptHandle です。ReceiptHandle は受信ごとに発行される一時的なトークンなので、DB に保存して後で使う類のものではありません。
なお、ループを try で囲む位置も重要です。1件の失敗で残りの処理が止まらないよう、メッセージ単位で try/catch するのが基本です。
失敗させて再配信を観察する
ここが理解の山場です。わざと失敗する consumer を書いて、削除しなかったメッセージがどうなるかを見ます。
const command = new ReceiveMessageCommand({
QueueUrl: queueUrl,
MaxNumberOfMessages: 1,
/*
* 学習を早く進めるため5秒にする。
* 本番では処理時間より十分に長くする。
*/
VisibilityTimeout: 5,
WaitTimeSeconds: 20,
MessageSystemAttributeNames: ["ApproximateReceiveCount"],
});
const message = (await sqsClient.send(command)).Messages?.[0];
console.log("受信", {
receiveCount: message.Attributes?.ApproximateReceiveCount,
});
throw new Error("メール送信処理に失敗しました");
/*
* DeleteMessageを呼ばないことが重要。
* メッセージは可視性タイムアウト後に再受信可能になる。
*/可視性タイムアウトを 5 秒に縮めているのがコツです。30 秒だと待ち時間が長くて検証が進みません。
npm run send # 1件送る
npm run consume:failure # 失敗(削除されない)
# 5秒待つ
npm run consume:failure # 同じメッセージが再受信され、receiveCount が増えるApproximateReceiveCount が 1 → 2 → 3 と増えていくのが見えます。これが SQS の再試行の実体で、アプリ側に再試行ロジックは一切ありません。
DLQ:何回失敗したら諦めるか
放っておくと壊れたメッセージが無限に再配信され続けます(メッセージ保持期間はデフォルト4日、最大14日)。これを止めるのが DLQ(デッドレターキュー) です。
DLQ は「専用の種類のキュー」ではなく、普通のキューを DLQ として指定するだけです。元のキューに RedrivePolicy を設定します。
{
"deadLetterTargetArn": "arn:aws:sqs:ap-northeast-1:123456789012:my-queue-dlq",
"maxReceiveCount": 3
}maxReceiveCount: 3 なら、4回目の受信時にメッセージは DLQ へ移されます。移動は SQS 側が勝手に行うので、アプリは何も書きません。
設定時の注意点:
- 標準キューの DLQ は標準キュー、FIFO キューの DLQ は FIFO キューにする(種類を揃える必要がある)
- 同一アカウント・同一リージョンである必要がある
- DLQ の保持期間は元キューより長くする。DLQ に入った時点で保持期間はリセットされないため、短いと調査前に消える
- DLQ が溜まったら CloudWatch でアラートを出す。DLQ は「置き場所」であって「通知」ではない
原因を直したら、redrive でマネジメントコンソールから元のキューへ戻せます。
キューの状態を覗く
「今キューに何件溜まっているのか」はコンソールでも見られますが、GetQueueAttributes で取れるようにしておくと検証が速くなります。
const command = new GetQueueAttributesCommand({
QueueUrl: queueUrl,
AttributeNames: [
"ApproximateNumberOfMessages",
"ApproximateNumberOfMessagesNotVisible",
"ApproximateNumberOfMessagesDelayed",
"VisibilityTimeout",
"ReceiveMessageWaitTimeSeconds",
"RedrivePolicy",
],
});
const attributes = (await sqsClient.send(command)).Attributes ?? {};
console.log({
availableMessages: attributes.ApproximateNumberOfMessages,
inFlightMessages: attributes.ApproximateNumberOfMessagesNotVisible,
delayedMessages: attributes.ApproximateNumberOfMessagesDelayed,
visibilityTimeout: attributes.VisibilityTimeout,
receiveWaitTime: attributes.ReceiveMessageWaitTimeSeconds,
redrivePolicy: attributes.RedrivePolicy
? JSON.parse(attributes.RedrivePolicy)
: null,
});見るべきは主に3つです。
ApproximateNumberOfMessages… 受信可能な件数(滞留)ApproximateNumberOfMessagesNotVisible… in-flight。誰かが受信して処理中(or 失敗して可視性タイムアウト待ち)ApproximateNumberOfMessagesDelayed… 遅延キュー/DelaySecondsで待機中
失敗 consumer を叩いた直後は in-flight が 1、5秒後に滞留が 1 に戻る、という動きが数字で追えます。Approximate と付いているのは分散システムゆえの概算値だからで、監視のしきい値としては使えますが厳密な在庫管理には向きません。
At-least-once:冪等性はアプリの責務
標準キューの配信保証は At-least-once(最低1回) です。順序もベストエフォートで、必ずしも送信順には届きません。つまり:
- 同じメッセージが2回届くことがある(削除リクエストが届かなかった、可視性タイムアウトが短かった、など)
- 順序が入れ替わることがある
したがって「1回しか処理してはいけない処理」は、アプリ側で冪等にする必要があります。eventId を producer で発行しているのはこのためで、典型的にはこうします。
- 処理済み
eventIdをユニークキー制約付きのテーブルに記録し、重複挿入で弾く - 更新処理を「同じ値を何度書いても結果が同じ」形(べき等な UPSERT)に寄せる
- メール送信のような外部副作用は、送信記録を先にコミットしてから送る
順序や重複排除を SQS 側に任せたい場合は FIFO キューという選択肢もありますが、MessageGroupId の設計次第でスループットが落ちるので、まずは標準キュー + アプリ側の冪等性が扱いやすい構成です。
まとめ
- メッセージは
DeleteMessageでしか消えない。成功時だけ削除するコードを書けば再試行は SQS がやる VisibilityTimeoutは処理時間より長く。短いと二重処理、長いと復活が遅いWaitTimeSecondsは必ず指定する(最大20秒)。ショートポーリングは空振りするしコストも高い- 無限再配信は
RedrivePolicyのmaxReceiveCountで止め、DLQ は保持期間を長めにして CloudWatch で通知する - 標準キューは At-least-once。冪等性はアプリの責務なので
eventIdを送信側で発行しておく
ApproximateReceiveCount が増えていくのを一度自分の目で見ると、SQS のリトライ設計は一気に腹落ちします。わざと失敗する consumer は、学習用に1本書いておく価値があります。
参考ソース
関連記事

Firebase Cloud Functions の onRequest(HTTP) と onCall(callable) を、認証・CORS・エラー処理・ストリーミングの観点で比較します。「アプリ内の本人確認が要る処理は onCall、監視・Webhook・ストリーミングなど外向き/特殊なものだけ onRequest」という選定基準に落とし込みます。

REST API v1 + Lambda を Terraform で組むと、ルート追加のたびに resource/method/integration/permission をコピペしがち。OpenAPI body import + module の for_each + 派生マップで、「エンドポイント追加は openapi.yml に1ブロック、Lambda 追加は locals に1行」に収める設計を、Makefile の zip ビルドと GitHub Actions まで含めてまとめます。

create-next-app から Amplify Gen2 のバックエンド(Cognito/DynamoDB/Lambda)を追加し、Server Actions での SSR 書き込み、Lambda を API Gateway で REST 公開、Next.js API Route、Amplify Hosting の Git 連携デプロイまでを一気通貫でまとめます。