[レポート] サーバーレスにおけるエラーハンドリング #reinvent #svs311

こんにちは。サービスグループの武田です。開催中のre:Invent 2020でHandling errors in a serverless worldのセッションを視聴しましたのでレポートします。
2021.01.20

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

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

re:Invent 2020のWave 2が開催していました。Handling errors in a serverless worldのセッションを視聴しましたのでレポートします。

セッション概要

  • スピーカー
    • Josh Kahn(AWS Speaker)
  • タイトル
    • Handling errors in a serverless world
    • SVS311

サーバーレス技術は、インフラストラクチャを管理することなく、拡張性と耐障害性に優れたアプリケーションを構築する新しい方法を導入しました。複数のマネージドサービスを使用してサーバーレスアプリケーションを構築する場合、どのようにエラーを処理すべきでしょうか?コードにtry/catchブロックを含めるべきか、それともサービスにエラー処理を任せるべきか。AWS Step Functionsのステートマシンでタスクとして関数が呼び出された場合はどうなりますか?AWS Lambda Destinationsは役に立ちますか?このセッションでは、Lambdaの呼び出しモデル全体でのエラー処理について調べ、適切な可視性とリトライ動作のパターンについて議論します。様々なユースケースでのエラーに対応するためのサービスのコーディングや設定方法を理解した上で、このセッションを終了することができます。

アジェンダ

このセッションでは、信頼性を向上させ、サーバーレスアプリケーションで遭遇する可能性のあるエラーを可視化する方法を見ていきます。開発者がどんなに頑張ってもバグは発生します。API Gatewayを使用しているときに、Lambdaなどでサーバーエラーが発生するとInternal Server Errorという無害なエラーメッセージを返します。開発者としては詳細な情報が何もないため、アーキテクチャに精通していない限りトラブルシューティングは難しいものとなるでしょう。CloudWatch Logsなどのログを掘り下げる必要がありますが、もしログに記録されていなかった場合、デバッグはさらに困難になります。

  • 話すこと
    • サーバーレスアーキテクチャへの信頼性の加え方
      • 組込みリトライの挙動の理解
      • リトライ動作で利用可能な設定
    • エラーに対する可視性の高め方
      • AWS Lambdaの各呼び出しタイプについて
      • 開発者への最小限の影響
  • 話さないこと
    • オブザーバビリティ(可観測性)
    • サーバーレスの基本的なこと

サーバーレスeコマースアーキテクチャ

簡略化したサーバーレスeコマースを使って、サーバーレスアーキテクチャで利用可能なさまざまな呼び出しタイプを見ていきます。それぞれのタイプについて、エラー処理のアプローチと構成オプションについて確認していきましょう。

最初の呼び出しタイプは同期型です。この例では、モバイルアプリケーションがAPI Gatewayにアクセスし、DynamoDBに書き込むLambda関数を呼び出しています。この場合、モバイルアプリケーションはこの操作に対するレスポンスを待っており、API Gatewayにはタイムアウトがあります。クライアントは同期的に応答を待っているため、Lambda関数はこの時間内に応答する必要があります。

2つ目の呼び出しタイプは非同期型です。この場合、EventBridgeは関数ではなくLambdaサービスが内部的に管理するキューにメッセージを書き込んでいます。メッセージを正常に書き込むと、EventBridgeは処理の完了を待たずに次の処理に進みます。

次はプルベースの呼び出しです。ここではプルベースの呼び出しを2つに分けて見ていきます。どちらの場合も、処理が必要なメッセージやレコードのために、関数ではなくLambdaサービスがイベントソースをプルしています。そして関数は単一のイベントではなく、レコードをバッチまたはレコードのグループで処理することになります。プルベースの呼び出しの1つ目はストリーミングソースで、一般的にはDynamoDBストリームやKinesisなどが該当します。

プルベースの呼び出しの2つ目はSQSです。この場合も、Lambdaサービスはキュー上のメッセージからプルしており、メッセージはバッチで配信されます。これら2つの異なるプルベースのイベントソースでは、エラー処理のアプローチや利用可能な設定オプションが異なります。

最後はStep Functionsで、ASL(Amazon State Language)を使用することで柔軟な設定が可能です。エラー処理やリトライ動作もASLで定義可能です。

同期呼び出し

同期呼び出しの場合、サービス自体にリトライ動作は組み込まれていません。失敗した場合、必要であればクライアント自身でリトライします。次にエラー処理における目標を定めます。第一に、構造化された(インサイト可能な)方法でエラーをログに記録し、アラームやメトリクスを生成することです。第二に、X-Rayのようなツールからトレースを取得できるような方法でエラーを処理し、復帰可能とすることです。第三に、呼び出し元に対してリトライすべきか判断できるような内部エラー情報をケースバイケースで返せるようにすることです。システムはクライアント起因とサーバー起因、2種類のエラーを区別する必要があります。クライアント起因のエラーとは、不正なペイロードが送られてきた場合や、データベースに存在しないレコードを取得しようとした場合などに発生します。サーバー起因のエラーとは、DynamoDBテーブルに書き込みができない場合やアクセス権がない場合などです。あるいはLambda関数などのスロットリングも考えられます。これらは必ずしもクライアントがリカバリ可能ではありません。システムはこの2種類のエラーについて区別して扱う必要があります。

