AWS SQS でレートリミットを実現する方法 / 予約済み同時実行数を設定する必要はないです

SQSでAWS Lambdaを起動する際、Lambdaの最大起動数≒レートリミット制御をしたいときがあると思います。

検索すると、Lambdaに「予約済み同時実行数」を設定する
という方法がよく見受けられますがあまりお勧めしません。以下の方法で実装することを推奨します。

結論

SQSでタスクを一定数のLambdaで処理したい場合は、 SQS FIFOキューを使用し、メッセージグループIDの種類をLambdaを並行起動したい数になるように設定して実現することをお勧めします。

(サンプルは記事下部に記載)

FIFOキューのグループIDについて

SQSのFIFOキューのメッセージグループIDについては以下のページに記載があります docs.aws.amazon.com

単一の FIFO キューに複数の順序付けされたメッセージグループをインターリーブするには、メッセージグループ ID 値を使用します (たとえば、複数のユーザーによるセッションデータ)。このシナリオでは、複数のコンシューマーでキューを処理できますが、各ユーザーのセッションデータは FIFO 方式で処理されます。

特定のメッセージグループ ID に属するメッセージが表示されない場合、他のコンシューマーでも同じメッセージグループ ID のメッセージを処理できません。

FIFOキューはキュー内のタスクに設定されたグループIDの数だけ同時に処理されます。

例えばグループID=AのメッセージがFIFOキューから処理されている場合、
そのタスクより後に追加されたグループAのタスクは、先頭のタスク処理が終了しない限り、コンシューマから取得できない状態になり処理されません。

すでにグループAのタスクが処理されている状態で、グループAのタスクを追加した場合、 追加されたタスクによってLamdaが新規に立ち上がることはありません。

一方で処理中でない、グループBのタスクを追加した場合には、
Lambdaが新規に立ち上がり、グループBのタスクがそれより前に追加されたグループAのタスクより先に処理されることになります。

このメッセージグループIDの仕組みを使うことで、Lambdaの並行起動数に制限がかけられます。

例えば、タスクを10件のLambdaで継続的に処理したい場合、
タスクをキューに追加するproducer側でメッセージIDに1-10の数字のどれかを振りましょう。
(なるべく各グループが均一になるように、処理時刻の小数点以下の秒数や乱数を使って発番するのが良いです)

こうすれば、タスクが最大10個のLambdaに割ふられ、想定した起動数以下でタスクを処理がされるようになります。

なぜLambdaの予約済み実行数を使わないのか?

SQSのレートリミットの実現方法を検索すると、 以下のドキュメントに記載されたLambdaの予約済み実行数の設定を使用する方法を見つけられます。

この方法は個人的にはあまりおすすめしません。

docs.aws.amazon.com

予約済み実行数を使わない理由/おすすめしない理由としては以下の2点があります

①SQSから起動するLambdaに予約済み実行数を設定すると、SQSタスクが大量に失敗する
②予約済み同時実行数はAWSアカウント単位の同時実行数を先食いする

①SQSから起動するLambdaに予約済み実行数を設定すると、SQSタスクが大量に失敗する

予約済み実行数を使わない方が良い一番の理由がこの問題です。 予約済み実行数(以下予約数)を使用した場合、Lambdaの処理/SQSの設定をタスクが大量に失敗する前提の設定にしないと意図通り動作しないためです。

以下の記事でも似たような問題提起がされています。
(この記事を書いた一番のモチベーションが、以下の記事と似た記述が日本語記事で見つからなかっためです)

We love AWS Lambda, but its concurrency handling with SQS is silly.

even if your concurrency is 2, Lambda will still start out by pulling 5 batches from SQS at a time, and it will still scale up to 1000.

So your Lambda is only allowed to run 2 at a time, but SQS is trying to push hundreds of events to Lambda, then almost all those events are going to be bounced back to SQS And this is a problem because (typically) you’ll configure SQS to only attempt each item a few times before it gives up and sends that item to the Dead Letter Queue (DLQ).

