[レポート] SNSとSQSとLambdaによるスケーラブルでサーバーレスなイベント駆動アーキテクチャ #reinvent #svs303

こんにちは。サービスグループの武田です。開催中のre:Invent 2020でScalable serverless event-driven architectures with SNS, SQS & Lambdaのセッションを視聴しましたのでレポートします。
2020.12.09

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。サービスグループの武田です。

開催中のre:Invent 2020でScalable serverless event-driven architectures with SNS, SQS & Lambdaのセッションを視聴しましたのでレポートします。

何度か配信がありますので視聴したい方はスケジュールを確認してみてください。

AWS re:Invent 2020

セッション概要

  • スピーカー
    • Justin Pirtle(AWS Speaker)
  • タイトル
    • Scalable serverless event-driven architectures with SNS, SQS & Lambda
    • SVS303

イベント駆動型のサーバレスアーキテクチャは、アプリケーションをシームレスに拡張して、ほぼすべての需要に対応できるようにし、従量課金と最小限の運用オーバーヘッドの恩恵を受けます。このセッションでは、キュー、パブリッシュ/サブスクライブトピック、フロントエンドAPIとAWS Lambdaベースのオンデマンド/自動スケーリングサーバーレスイベント処理を組み合わせたエンドツーエンドのアーキテクチャを構築するためのアーキテクチャパターンとベストプラクティスを学びます。配信と注文の保証、イベント消費モデル、インフラコストを考慮した最適なイベント駆動型アーキテクチャを構築するための指針を学ぶことができます。このセッションで紹介するAWSサービスには、Amazon SNS、Amazon SQS、AWS Lambdaが含まれます。

アジェンダ

  • メッセージングサービスの概要
  • イベント処理モデル
  • Lambdaの同時実行およびスケーリング
  • エラー処理とベストプラクティス
  • 最適なメッセージングサービスの選択

メッセージングサービスの概要

まずはAmazon Simple Notification Service(SNS)です。次のような特徴があります。

  • Pub/Subモデルのメッセージングサービス
  • トピックにメッセージを送信すると1人以上のサブスクライバに並行してメッセージをプッシュする
    • ファンアウト と呼ばれるパターン
  • メッセージをフィルタリングすることで特定のメッセージのみ受信が可能
  • HTTP/S、Email、Amazon SQS、Lambda、SMS、モバイルプッシュなど多数のプロトコルに対応
    • このセッションではAmazon SQSとLambdaに焦点を当てる

続いてAmazon Simple Queue Service(SQS)です。

  • 耐久性と拡張性に優れたメッセージキューサービス
  • 任意の容量のメッセージをサポートし、レートはほぼ無制限
  • キューイングされたメッセージはコンシューマーが取りに行くプル型で、一度に複数のメッセージを受信できる
  • At-least once および Exactly once のキューをサポート
    • At-least once(少なくとも1回)は、「メッセージは欠損しないが重複しうる」という意味で、標準キューはこの形式
    • Exactly onceは(正確に1回)、「メッセージは欠損しないし重複もしない」という意味で、FIFOキューはこの形式
  • 可視性タイムアウトによるエラーハンドリング
  • このセッションではLambdaによるキューのロングポーリングを取り上げる

SNSとSQSは相互に連携できます。この例では1つのSNSトピックに対して2つのSQSキューがサブスクライブしています。ファンアウトしたそれぞれのキューでバッファリングをし、独立したパイプラインを構築しています。

イベント処理モデル

異なる3つのLambda呼び出しモデルを理解することが重要です。

  • 同期呼び出し
    • API Gateway
  • 非同期呼び出し
    • SNSやS3
  • プルベース
    • SQSやKinesis

プルベースではユーザーの代わりにLambdaサービスがロングポーリングを行います。キューにメッセージがある場合にのみ、関数が実行されます。

SNSからLambdaの非同期呼び出しを詳しく見ていきます。SNSトピックがあり、メッセージを送信しています。トピックは3つの異なるサブスクライバがいて、それぞれが異なる機能を実装しています。Function Cはフィルターポリシーを設定しているため、実際にメッセージを受信するのはFunction AとFunction Bの2つだけです。

内部の動作を詳しく見てみましょう。SNSトピックで発行されたメッセージは、Lambdaサービスの内部キューが受信します。そしてユーザーの代わりに管理されたポーラーがこのキューをポーリングし、メッセージを見て各関数を呼び出します。