ここでは開発者がLambda関数で書く必要のある実際のコードと、ミドルウェアを構築するためのアプローチについて見ていきます。ミドルウェアとは関数ハンドラをラップするカスタムコードのことで、関数ハンドラの前後に呼び出すことができます。これによってエラーの種類にかかわらず一貫性を保てます。クライアントエラーの場合、BookNotFoundErrorのような独自例外を投げます。この場合、HTTPエラーコードは400のようになります。一方でサーバーエラーの場合、AccessDeniedErrorのような例外が発生しますが、関数ではキャッチしません。この場合、HTTPエラーコードは500のようになります。ミドルウェアは発生したらエラーや例外をキャッチし、EMF(Embedded Metric Format)を使用してCloudWatchに記録します。EMFは構造化されたログを提供しかつ低レイテンシーです。ミドルウェアが独自例外をキャッチした場合、エラーメッセージを無害なものに変換し404などのエラーコードとともに返します。クライアントが回復できないサーバーエラーの場合、Lambdaの呼び出しを失敗させることになります。

Pythonで実装する方法を紹介します。最初にhandler関数があり、@error_handlerデコレータを定義しています。これはその下のerror_handler関数と連携しています。handlerは本の検索をし、結果が返ってこない場合はBookNotFoundErrorを発生させます。error_handlerはPowerToolsのロガーをインスタンス化し、handlerをtry/catchブロックの中で呼んでいます。もし例外が投げられればそれをキャッチしログに記録します。そしてエラーの内容を確認し、400番台のエラーであれば404のようなステータスコードおよびメッセージとともに正常に終了します。一方で、関数の失敗であれば再度例外を投げることで関数自体を失敗させるとともに、開発者がデバッグできるよう問題をログに記録します。

これはX-Rayを使った例です。API Gatewayから3つの関数が呼ばれていますが、サービスマップの一番上に表示されている関数は常に失敗しています。黄色い丸で囲ってあるのが失敗したことを示しています。他の2つの関数のうち、ひとつは約50%の確率でエラーを発生させますが緑色の丸で囲まれています。ところで、API Gatewayは約50%の割合で正常な200のステータスコードを返していることに気付くでしょうか。残りの25%はサーバー側のエラーで赤になります。Lambda関数から適切にステータスを返すことで、X-Rayのようなツールで有用なトレースを得られます。Node.jsファンのためにひとつ注意しておくと、非同期ハンドラで実装する場合は例外ではなくPromiseを使うようにしてください。

非同期呼び出し

次に非同期呼び出しです。例ではEventBridgeでメッセージを発行していましたが、SNSやS3などもイベントソースとなります。Lambda関数が非同期イベントの処理中に失敗した場合、Lambdaサービスは自動的に関数を2回リトライします。この動作はオプションで0回または1回に変更できます。またMaximumEventAgeの設定により、リトライ期間を60秒〜6時間の間で調整可能です。イベントソースがLambdaサービスへの配信に失敗した場合やスロットリングした場合、イベントソースはエクスポネンシャルバックオフを使ってリトライを続けます。不必要な関数呼び出しを避けるため、エラーおよびリトライの挙動を理解することは非常に重要です。

さて、非同期呼び出しでは同期呼び出しにはなかった機能があります。Lambda Destinationsと呼ばれる機能で、re:Invent 2019で発表されました。関数が成功/失敗した際、SNS、SQS、EventBridge、あるいは別のLambda関数に、追加コードなしに自動的にルーティングできます。その宛先にルーティングされるペイロードには、元のイベントや元のペイロードだけでなく、リトライ回数や関数名などの追加メタデータも含まれています。Lambda Destinationsの設定例としてAWS SAMのコードを載せておきます。

非同期呼び出しのエラー処理はどのような行えばよいでしょうか。一般的にはエラーを投げてミドルウェアに処理させるだけです。関数が他のサービスを呼び出している場合は、リトライロジックやサーキットブレーカーパターンの実装を検討するのもよいでしょう。そして関数が失敗した場合は、リトライの最大回数に達した後、再びエラーを投げます。そのイベントは元のイベントデータと追加のメタデータとともに、失敗時の宛先にルーティングされます。

実装は同期呼び出しとよく似ています。PowerToolsを使ってミドルウェアを構築していますが、CloudWatchにメッセージの詳細を記録するためにLambdaコンテキストを追加しています。また、AWSサービスのエラーと一般的なエラーを分けて処理しています。

イベントソース:ストリーミング

次にプルベースの呼び出しのうち、DynamoDBストリームやKinesisのようなイベントソースをストリーミングしている場合について見ていきましょう。これらの場合、ストリーム上にメッセージが存在する限り、失敗してもLambdaサービスは関数をリトライし続けます。しかし、ここでも重要なことは、これらのメッセージはバッチで配信されるということです。また、MaximumRecordAgeMaximumRetryAttemptsによって制御可能です。BisectBatchOnFunctionErrorは、関数がエラーを返した際に、バッチを二分割しリトライします。非常に強力な機能ですが、副作用なしに何度も実行できなければいけないことは理解しておく必要があります。