これが意味することは、

  • 仮に予約済み実行数をLambdaに設定しても、SQSはLambdaを立ち上げようとする
  • ただしLambda側に予約数が設定されているため、このLambdaの立ち上げは失敗する
    • タスクはLambdaでエラーが起きた場合と同じように、非表示状態のまま、タスクの失敗数が1加算される
  • このときSQSの再実行ポリシーの最大受信数をが少ない設定だった場合、タスクが数回失敗扱いになるだけでそのタスクがDLQに送られ処理されなくなる

というようなところです。

具体例

具体的な数字を使った例で説明すると、

Lambdaの予約数を使ってSQSのLambda並行数を制御した場合で、 タスクが10000個、予約数を5としたとき

  • 最初に先頭タスク5個が処理されLambdaが5個起動する、残りの9995件のタスクは失敗扱いになり非表示になる
  • SQSの可視性タイムアウト秒数後、次に5-10番目のタスクが処理される。残りの9990件のタスクは失敗扱いになり非表示なる
  • 繰り返す…

となり、簡単に見積もると10000番目ののタスクは1999回失敗することになります。 しかも可視性タイムアウト秒数が30秒だったとすると、実行されるのは1000分後になります。

もしSQSにこの設定をした場合、必ず失敗すタスクをキューに追加してしまった場合でも、そのタスクがDLQに移動するのは1000分後になるため、 1000分後までそのキューは無駄なリトライを繰り返すことになってしまいます。

(実際にはバッチ数やタスク処理の具合によってこの数字にはならないかも、ただ同様の現象は起きる)

このようにLambdaの予約済み実行数による並行制御は特にタスクが大量に入るようなときには非常に扱いが難しくなります。 以上が予約済み実行数をおすすめしない理由の1つになります。

②予約済み同時実行数はAWSアカウント単位の同時実行数を先食いする

予約数をお奨めしない理由の2つ目が、Lambda全体の同時実行数を事前に消費してしまうという点です。

みなさんがLambdaを利用するのは、「自動的に起動数が拡大するためほぼ無制限にスケールアウトする」というのが1つの理由だと思いますが、 このLambdaがどこまでスケールアウトするか(=起動数が増やせるか)は、アカウント内のLambda最大同時実行数としてクオータで規定されています(デフォルトだと1000)。

Lambda クォータ - AWS Lambda

この同時実行数はアカウント内で共有されるリソースとして考えることができます。 一方で、予約済み実行数をある特定のLambdaに設定した場合、この共有リソースである同時実行数をそのLambdaで占有することになります。 予約した実行数を単にそのLambdaの最大並列数を制限する機能と考えると踏んでしまいそうな挙動になりますが、 実際には予約済み同時実行数は他のLambdaの起動にも影響を及ぼす設定です。

例えばあるLambda:Aに予約数200を設定した場合、残りのLambdaは最大でも800までしかスケールアウトできなくなります (アカウントのLambda起動数がデフォルト1000の場合)。 もし月に1回しか動作しないバッチ用途のためにLambdaに予約数を設定してしまうと、 バッチが動作しないほとんどの時間帯のスケールアウト上限を犠牲にしてしまうことになってしまうわけですね。

この挙動は以下のドキュメントにも記載されています。 docs.aws.amazon.com

予約同時実行 – 予約同時実行は、関数の同時インスタンスの最大数を保証します。ある関数が予約済同時実行数を使用している場合、他の関数はその同時実行数を使用できません。関数に対して予約済み同時実行を設定する場合には料金はかかりません。

予約済み同時実行数は、他のLambda実行に影響を及ぼす場合があるため、使う場合はよく注意して使用した方が良いですね。
特にキューを500個のLambdaで処理するために、予約済み同時実行数に大きい数を設定すると、他のLambdaがスケールアウトする余地がなくなってしまうことが予想されます。 このためFIFOキューを使って実装した方が他のLambda実行にも影響を及ばさず良いかと思います。