続いてSQSからLambdaを呼び出す場合を見てみます。先ほどの図と比べると大きく似ていることに気付くでしょうか。異なる点は2点で、キューがLambdaサービスの内部キューではなくユーザーが作成したキューであること。もうひとつはバッチ処理のサポートです。またポーラーは並列処理され、キューにメッセージが残っている間スケールアウトが行われます。

3メッセージを受信した関数が成功した場合、Lambdaサービスがユーザーの代わりにDelete message batch APIを呼び出し、3つのメッセージがすべて削除されます。処理が完了した関数は次のメッセージに備えて待機しますが、一定時間アイドル状態になると回収されます。一方で1メッセージを受信した関数が失敗したとするとどうでしょうか。処理は完了していないためメッセージは削除されません。代わりに可視性タイムアウトによってメッセージが一定時間 見えなく なります。タイムアウトが切れるとメッセージは元の状態に戻り、再びポーラーによってピックされます。ただし受信カウントは2となります。

Lambdaの同時実行およびスケーリング

イベントソースとしてSNSとSQSを比較すると、Lambdaの同時実行の違いが分かります。SNSは1イベントに対して関数を呼び出します。これは入ってくるイベントに合わせてスケールアウトすることを意味します。一方のSQSでは、イベントはキューに含まれ、バッファリングやレート制限が可能です。最大で10個までのメッセージをまとめて処理できるバッチ処理もできます。

なぜバッファリングやレート制限が重要なのでしょうか。たとえば大量のメッセージを送信するとします。そして後続の処理にRDBのようなシステムがあるとします。多くの関数が実行され300個以上の独立したコネクションが実行され、圧迫することになるでしょう。その結果システムは不安定になり最悪ダウンすることもあります。SQSとLambdaを組み合わせることで関数の同時実行を制限できるという強力なメリットがあります。

Lambdaには予約同時実行と呼ばれる機能があり、これを利用することで関数ごとの同時実行数を制御できます。同時実行の上限を設定することで、キューを消費する速度を制御できます。図は同時実行の上限を5にした場合です。キューのメッセージ数増加に伴い、スロットルと並列処理も増加していますが、並列処理が5を超えることはありません。メッセージが減ってくるとスロットルと並列実行も減少しています。これでRDBをオーバーランさせないようにできます。

スケーリングの観点から最終的に考慮しなければいけないのはLambdaのポーラーです。ユーザーの代わりにポーリングを行い、最初は5並列の実行から始まります。これらが実行されていてメッセージがキューに残っている場合、追加で60回の呼び出しをします。これを毎分繰り返し、キューが空になるか最大で1000になるまでスケーリングします。バッチサイズを10とすると、最大で10000メッセージを並列に処理できることを意味します。

多くのアプリケーションにとってもうひとつの重要な考慮事項は順序の概念です。つまり先入れ先出しの順序付けされた厳密なFIFO処理です。SQSの標準キューは、順序はベストエフォートであり保証されません。またFIFOキューはExactly onceである点も異なります。FIFOキューを採用する主なユースケースとしては、注文処理やチケット予約システム、金融取引などが挙げられるでしょう。両者はトレードオフがあるため、これらの機能とスループットおよびコストを検討して適切なキューを採用してください。

具体的にFIFOキューがどのように機能するのか見ていきます。これまで同様、SQSとLambdaを採用したアーキテクチャがあります。ポーラーがメッセージを受信し関数を呼び出します。しかし、すべてのメッセージの順序を厳密に守ろうとすると、メッセージDは最初のメッセージが終了するまで処理できません。ところが、ほとんどのユーザーにとって、本当にすべてのメッセージが厳密に順序付けされている必要はありません。ある処理のグループで順序が保証されてほしいのであり、各グループは独立して処理できるはずです。そのためにFIFOキューはメッセージグループという概念を持っています。

この例ではひとつのキューしか存在していませんが、3つのA、B、Cという仮想的なメッセージグループがあります。これらはメッセージグループIDという属性によって識別されます。ポーラーは各グループについて順序を保って受信しますが、実際にはA1、B1、B2というような、異なるグループのメッセージを受信することがあります。引き続き、ポーラーはメッセージを受信しようとしますが、AグループとBグループは処理中です。そのため、空いているCグループからメッセージを受信し別の関数を並列に実行します。これ以上は並列実行できませんが、A1、B1、B2の処理が正常に終了すればキューから削除され、再度ポーラーは各グループからメッセージを受信します。