ストリーミングのエラー処理は、非同期呼び出しとよく似ています。つまり、エラーを投げ、ミドルウェアでキャッチしてログに記録しリトライします。Lambda Destinationsも利用できます。ここでの注意は2点あります、1つ目は、失敗時の宛先はSQSかSNSのみです。2つ目は、この宛先は元のペイロードを受け取りません。代わりに、ストリーム自体に関するメタデータを受け取ります。失敗時の調査はこのメタデータを利用します。

これはAWS SAMのコードです。イベントソースがストリーミングのときにLambda Destinationsを利用する場合は、関数ではなくストリーム自体に宛先を設定していることを確認してください。

イベントソース:Amazon SQS

プルベース呼び出しの2つ目はSQSです。SQSとストリーミングはバッチなど似ている側面もありますが、エラー処理や設定オプションが異なります。SQSでは、キューのメッセージの有効期限まで自動的にリトライされます。ストリーミングとの大きな違いは、メッセージが正常に処理されたとき、Lambda関数がそのメッセージをキューから削除可能なことです。キューから削除されたメッセージは、その後のバッチには含まれません。また、リトライの挙動はLambdaあるいはSQSのDLQ(Dead Letter Queue)を設定することで制御できます。最大10までのバッチサイズや可視性タイムアウトによる柔軟な制御も可能です。

この場合のエラー処理のアプローチは、これまでに説明した他のものよりも少し複雑です。お勧めなのは、Lambda関数の中にハンドラとは異なる、バッチの各レコードを処理する別のメソッドを用意することです。各レコードが正常に処理された場合、特に問題は起きません。ミドルウェアはエラーがなければ単にリターンし、LambdaサービスがSQSキューから各メッセージを削除します。もしエラーが発生した場合は、レコードを処理するメソッドでエラーをキャッチし、グループとして返します。その場合、ミドルウェアが各メッセージを削除します。失敗したメッセージをリトライするのかDLQに移動させるのかはビジネス要件として開発者が判断します。

PythonのPowerToolsを使用した簡単な例です。これはJava版のPowerToolsでも利用できますし、Node.js用のプラグインも用意されています。エラー処理やバッチに対する処理など、まったく同じアプローチの処理を開発者自身が多くのコードを書くことなく実装できます。

イベントソース:Step Functions

最後に、Step Functionsについても見ていきましょう。先述したように、Step Functionsはとても強力で、リトライ動作やエラー処理をステートマシン内で定義可能です。また、ステートマシンの定義に集中型のエラーハンドラも実装できます。これにより、ステートマシン内で一貫性を保つことができます。

簡単なステートマシンの例を見てみましょう。Call APIというタスクがあり、Lambda関数を呼び出しています。左側のJSONを見ると、リトライ動作とtry/catch型のロジックがステートマシンの定義の中に実装されていることがわかります。Lambda関数がTooManyRequestsExceptionを返した場合、ステートマシンは自動的にそのステップやタスクを、1秒間隔であと2回までリトライします。リトライ動作はステートマシン内で定義されているので、関数の開発者はそれについて何も知る必要はありません。最大試行回数を超えた後、あるいは関数内で別のエラーが発生した場合は、Catchブロックに進みます。ここでは、TooManyRequestsExceptionの場合には待機状態に移行し、その後リトライします。ServerUnavailableExceptionなど別のエラーが発生した場合は、ステートマシン内の別のステートに遷移します。

まとめ

どの呼び出しタイプでもロギングは重要でしたが、実際これが何の役に立つのかといえば、エラーの可視性を高めるためです。CloudWatchのEMFを使用することで、メトリクスの取得やダッシュボードあるいはアラームを構築できます。右側のスクリーンショットでは、CloudWatch Insightsを利用して特定の期間内のすべてのエラーメッセージを検索しています。開発者は何が問題だったのかを理解するのに役立ちます。Lambda関数から適切な値を返すことで、X-Rayのようなツールを使って分散システムをトレースし、内部動作の理解や障害の発見に役立ちます。CloudWatchのダッシュボードやアラームなどを構築し、サービスオーナーが閾値を超えたエラーを認識できるようにもできます。また、カオスエンジニアリングのようなテクニックを使用してエラーを生成し、エラー処理が想定どおりの挙動をするのかも確認できます。

最後に

サーバーレスアーキテクチャにおけるエラーハンドリングがケース別に紹介され、非常に参考になるセッションでした。フレームワークなどを利用したシステム開発では、フレームワークの挙動を理解し、ベストプラクティスにのっとった実装をします。一方で、サーバーレスではAWSなどクラウドプロバイダーが提供しているサービスを組み合わせて実装をします。フレームワークとサーバーレス、どちらも提供されている機能を理解しフィットする形で実装することが重要なことは変わりません。理解を深め、スケーラブルで高信頼なシステムを作っていきましょう。