どうもこんにちは。
情緒不安定なタイトルコールで失礼致します。
最近の業務で、非同期処理をSQS、Lambda、ECSなどを用いてサーバーレス構成で実現したいケースが出てきておりまして、まずはSQSについて理解を深めねばということで勉強してみた所存でございます。
キューって何??
キューは、英語で「Queue」と書きます。(クエクエじゃないよ。)この英単語の意味は、待ち行列・順番待ちです。技術用語のキューも、ほぼ同じニュアンスです。
簡単にいうと、「ある処理をすぐに実行せず、順番や処理能力に合わせて待機させておく」仕組みです。
この記事では、ConsumerとProducerは以下の意味で使用します。
-
Consumer: キューにメッセージを送る側 -
Producer: キューからメッセージを受け取り、実際に処理する側
注文API(Producer)
│ メッセージ送信
▼
SQS
│ メッセージ受信
▼
Lambda(Consumer)
AWSのキューイングサービス
AWSでキューイングを実現するサービスがAmazon SQSです。Producerが仕事を登録し、Consumerが都合のよい速度で処理することで、リクエスト処理と重いバックグラウンド処理を分離できます。
Amazon SQSに存在するキューの種類
キューに種類が存在するんか!という感じかなとは思いますが、Amazon SQSのキュー種別は次の2種類です。
- 標準キュー(Standard Queue)
- FIFOキュー(FIFO Queue)
| 観点 | Standard Queue | FIFO Queue |
|---|---|---|
| 配信 | 少なくとも1回配信です。重複配信があり得ます | 送信時は5分間の重複排除があります。削除前に失敗すると再配信され得るため、冪等性が必要です |
| 順序 | ベストエフォートです。順序が前後することがあります |
同じMessageGroupId内でFIFO順序です |
| Consumerの設計 | 重複を前提に冪等にします | 削除前の失敗に備えて冪等にします |
| 向く仕事 | 順序に依存しない大量の非同期処理です | 注文・状態遷移など、同じエンティティの処理順が意味を持つ仕事です |
スループット: 1秒間に処理できるメッセージの数(トランザクション数/秒:TPS)です。
FIFOキューの順序が厳密であるのは、MessageGroupIdというグループ内でのことです。キュー全体で順番が守られるというルールではありません。
以下のように各注文が順番通りに並列して処理が行われます。
注文-001:
受注 -> 決済 -> 梱包 -> 出荷
注文-002:
受注 -> 決済 -> 梱包 -> 出荷
たとえば注文IDをMessageGroupIdにすれば、注文ごとには順序を守りつつ、異なる注文は並列に処理できます。キュー全体を1本の順序にしたい場合は全メッセージに同じMessageGroupIdを指定できますが、並列性は失われます。
一旦2種類のキューについてまとめ
上記の比較では、以下のように整理できます。
- Standard Queue: 一般的な非同期処理
- FIFO Queue: エンティティごとの順番に重きを置く処理
標準キューでグループごとの待ち時間を公平にしたい場合
Fair Queue
Fair Queueは、Standard QueueへMessageGroupIdを付けて送信したときに使える、公平性を高めるための機能です。
MessageGroupIdごとの順序保証や直列処理を行うものではありません。
共有キューで、あるテナントだけが大量のメッセージを送って他テナントの待ち時間まで悪化させる事象を緩和したいときに使います。たとえばテナントIDを指定します。
tenant-a の仕事 → MessageGroupId = tenant-a
tenant-b の仕事 → MessageGroupId = tenant-b
これにより、処理を大量送信するテナントの後ろで小さく動いているテナントの処理が滞留しにくくなります。
- テナントごとの消費レートや均等なスループットを保証する機能ではありません。
- 順序や同じグループの直列処理が必要ならFIFO Queueを選びます。
遅延キューと可視性タイムアウトは似てるけど違う
Amazon SQSには、メッセージを「今すぐ処理しない」ための仕組みが2つあります。
- 遅延キュー(Delay Queue)
- 可視性タイムアウト(Visibility Timeout)
似ていて混同しやすいため、作用する時点を分けて整理します。
遅延キュー
遅延キューは、送信したメッセージを一定時間キューから見えない状態にする機能です。
例えば、以下のようなケースで利用します。
- 数秒後に処理を開始したい
- 外部サービスの反映待ちを行いたい
- リトライまで一定時間待機させたい
イメージとしては以下のようになります。
送信
│
▼
──────────────
30秒間見えない
──────────────
│
▼
Consumerが受信可能
遅延時間は0〜900秒、つまり最大15分です。Standard Queueではメッセージ単位のDelaySecondsも指定できますが、FIFO Queueではメッセージ単位のDelaySecondsは使えません。FIFO Queueでの遅延はキュー単位で設定します。15分を超える開始待ちには、SQSのDelayではなくEventBridge Schedulerを検討します。
可視性タイムアウト
一方、可視性タイムアウトは、Consumerが受信したメッセージを他のConsumerから見えなくする機能です。
例えば、Consumer Aがメッセージを受信して処理を開始したとします。
Producer
│
▼
+--------+
| SQS |
+--------+
│
▼
Consumer A
このタイミングで別のConsumerが存在していても、そのメッセージは取得できません。
Producer
│
▼
+--------+
| SQS |
+--------+
│
├── Consumer A(処理中)
│
└── Consumer B(取得不可)
Consumer Aが処理中に障害で停止した場合、可視性タイムアウトが終了すると、そのメッセージは再びキューに現れます。
Consumer Aが障害
│
▼
可視性タイムアウト終了
│
▼
メッセージが再表示
│
▼
Consumer Bが取得
この仕組みにより、処理中の重複実行を抑えつつ、障害時には再実行できます。個別メッセージはChangeMessageVisibilityで延長・短縮できますが、延長しても初回受信から最大12時間という上限はリセットされません。12時間を超える処理は、処理を分割して別の仕組みで状態を管理します。
遅延キューと可視性タイムアウトの違い
| 遅延キュー | 可視性タイムアウト | |
|---|---|---|
| 対象 | 送信直後 | 受信後 |
| 見えない相手 | すべてのConsumer | 他のConsumer |
| 主な目的 | 処理開始を遅らせる | 処理中の重複実行を抑える |
- 遅延キューは「まだ処理を始めない」ための機能です
- 可視性タイムアウトは「誰かが処理している間、他のConsumerが触れない」ための機能です
参考: Delay queues、Visibility timeout
ロングポーリング
ConsumerはSQSに対して「新しいメッセージある?」と問い合わせます。この問い合わせ方法にはショートポーリングとロングポーリングがあります。
ショートポーリング
ショートポーリングでは、メッセージが存在しなければ即座にレスポンスが返ります。
Consumer
│ ReceiveMessage
▼
+--------+
| SQS |
+--------+
│
└── メッセージなし
↓
すぐレスポンス
この方法では、何度も問い合わせることになり、API呼び出し回数が増えます。
ロングポーリング
ロングポーリングでは、メッセージが到着するまで最大20秒待機します。
Consumer
│ ReceiveMessage
▼
+--------+
| SQS |
+--------+
│
├── メッセージなし
│ ↓
│ 最大20秒待機
│
└── メッセージ到着
↓
すぐ返却
無駄なAPI呼び出しと空レスポンスを減らせるため、コストを抑えられます。これはメッセージの開始を遅らせる機能ではなく、ConsumerのReceiveMessage要求を待たせる機能です。
デッドレターキュー(DLQ)
メッセージの処理が何度試しても失敗することがあります。たとえば、バグがある、データが壊れている、必須項目が不足しているといったケースです。
このようなメッセージを何度もリトライすると、正常なメッセージまで処理が遅れます。そこで利用するのがデッドレターキュー(Dead Letter Queue、DLQ)です。
処理失敗
│
▼
+--------+ リトライ
| SQS |─────────────┐
+--------+ │
│ │
▼ │
Consumer │
│ │
└──────失敗───────┘
│
最大受信回数到達
│
▼
+----------------+
| DLQ |
+----------------+
あらかじめmaxReceiveCountを設定しておくと、maxReceiveCount回までに削除されなかったメッセージがDLQへ移動します。これにより、問題のあるメッセージを隔離し、正常なメッセージの処理を止めず、後から原因調査できます。
冪等性をConsumerの契約にする
SQSでは、受信、業務処理、DeleteMessageが別操作です。したがって、次のように同じメッセージが再配信されることがあります。
1. Consumerが注文 order-100 をDBへ反映する
2. DeleteMessageの前にConsumerがタイムアウトする
3. order-100 が再配信される
メッセージに業務上の一意なIDを持たせ、処理済みIDを永続化して二度目の副作用を行わないようにします。DBの一意制約、条件付き更新、外部APIの冪等性キーを使い、同じメッセージを受け取っても結果が同じになるようにします。これはStandard QueueだけでなくFIFO Queueでも必要です。
長期処理用のキューはある?
SQSに「長期処理用」という別のキューはありません。
15分を超える開始待ちにはEventBridge Scheduler、複数段階の長い処理にはStep Functions、長時間のコンテナ処理にはAWS Batchなどを検討します。
まとめ
用途から選ぶと、SQSの機能を混同しにくくなります。
| 要件 | 選択 |
|---|---|
| 高スループットで、順序・重複をConsumer側で扱える | Standard Queue + 冪等なConsumer |
| 共有キューで、静かなテナントの待ち時間を守りたい | Standard Queue + テナントIDをMessageGroupIdにしたFair Queue |
| エンティティごとの順序が必要 | FIFO Queue + エンティティIDをMessageGroupIdにする |
| 数秒〜15分だけ開始を遅らせたい | Delay Queue |
| 受信した処理を一時的に占有したい | Visibility Timeout |
| 空の受信要求を待たせてAPI呼び出しを減らしたい | Long polling |
| 何度も失敗する仕事を隔離して調査したい | DLQ |
CDKで確認してみる
このリポジトリのCDK学習デモでは、Standard、FIFO、Fair、Delay、Visibility、Long pollingの6つのキューを一つずつ検証できます。ローカルでは次のコマンドを実行します。
npm install
npm test
npm run test:lambda
npm run build
npm run synth
npm run synthはCloudFormationテンプレートをローカルに生成するだけで、AWSへデプロイしません。
デプロイ後にAWSで検証してみる
ここからは、スタックをデプロイした後の確認手順です。最初に、操作対象のアカウントとリージョンを確認します。
export AWS_REGION=ap-northeast-1
aws sts get-caller-identity
CloudFormationコンソールでSqsLearningDemoStackを開き、ステータスがCREATE_COMPLETEであることを確認します。Outputsタブには、6キューそれぞれのURLとARNが出力されます。
コンソールで設定を照合する
Amazon SQSコンソールで各キューを開き、Editから設定を見ます。
| キュー | 見る設定 | 想定値 |
|---|---|---|
| Standard | タイプ、Visibility timeout | Standard、30秒 |
| FIFO | タイプ、Content-based deduplication、Visibility timeout | FIFO、有効、30秒 |
| Fair | タイプ、Visibility timeout | Standard、30秒 |
| Delay | Delivery delay、Visibility timeout | 60秒、30秒 |
| Visibility | Visibility timeout | 300秒 |
| Long Polling | Receive message wait time、Visibility timeout | 20秒、30秒 |
次にAWS Lambdaコンソールで各Consumerを開き、Configuration → Triggersを見ます。対応するSQSキューがトリガーとして登録され、バッチサイズが1になっていれば想定どおりです。
メッセージを送ってConsumerを観察する
SQSコンソールのSend and receive messagesから送信できます。ここではCLIで、Standard QueueとFIFO Queueを実際にたどります。送信時にMessageIdが返ればSQSが受理したことは分かりますが、Consumerが処理できたかどうかはCloudWatch Logsで確認します。
Producer
│ メッセージ送信
▼
+--------+ SQSトリガー +----------------+
| SQS | ────────────────────────▶ | Lambda Consumer|
+--------+ +----------------+
│
▼
CloudWatch Logs
まず、CloudFormationのOutputsからキューURLを変数へ入れます。別のターミナルを開いた場合は、もう一度この手順を実行します。
STACK_NAME=SqsLearningDemoStack
STANDARD_QUEUE_URL="$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='StandardQueueUrl'].OutputValue | [0]" \
--output text)"
FIFO_QUEUE_URL="$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='FifoQueueUrl'].OutputValue | [0]" \
--output text)"
Standard Queueを送信・確認する
aws sqs send-message \
--queue-url "$STANDARD_QUEUE_URL" \
--message-body 'standard message'
次に、Standard Consumerの名前を取得してログを確認します。AWS CLI v1ではaws logs tailを使えないため、どちらのバージョンでも使えるfilter-log-eventsを使います。
STANDARD_CONSUMER_NAME="$(aws cloudformation list-stack-resources \
--stack-name "$STACK_NAME" \
--query "StackResourceSummaries[?ResourceType=='AWS::Lambda::Function' && contains(LogicalResourceId, 'StandardConsumer')].PhysicalResourceId | [0]" \
--output text)"
aws logs filter-log-events \
--log-group-name "/aws/lambda/${STANDARD_CONSUMER_NAME}" \
--start-time $(( ($(date +%s) - 600) * 1000 )) \
--query 'events[].message' \
--output text
Hello World!!、body=standard message、送信時に返ったMessageIdが表示されれば、Producer → SQS → Lambda Consumerが確認できています。Standard Queueではmessage_group_id=Noneと出ます。
FIFO Queueを送信・確認する
FIFOキューへ送るときはMessageGroupIdが必須です。同じグループorder-100へ連番を送ります。
aws sqs send-message \
--queue-url "$FIFO_QUEUE_URL" \
--message-body 'order created: 100' \
--message-group-id order-100
aws sqs send-message \
--queue-url "$FIFO_QUEUE_URL" \
--message-body 'order created: 101' \
--message-group-id order-100
aws sqs send-message \
--queue-url "$FIFO_QUEUE_URL" \
--message-body 'order created: 102' \
--message-group-id order-100
FIFOではSequenceNumberも返ります。送信順に増えていることを確認します。次に、FIFO Consumerのログを見ます。
FIFO_CONSUMER_NAME="$(aws cloudformation list-stack-resources \
--stack-name "$STACK_NAME" \
--query "StackResourceSummaries[?ResourceType=='AWS::Lambda::Function' && contains(LogicalResourceId, 'FifoConsumer')].PhysicalResourceId | [0]" \
--output text)"
aws logs filter-log-events \
--log-group-name "/aws/lambda/${FIFO_CONSUMER_NAME}" \
--start-time $(( ($(date +%s) - 600) * 1000 )) \
--query 'events[].message' \
--output text
body=order created: 100、101、102がこの順に並び、いずれにもmessage_group_id=order-100と出れば確認完了です。FIFOの順序保証はキュー全体ではなく、同じMessageGroupIdの中で働きます。
Fair Queueを送信する
Fair QueueはStandardキューにMessageGroupIdを付けて使います。順序保証の機能ではなく、共有キューでグループ間の公平性を高めるものです。
FAIR_QUEUE_URL="$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='FairQueueUrl'].OutputValue | [0]" \
--output text)"
aws sqs send-message \
--queue-url "$FAIR_QUEUE_URL" \
--message-body 'tenant-a work item' \
--message-group-id tenant-a
Fair Queueの最小構成で確認できるのは、MessageGroupId付きのメッセージを受け取りログへ出せることまでです。目に見える公平性を確認するには、複数テナントから継続的に送信してバックログを作る必要があります。
Delay、Visibility Timeout、Long pollingを観察する
- Delayキューへ送ったメッセージは、約60秒間Lambdaのログに現れません。60秒後にログが出ることを確認します。
- Visibility Timeoutを観察するときは、対応するLambdaトリガーを一時的に無効化し、ステータスが
Disabledになった後も20秒以上待ってから手動受信します。停止前から待機していたポーラーがメッセージを取得する可能性があるためです。受信後、約300秒間は同じメッセージを再受信できません。観察後はトリガーを必ず有効に戻します。 - Long pollingは、キューが空であることを確認すればLambdaトリガーを止めずに観察できます。
ReceiveMessageを実行すると、キュー属性に設定した最大20秒の待機後に空の応答が返ります。
LONG_POLLING_QUEUE_URL="$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='LongPollingQueueUrl'].OutputValue | [0]" \
--output text)"
aws sqs receive-message \
--queue-url "$LONG_POLLING_QUEUE_URL"
実測結果を貼っておく
ここまでの手順を、SqsLearningDemoStackへデプロイした環境で実行しました。以下はその結果です。アカウントID、CloudFormationが付与したリソース末尾、RequestId、MessageId、ReceiptHandle、ランタイムARNのハッシュはすべてマスキングしています。
Standard Queue: Consumerまで到達する
Hello World!!
queue=arn:aws:sqs:ap-northeast-1:<AWS_ACCOUNT_ID>:SqsLearningDemoStack-StandardQueue...-<RESOURCE_SUFFIX>
message_id=<MESSAGE_ID>
body=standard message
message_group_id=None
送信したstandard messageがLambdaへ渡り、正常に処理されました。Standard QueueではMessageGroupIdを指定していないので、ログもNoneです。
FIFO Queue: 同じグループで順番に処理する
body=order created: 100
message_group_id=order-100
body=order created: 101
message_group_id=order-100
body=order created: 102
message_group_id=order-100
同じorder-100グループへ100、101、102の順に送ると、Consumerのログも同じ順になりました。FIFOの順序保証は、キュー全体ではなくMessageGroupIdごとに働きます。
Fair Queue: グループIDをConsumerへ渡す
Hello World!!
queue=arn:aws:sqs:ap-northeast-1:<AWS_ACCOUNT_ID>:SqsLearningDemoStack-FairQueue...-<RESOURCE_SUFFIX>
message_id=<MESSAGE_ID>
body=tenant-a verification item
message_group_id=tenant-a
Standard Queueとして作成したFair Queueへ、tenant-aというグループID付きのメッセージを送信し、Consumerまで届くことを確認しました。ただし、これだけでは公平性そのものは証明できません。公平性を見るには、複数テナントから継続的にメッセージを送って、noisy neighborがいる状態を作る必要があります。
Delay Queue: 60秒後にConsumerへ届く
送信直後: Consumerのログ出力なし
約60秒後:
Hello World!!
queue=arn:aws:sqs:ap-northeast-1:<AWS_ACCOUNT_ID>:SqsLearningDemoStack-DelayQueue...-<RESOURCE_SUFFIX>
message_id=<MESSAGE_ID>
body=delay verification item
message_group_id=None
送信直後にはConsumerは動かず、約60秒後にLambdaが起動しました。これはキューに設定したDelivery delay = 60秒が、最初の配信可能時刻を遅らせているためです。
Visibility Timeout: 受信中は再取得できない
Visibility Consumerのトリガーを一時的に無効化し、停止前のポーラーを排出してからテストメッセージを送信しました。受信後、同じメッセージをすぐには取得できません。
FIRST_MESSAGE_ID=<MESSAGE_ID>
FIRST_APPROXIMATE_RECEIVE_COUNT=1
SECOND_RECEIVE_MESSAGES=null
TEST_MESSAGE_DELETED=true
1回目の受信ではメッセージを取得でき、直後の2回目はnullでした。このキューはVisibility Timeoutが300秒なので、削除しなければ約300秒間は他のConsumerから見えません。今回は検証メッセージをDeleteMessageで削除し、トリガーもEnabledへ戻しています。
Long polling: 空キューでもすぐには返さない
キューが空であることを確認してから、ReceiveMessageを実行しました。
VISIBLE_MESSAGES_BEFORE=0
RECEIVE_RESULT=null
ELAPSED_SECONDS=21
Long pollingのキュー属性は20秒です。実測は21秒で、ネットワークやコマンド起動のわずかな誤差を含めて、空キューでも約20秒待機してから空の結果を返すことを確認できました。
検証が終わったら、不要な課金を残さないようにスタックを削除します。削除前に、対象アカウントとスタック名が正しいことを確認してください。
npx cdk destroy SqsLearningDemoStack
以上