順序だけでなくメッセージ配送についても考えてみます。FIFOキューはExactly onceのしくみを提供しますが、それ自体を保証するものではありません。コンシューマーがメッセージを受信したにもかかわらず確認応答を送信しなかった場合などを考慮する必要があります。このような分散システムでは、重複したメッセージを受信しても処理しないように、複数回処理に対応する必要があります。

FIFOキューから関数が受け取るメッセージは図のような内容です。最大10個の複数のメッセージを配列で受け取ります。この場合は2つのメッセージがあり、それぞれがオブジェクトになっています。注目すべきはattributesで、メッセージグループID、シーケンス番号、メッセージ重複排除IDを持っています。処理をする際にはまずメッセージグループIDでグルーピングし、シーケンス番号でソートしてから、それぞれを並列に処理します。全体をそのまま処理してしまうと順序が保証されません。またExactly onceの処理を実装するために、メッセージ重複排除IDが利用できます。処理が成功したメッセージについて、メッセージ重複排除IDをデータストアに記録しておきます。新しいメッセージを受信した際に、処理を行う前にIDをチェックし、存在すれば単に成功ステータスを返すことで重複処理を避けられます。

先日、SNSがFIFOトピックをサポートするようになりました。厳密に順序付けされたファンアウトを実現できます。SQS FIFOキューと同じスループットを提供します。

これらをまとめた FIFOアプリケーションのリファレンスアーキテクチャは次のようになります。まずAPI Gatewayにメッセージを送信し、Lambda関数で注文承認し、注文IDを生成してFIFOトピックに送信します。FIFOトピックはFIFOキューにファンアウトし、在庫管理と支払い処理を並行して行います。最終的には関数がDynamoDBに結果を書き込みます。

エラー処理とベストプラクティス

注意するべき潜在的なエラーとそれに対するベストプラクティスを確認していきます。開発者が意識するべきエラーには2つのタイプがあります。

  • ポイズンメッセージ/関数エラー
    • 関数内でエラーが発生し例外を投げたりする予期せぬイベント
  • スロットリング/システムエラー
    • 関数のスロットリングのような予期されるべきイベント

まずは正常に処理できないポイズンメッセージのパターンを見てみましょう。Lambdaはキューから複数のメッセージをバッチで取り出します。ここでは4つのメッセージを受信しますが、メッセージBは正常に処理できない不正なメッセージだとします。関数はメッセージDとメッセージCを正常に処理できますが、メッセージBで例外を投げます。その結果この関数の処理は失敗となり、可視性タイムアウトが過ぎるまでリトライもしませんし、Delete message APIも呼びません。タイムアウトが明けてもメッセージBが成功することはありません。またメッセージDとメッセージCは再度受信され、処理されることになります。メッセージの期限が来るまでこのサイクルが続くことになります。

これに対するベストプラクティスは、常にデッドレターキューを設定することです。メッセージごとの最大の試行回数を設定でき、最大値に達したらメッセージをデッドレターキューに移動します。この設定をすることで失敗したメッセージをデッドレターキューに移せますが、4メッセージのうち正常に処理できる3メッセージもまとめて無視されていることに注意が必要です。

先ほどと同じシナリオで、例外を投げる前に、処理したメッセージを自分で削除するとどうなるか見てみましょう。4メッセージを取得し関数で処理をします。メッセージD、C、Aは正常に処理できますが、メッセージBはエラーが発生し例外をキャッチしたとします。関数全体として例外を返す前に、正常に処理できたメッセージをバッチ削除します。その後でキャッチしていた例外を返します。その結果、正常に処理できたメッセージは削除され、失敗したBのみが可視性タイムアウトで再実行待ちになります。タイムアウトが経過するとメッセージBは再実行されますが、何度か再実行されたのちデッドレターキューに移動されます。

次にシステムレベルのLambdaのスロットリングが発生した場合を見てみます。Lambdaのポーラーはメッセージを受信すると関数をスピンアップしようとしますが、Lambdaはスロットルイベントを返してきます。ポーラーはスケーリングを停止し、ローカルにバッファリングされたメッセージの処理を再試行します。可視性タイムアウトになるまで再試行を繰り返しますが、成功すればLambda実行環境に送信されますし、失敗した場合はキューに戻されます。結論として、スロットリングを恐る必要はありません。

