Amazon Web Services ブログ

サーバーレスマイクロサービスを構築するための設計アプローチの比較

AWS Lambda でワークロードを設計すると、コードレベルでもインフラレベルでも表現できるモジュール性のために、開発者に疑問が生じます。また、コードを実行するためにサーバーレスを使用するには、基盤となる機能コンポーネントからビジネスロジックを抽出するためのさらなる検討が必要です。この意図的な関心の分離により、堅牢なモジュール性が保証され、進化的なアーキテクチャへの道が開かれます。

この投稿は同期ワークロードに焦点を当てていますが、他のワークロードのタイプでも同様の考慮が当てはまります。API の境界を特定し、コンシューマと API について擦り合わせた後、その境界と関連するアーキテクチャを構成します。

Lambda 関数を使用して API を構成する最も一般的な 2 つの方法は、単一責任 と Lambda-lith (Monolith な Lambda という意味の造語) です。しかし、このブログでは、これらのアプローチに代わる、両方の長所を提供できる方法を探ります。

単一責任の Lambda 関数

単一責任の Lambda 関数は、サーバーレスアーキテクチャ内で特定のタスクを実行したり、イベントによってトリガーされた特定の操作を処理したりするように設計されています:

このアプローチにより、ビジネスロジックと機能の関心が強力に分離されます。特定の機能を分離してテストし、Lambda 関数を個別にデプロイし、バグが発生する可能性を減らし、Amazon CloudWatch でのデバッグが容易になります。

さらに、単一目的の関数は、Lambda が需要に応じて自動的にスケールするため、効率的なリソース割り当てが可能になり、リソースの消費を最適化し、コストを最小限に抑えることができます。つまり、メモリサイズやアーキテクチャなど、関数ごとに利用可能な設定を変更できます。さらに、すべてのリクエストを処理する単一の Lambda 関数にトラフィックを集約するのではなく、単一のタスクのトラフィックに基づいて上限緩和を要求できるため、サポートチケットを介して関数の同時実行数の上限緩和を要求することが容易になります。

もう 1 つの利点は、実行時間の速さです。単一のタスクのために設計された単一目的の Lambda 関数のビジネスロジックでは、他のアプローチで必要な追加ライブラリを必要とせず、関数のサイズをより簡単に最適化できます。これにより、バンドルサイズが小さくなり、コールドスタートの時間を短縮できます。

このような利点がある一方で、単一目的の Lambda 関数だけに依存する場合、いくつかの問題が存在します。コールドスタートの時間は短縮されますが、特に散発的な、または呼び出し頻度が低い関数では、コールドスタートの回数が増える可能性があります。例えば、Amazon DynamoDB テーブルのユーザーを削除する関数は、ユーザーデータを読み込む関数ほど頻繁にトリガーされることはないでしょう。また、単一目的の Lambda 関数に大きく依存することは、関数の数が増えるにつれて、システムの複雑さを増大させる可能性があります。

関心をうまく分離すると、コードベースを保守し易くなりますが、コードの凝縮度が失われます。API の書き込み操作 (POST, PUT, DELETE) など、似たようなタスクを持つ関数では、複数の関数にまたがってコードや動作が重複する可能性があります。さらに、Lambda Layer やその他の依存関係管理の仕組みを介して共有される共通ライブラリを更新する場合、単一のファイルに対するアトミックな変更ではなく、すべての関数にわたって複数の変更が必要になります。これは、ランタイムバージョンの更新など、複数の関数にまたがる他の変更にも当てはまります。

Lambda-lith: 1 つの Lambda 関数を使う

多くのワークロードで単一目的の Lambda 関数を使用すると、開発者の AWS アカウント全体で Lambda 関数が増殖してしまいます。開発者が直面する主な課題の 1 つは、共通の依存関係や関数の設定を更新することです。この問題に対処するための明確なガバナンス戦略 (依存関係の更新を強制するための Dependabot や、プロビジョニング時に取得されるパラメータ化されたパラメータの使用など) が実装されていない限り、開発者は別の戦略を選ぶかもしれません。

その結果、多くの開発チームは逆の方向に進み、API に関連するすべてのコードを同じ Lambda 関数内に集約します。

このアプローチは、API を構成するすべての HTTP メソッド、場合によっては複数の API を同じ関数にまとめるため、しばしば Lambda-lith と呼ばれます。

これにより、アプリケーションのさまざまな部分にわたって、より高いコードの凝縮度とコロケーションを実現できます。この場合のモジュール性はコードレベルで表現され、単一責任、依存性の注入、ファサードというようなパターンがコードを構造化するために適用されます。開発チームが適用する規律とコードのベストプラクティスは、大規模なコードベースを維持するために極めて重要です。

Lambda 関数の数が減ることを考慮すると、単一責任のアプローチに比べ、設定の更新や複数の API にまたがる新規格の実装がより簡単に実現できます。

さらに、すべてのリクエストはすべての HTTP メソッドに対して同じ Lambda 関数を呼び出すので、リクエストに対応する実行環境が利用できる可能性が高くなるため、呼び出し頻度の高くないコードのレスポンスタイムが向上する可能性が高くなります。