おまけ-実際に起動数を制御してみた

実際にFIFOキューでLambdaの同時実行数が制御できることを確認してみました。

Lambdaの処理

const sleep = msec => new Promise(resolve => setTimeout(resolve, msec));

exports.handler = async (event) => {
    
    console.log("###START",event.Records[0].body)
    
    await sleep(3000)
    
    console.log("###END",event.Records[0].body)
};

この際設定でSQS処理時のバッチサイズを1にしています。 またSQSの種別はFIFOキューです。

タスクの追加

グループIDを設定したタスクを追加します。

aws sqs send-message --queue-url "$MYSQS" --message-body "GroupA:Task1" --message-group-id "A"
aws sqs send-message --queue-url "$MYSQS" --message-body "GroupA:Task2" --message-group-id "A" 
aws sqs send-message --queue-url "$MYSQS" --message-body "GroupA:Task3" --message-group-id "A" 
aws sqs send-message --queue-url "$MYSQS" --message-body "GroupB:Task1" --message-group-id "B"
aws sqs send-message --queue-url "$MYSQS" --message-body "GroupB:Task2" --message-group-id "B"
aws sqs send-message --queue-url "$MYSQS" --message-body "GroupB:Task3" --message-group-id "B"
aws sqs send-message --queue-url "$MYSQS" --message-body "GroupC:Task1" --message-group-id "C"
aws sqs send-message --queue-url "$MYSQS" --message-body "GroupC:Task2" --message-group-id "C"
aws sqs send-message --queue-url "$MYSQS" --message-body "GroupC:Task3" --message-group-id "C"
aws sqs send-message --queue-url "$MYSQS" --message-body "GroupD:Task1" --message-group-id "D"

実行結果

キューのタスクが一定数のLambdaで処理されている様子を確認できました。

赤、青、緑、黄色がそれぞれグループABCDの処理を表しています。 これを見ると想定通り、グループの数だけlambda処理が並行起動していることが確認できます。

補足

もしタスクがあまり多くない場合は予約済み実行数+SQSでも数回のリトライ内でタスクを処理し切れるため、 状況に応じた実現方法を選択するのが良いかと思います。

ただし、その場合でも初めからFIFOキュー+グループIDを設定する方が、 無駄なLambda失敗数がメトリクスに上がることがないため良いかと思います。

注意事項

FIFOキューを使った実現方法にも一部制限があります。

Amazon SQS 可視性タイムアウト - Amazon Simple Queue Service

FIFO キューの場合、最大 20,000 のインフライトメッセージが存在できます FIFO キューは、最初の 20k メッセージを検索して、使用可能なメッセージグループを判別します。つまり、単一のメッセージグループにメッセージのバックログがある場合、バックログからメッセージを正常に消費するまで、後からキューに送信された他のメッセージグループからのメッセージを使用することはできません。

FIFOキューがタスクのグループIDを認識して、現在実行中でないグループのタスクを処理できる状態にするためには、 そのメッセージが先頭から20k以内である必要があります。 タスクの量が20kを超える場合は、タスクを追加する順番によっては意図した数のLambda起動数より少なくなる恐れがあります。

また、そもそもFIFOキューは通常のキューと比べるとメッセージ重複排除IDという概念があるため、その点も注意してください。

補足2 FIFOキューで先頭のタスクがエラーになった場合後続のメッセージは処理されるのか?

FIFOキュー内のグループの先頭のタスクがエラーで可視性タイムアウトに陥った際に、後続の同じグループIDのものが処理されるのかについて、 可視性タイムアウトのページに以下の記載があります

https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html

メッセージグループ ID があるメッセージを受信した場合、そのメッセージを削除しない、または表示されない限り、同じメッセージグループ ID のメッセージはそれ以上返されません

表示されない限り=可視性タイムアウトの時間のため、 グループIDが設定されたタスクが非表示になっただけでは後続の同じグループのタスクは非表示のままになるかと思います。

以上