続いてSNSとLambdaのエラーハンドリングを見ていきます。最初のシナリオはポイズンメッセージに起因する関数のエラーです。トピックにメッセージを送信すると、そのメッセージはLambdaの内部キューに送られます。ポーラーは関数を実行しますが、例外を投げたとします。この場合、先ほどとは異なりポーラーが直接再試行はしません。代わりに可視性タイムアウトとともに内部キューに戻ります。タイムアウト経過後、再びポーラーがメッセージを受信し関数を実行します。これは最初の実行を含め最大3回実行します(2回の再試行)。再試行の上限となると、メッセージは破棄されるか、デッドレターキューが設定されていればそこに書き込まれます。これによってメッセージが失われることはありません。

次にLambdaのスロットリングが発生した場合です。メッセージはSNSからLambdaの内部キュー、そしてポーラーが受信し関数を実行しようとするわけですが、ここでスロットリングが発生します。この場合、Lambdaは内部キューに戻し、最大6時間に渡って関数を再実行しようとします。最終的に諦める場合は単に捨てるか、デッドレターキューが設定されていればそこに移動します。そのため、SNSや非同期イベントがソースの場合は常にデッドレターキューを設定することを強くお勧めします。

それではこれまでの要約としてベストプラクティスおよびTipsをまとめます。

  • キューの設定
    • 可視性タイムアウトは非常に重要な設定で、常に関数のタイムアウトより大きい値を設定する
    • 低並列、長時間の関数の場合、可視性タイムアウトを関数タイムアウトの6倍に設定する
      • 受信カウントが不要に増加し、デッドレターキューに送られることを避けられる
  • 関数の設定
    • コスト削減とパフォーマンス向上のために、常にバッチ処理を使用する
      • ただし、呼び出しのたびに最大バッチサイズとなる保証はない
    • バッチ処理を使用する場合は例外の動作を理解し、すべて処理してから一度にすべてのエラーを返すことが重要
    • エラーを返す前に、正常に終了したメッセージは関数内で削除する
    • 失敗したメッセージの処理を高速化させたい場合、コード内で可視性タイムアウトを低い値に設定する
    • もっとも重要なことは、関数のエラーメトリクスを理解し、監視すること
      • エラーに遭遇すると並行性が低下する

  • SQS FIFO Lambda処理
    • メッセージグループごとにシーケンス番号でソートしてから処理する
    • 成功した処理はチェックポイントとしてメッセージ重複排除IDをデータストアに記録する
    • メッセージ受信後、再処理を避けるための実装をする
  • SNS Lambda処理
    • 関数にデッドレターキューまたはLambda Destinationsを必ず設定する
    • サブスクリプションフィルターを設定し無駄なコストがかからないようにする
    • SNSはバッチ処理がないため、1メッセージが1関数呼び出しとなることを理解する
      • 並行性を監視する

最適なメッセージングサービスの選択

どのアーキテクチャを採用すればよいのか、シンプルな意思決定ツリーを確認しましょう。まず最初の質問はファンアウトが必要かです。必要であればSNSを採用する必要がありますが、そうでない場合は必要ありません。左のパスで続く質問はレートリミットが必要かです。必要であれば、SNS+SQSがすばらしい組み合わせとなるでしょう。ファンアウトが必要でかつレートリミット不要なら、SNS単体でLambdaをサブスクライバーとして使用します。続いて右のパスも同様にレートリミットが必要かを確認します。必要ならSQSの出番です。一方でレートリミット不要なら一番簡単で、ユースケースに合わせて、同期または非同期で直接Lambda関数を呼び出します。

まとめ

LambdaとSNS、SQSを組み合わせるパターンについて、具体的なユースケースも含めて理解できました。特にSQSの前にSNSを置くパターン、逆にSNSとLambdaの間にSQSを置くパターンが整理ついてよかったです。またFIFOの順序制御などは非常に強力な機能ですが、トレードオフとしてスループットは下がります。サーバーレスアーキテクチャを検討する際には、選択肢がいくつもありますので、それぞれを比較検討しベストな選択をしていきましょう。

AWS re:Invent 2020は現在絶賛開催中です!

参加がまだの方は、この機会にぜひこちらのリンクからレジストレーションして豊富なコンテンツを楽しみましょう!

AWS re:Invent | Amazon Web Services

またクラスメソッドではポータルサイトで最新情報を発信中です!

AWS re:Invent 2020 JAPAN PORTAL | クラスメソッド