考慮すべき点をもう一箇所挙げるとすると、関数のサイズです。これは、API のすべての依存関係とビジネスロジックを持つメソッドを同じ関数内に配置すると増加します。これは、ワークロードが急増する Lambda 関数のコールドスタートに影響するかもしれません。特に、コールドスタートによって影響を受けるような厳しい SLA を持つアプリケーションの場合、顧客はこのアプローチの利点が欠点を上回っているか評価する必要があります。開発者は、使用されている依存関係に注意を払い、tree-shaking、minification、デッドコード除去などのテクニックを実装することで、この問題を軽減することができます。

このような粗いアプローチでは、関数設定を個別に調整することはできません。しかし、より高いメモリサイズや、セキュリティチームが設計した仕様に合わないほど緩いセキュリティ権限で、すべてのコードが機能するような設定を探し出さないといけなくなります

読み取り関数と書き込み関数

今までの 2 つのアプローチにはトレードオフがありますが、それぞれの利点を併せ持つ第 3 の選択肢があります。

多くの場合、API のトラフィックは読み込みと書き込みのどちらかに偏っているため、開発者はコードや構成をどちらか一方に最適化せざるを得ません。

例えば、利用者がユーザーを作成、更新、削除できるだけでなく、ユーザーやユーザーのリストを検索することもできるユーザー API を構築することを考えてみましょう。このシナリオでは、1 度に 1 人のユーザーを変更でき、一括操作は利用できませんが、API リクエストごとに 複数のユーザーを取得できます。API の設計を読み取り操作と書き込み操作に分けると、このようなアーキテクチャになります:

書き込み操作 (create, update, delete) をコードでまとめることは、多くの理由で有益です。たとえば、リクエストボディを検証し、必須パラメータがすべて含まれていることを確認する必要があるかもしれません。ワークロードが書き込みに集中している場合、あまり使用されない操作 (例えば、Delete 操作) は、ウォームスタートの恩恵を受けます。コードのコロケーションは、似たようなアクションのコードの再利用を可能にし、例えば共有ライブラリや Lambda Layer でプロジェクトを構成する際の認知負荷を軽減します。

読み取り操作の側面を見ると、この関数にバンドルされるコードを減らし、コールドスタートを高速化し、書き込み操作に比べてパフォーマンスを大幅に最適化することができます。また、Lambda 関数の実行時間を改善するために、実行環境のメモリ内にクエリ結果の一部または全部を保存することもできます。

このアプローチは、進化的なアーキテクチャではさらに効果を発揮します。プラットフォームがもっと普及した場合を想像してみてください。ElastiCache と Redis を使ったキャッシュアサイドパターンを追加することで、読み取り性能を改善し、API をさらに最適化しなければなりません。さらに、キャッシュミスの場合に読み取り機能を最適化した 2 つ目のデータベースを使用して、読み取りクエリを最適化することにしました。

書き込み側では、API のコンシューマがユーザー作成指示や削除指示の受付通知だけを受け取ることで、分散システムにおける結果整合性の恩恵を得れるかもしれません。

そこで、Lambda 関数の前に SQS キューを追加することで、書き込み操作のレスポンスタイムを改善できます。書き込みデータベースをバッチで更新することで、すべてのリクエストを個別に処理する代わりに、書き込み操作の処理に必要な呼び出し回数を減らすことができます。

コマンドクエリ責任分離 (CQRS) パターンは、データ変更、つまりシステムのコマンド部分をクエリ部分から分離する、よく知られたパターンです。スループット、遅延、または一貫性に関する要件が異なる場合は、CQRS パターンを使用して更新とクエリを分離できます。

完全な CQRS パターンから始めることは必須ではありませんが、API を大規模にリファクタリングすることなく、最初の読み書きの実装で強調されたインフラをより簡単に発展させることができます。

3 つのアプローチの比較

ここで 3 つのアプローチを比較してみましょう:

 

単一責任 Lambda-lith 読み込みと書き込みの分離
メリット
  • 強い関心の分離
  • きめ細かな設定
  • デバッグのしやすさ
  • 実行時間の速さ
  • コールドスタートの回数が減る
  • コードの凝縮度の向上
  • メンテナンスの簡素化
  • 必要に応じたコードの凝縮度
  • 進化的なアーキテクチャ
  • 読み書き操作の最適化
課題
  • コードの重複
  • メンテナンスが複雑
  • コールドスタートの回数が多い
  • 設定が粗い
  • コールドスタートの時間が長い
  • 2 つのデータモデルで CQRS パターンを利用する
  • CQRS パターンにより、システムが結果整合性を持つようになる

まとめ

アーキテクチャが進化するにつれて、単一責任の Lambda 関数から Lambda-lith に移行することがよくありますが、どちらのアプローチにも相対的なトレードオフがあります。このブログでは、読み取りと書き込みの操作ごとにワークロードを分離することで、両方のアプローチの長所を活かす方法を紹介しました。

この 3 つのアプローチはいずれもサーバーレス API を設計する上で有効であり、何のために最適化するのかを理解することが、最適な決断を下すための鍵となります。アプリケーションで表現すべきコンテキストとビジネス要件を理解することが、特定のワークロード内で考慮すべきトレードオフにつながることを忘れないでください。広い視野を持ち、問題を解決し、セキュリティ、開発体験、コスト、保守性のバランスをとるソリューションを見つけましょう。

その他のサーバーレス学習リソースについては、Serverless Land をご覧ください。

この記事は、テクニカルインストラクターの青木克臣が翻訳しました。
原文はこちらです。