AWS SQSのデッドレターキュー(DLQ)を試して理解する
概要
こんにちは🙌
AWS SQSは設定が少なく、手軽に始められるメッセージキューサービスです。しかし、実運用においてデッドレターキュー(以後DLQ)の理解は避けて通れません。
本記事では、実際にSQSを動かしながら、DLQの動作や設定について理解を深めていきます。
説明しないこと
- 基本的なSQS関連の用語(エンキュー、デキュー、コンシューマ、プロデューサ等)
- Goプログラムの作成、実行方法
ゴール
- DLQ作成方法を理解する
- DLQにメッセージが移動するケースを整理して試してみる
- DLQにメッセージが移動した後の処理を試してみる
SQSにおけるデッドレターキュー(DLQ)の基本
デッドレターキュー(DLQ)は、正常に処理できなかったメッセージを保管するための特別なキューです。以下のような場合にメッセージがDLQに移動されます:
- メッセージの処理が指定回数失敗した場合
- 処理時間が可視性タイムアウトを超過した場合
DLQを設定するには、以下の手順が必要です:
- DLQ用の通常のSQSキューを作成
- メインのキューの設定でDLQを有効化し、作成したキューを指定
- 最大受信回数を設定(メッセージが何回処理に失敗したらDLQに移動するか)
知っておくと良い前提知識
標準キュー と FIFOキュー
AWS SQSでは、タイプとして標準
とFIFO
で選択することが可能となっています。
-
標準
の場合は、最低1回の配信が行われ、順序はベストエフォートとなります。 -
FIFO
の場合、1回のみの配信が行われ、順序が保証されています。
FIFOを使う場合、プロデューサからキューにリクエストを送る際に、MessageGroupId
というパラメータが必須となります。
FIFOは、同一のMessageGroupID内ではメッセージの順序性が保証されているということになります。異なるMessageGroupIDを設定した場合、メッセージは送信された順に処理されるとは限りません。
AWS公式 builders.flushで詳しくFIFOの特徴について説明している記事があるため、さらに知りたいという方はこちらも参考にしてみてください。
FIFOキューの方が料金が少しだけ高いですが、そこまで大きな差ではありません。
2025/2時点(東京リージョン)
最初の 100 万リクエスト/月:標準、FIFOともに無料
標準キュー (100 万リクエストあたり) :USD 0.40
FIFO キュー (100 万リクエストあたり):USD 0.50
可視性タイムアウト と 保持期間
SQSを作成しようとするといくつかの設定値の入力を求められます。その中でも知っておくと、DLQを使う時に理解が進む、というよりもSQSを使うのであれば、必ず設定値 可視性タイムアウト
と保持期間
の違いを知っておくべきだと思うので紹介をしておきます。
-
可視性タイムアウト
可視性タイムアウトは、(1 つのコンシューマーによって) キューから受信されたメッセージが他のメッセージコンシューマーに表示されない時間となります。
つまり、コンシューマが処理するためのタイムアウト時間です。これによって別のコンシューマが同一のキューを処理することが無いようになっています。
可視性タイムアウト -
保持期間
キューがメッセージを保持している時間のことです。保持期間を過ぎるとメッセージはキューから削除されます。挙動としては、削除となるためDLQにも入りません。
保持期間
こちらもAWS公式 builders.flushで詳しく説明している記事があるため、さらに知りたいという方はこちらも参考にしてみてください。
環境構築
サンプルコード
※.env.example
は、環境に応じて書き換えて、.env
として読み込みます。
環境
- EC2 x1(Goが実行出来る環境)
- SQS x2(標準、標準DLQ)
試してみる
ということで、早速準備した環境で試してみたいと思います。
エンキューとデキューが出来ることを確認してから、DLQに入るケースとして想定される2パターンで試してみて、どういった挙動となるのか、DLQに入った後に何が出来るのかを理解していきます。
通常のエンキュー/デキューの確認
- プロデューサプログラムを実行して、キューにリクエストを送ります。
$ go run producer.go
yyyy/MM/dd hh:mm:ss メッセージを送信しました: msg-1
yyyy/MM/dd hh:mm:ss メッセージを送信しました: msg-2
yyyy/MM/dd hh:mm:ss メッセージを送信しました: msg-3
-
マネジメントコンソールを確認すると、メッセージが3件利用可能になっていることが分かります。
-
コンシューマプログラムを実行して、メッセージを取得してみます。
$ go run consumer.go
yyyy/MM/dd hh:mm:ss メッセージの処理回数: 1
受信したメッセージ:
ID: msg-2
Content: テストメッセージ
Timestamp: yyyy-MM-ddThh:mm:ssZ
yyyy/MM/dd hh:mm:ss メッセージを正常に処理しました: msg-2
--------------------------------
yyyy/MM/dd hh:mm:ss メッセージの処理回数: 1
受信したメッセージ:
ID: msg-1
Content: テストメッセージ
Timestamp: yyyy-MM-ddThh:mm:ssZ
yyyy/MM/dd hh:mm:ss メッセージを正常に処理しました: msg-1
--------------------------------
yyyy/MM/dd hh:mm:ss メッセージの処理回数: 1
受信したメッセージ:
ID: msg-3
Content: テストメッセージ
Timestamp: yyyy-MM-ddThh:mm:ssZ
yyyy/MM/dd hh:mm:ss メッセージを正常に処理しました: msg-3
--------------------------------
問題なく、エンキューとデキューを試すことが出来ました。
出力結果を見ると分かりますが、エンキューとデキューの順序が異なっていることが分かります。順序が保証されていない標準キューを使った場合、このようになります。
コンシューマがメッセージの処理に失敗するケース
設定値
可視性タイムアウト:30秒
デッドレターキュー最大受信数:3
プロデューサプログラムを書き換えて、コンシューマがメッセージの処理に失敗する(キューから削除されない)ケースをシミュレートして試してみます。
// メッセージの送信ループ
for {
msg := Message{
- ID: generateUUID(),
+ ID: "error",
Content: "テストメッセージ",
Timestamp: time.Now(),
}
- プロデューサプログラムを実行して、キューにエラー用のリクエストを送ります。
$ go run producer.go
yyyy/MM/dd hh:mm:ss メッセージを送信しました: error
yyyy/MM/dd hh:mm:ss メッセージを送信しました: error
yyyy/MM/dd hh:mm:ss メッセージを送信しました: error
- コンシューマプログラムを実行してみます。
$ go run consumer.go
yyyy/MM/dd hh:mm:ss メッセージの処理回数: 1
受信したメッセージ:
ID: error
Content: テストメッセージ
Timestamp: yyyy-MM-ddThh:mm:ssZ
yyyy/MM/dd hh:mm:ss エラーメッセージを検出しました。処理をスキップします: error
--------------------------------
[省略(可視性タイムアウトごとにスキップ処理が3回行われます。)]
yyyy/MM/dd hh:mm:ss メッセージの処理回数: 3
受信したメッセージ:
ID: error
Content: テストメッセージ
Timestamp: yyyy-MM-ddThh:mm:ssZ
yyyy/MM/dd hh:mm:ss エラーメッセージを検出しました。処理をスキップします: error
--------------------------------
- マネジメントコンソールを確認すると、DLQにメッセージが移動していることが分かります。
コンシューマの処理時間が可視性タイムアウトを超えたケース
設定値
可視性タイムアウト:30秒
デッドレターキュー最大受信数:3
処理時間を40秒としています。可視性タイムアウトの時間を超えるので、メッセージが処理(キューから削除)されることがありません。
// DLQテストシナリオ2: 処理時間超過をシミュレート
// IDに "timeout" が含まれている場合は処理を遅延させる
if msg.ID == "timeout" {
log.Printf("タイムアウトメッセージを検出しました。処理を遅延させます: %s", msg.ID)
// 可視性タイムアウトより長い時間スリープ
time.Sleep(40 * time.Second)
continue
}
では、エラーと同様に、プロデューサプログラムを書き換えて、コンシューマの処理がタイムアウトするケースをシミュレートして試してみます。
// メッセージの送信ループ
for {
msg := Message{
- ID: generateUUID(),
+ ID: "timeout",
Content: "テストメッセージ",
Timestamp: time.Now(),
}
- プロデューサプログラムを実行して、キューにタイムアウト用のリクエストを送ります。
$ go run producer.go
yyyy/MM/dd hh:mm:ss メッセージを送信しました: timeout
yyyy/MM/dd hh:mm:ss メッセージを送信しました: timeout
- コンシューマプログラムを実行してみます。
$ go run consumer.go
yyyy/MM/dd hh:mm:ss メッセージの処理回数: 1
受信したメッセージ:
ID: timeout
Content: テストメッセージ
Timestamp: yyyy-MM-ddThh:mm:ssZ
yyyy/MM/dd hh:mm:ss タイムアウトメッセージを検出しました。処理を遅延させます: timeout
--------------------------------
[省略(可視性タイムアウトの30秒ごとに1つのメッセージを処理しようとする動きが続きます。)]
yyyy/MM/dd hh:mm:ss メッセージの処理回数: 3
受信したメッセージ:
ID: timeout
Content: テストメッセージ
Timestamp: yyyy-MM-ddThh:mm:ssZ
yyyy/MM/dd hh:mm:ss タイムアウトメッセージを検出しました。処理を遅延させます: timeout
--------------------------------
- マネジメントコンソールを確認すると、DLQにメッセージが移動していることが分かります。
メッセージ保持期間を超過するケース
設定値
保持期間:3分
- プロデューサプログラムを実行して、キューにタイムアウト用のリクエストを送ります。
$ go run producer.go
yyyy/MM/dd hh:mm:ss メッセージを送信しました: msg-1
yyyy/MM/dd hh:mm:ss メッセージを送信しました: msg-2
yyyy/MM/dd hh:mm:ss メッセージを送信しました: msg-3
- 3分放置後に、マネジメントコンソールを確認します。
すると、DLQには入っていなく、メッセージ自体が削除されたことが分かります。
DLQに入ったメッセージの取り扱い
最後にDLQに入ったメッセージに対してどういった操作が出来るのかを確認します。
-
キューに入っているメッセージを確認すること
メッセージを送受信から、ポーリングして確認します。
-
通常のキューに戻す、DLQ再処理
ソースキューもしくは、任意の同一タイプのキューに送ることが出来ます。
-
メッセージが不要であればクリアすること
これは、DLQだけではないですが、不要であれば全てクリアすることも可能です。
個別にクリアしたい場合、ポーリングしてから1つずつ削除対象を選択して削除します。
LambdaからSQSのAPIにリクエストを送って、DLQに入ったメッセージの処理を自動化できそうなことが想像されます。
FIFOキューのDLQ動作について
これまで標準キューのDLQについて見てきましたが、FIFOキューでDLQを使用する場合は、いくつか重要な考慮点があります。
SQS FIFOのDLQは、失敗したメッセージを元のmessageGroupIdを保持したまま移動させます。
このとき、同じmessageGroupIdを持つメッセージはメインのFIFOキューでブロックされ、DLQのメッセージが処理されるまで継続します。
この状況に対処するには、DLQのメッセージを処理してメインのFIFOキューに戻すか、別のロジックで処理してブロックを解除する必要があります。
動作としては、以下の通りです。
メッセージを削除(DeleteMessage)=> 同じmessageGroupIdの次のメッセージが処理可能になる
メッセージがDLQに移動 => 同じmessageGroupIdのメッセージはブロックされたまま
おわりに
AWS SQSのDLQについて実際に色々と試しながら、どういった動作になるのかを整理してみました。
ちなみに、SQSを知るにあたっては、AWSから公開されているBlackBelt資料が最も分かりやすいと思います。サービスの選定基準からユースケースも複数種類書かれているので、利用に悩んでいる時に本当に必要かどうか当てはめながら考えることができると思います。
Discussion