Originally published on Medium on November 11, 2023.

このガイドでは、AWS LambdaとNestJSを使った分散システムでの通信について解説します。

前回のガイドでは、NestJSとAWS Lambdaの統合方法について説明し、Docker内でのデバッグとホットリロードの設定を行いました。今回のガイドでは、その基盤を基に、ラムダ間のシームレスな通信を実現するための次のステップに進みます。

本ガイドのアプローチは、C#マイクロサービスで Dapr を使用した過去のプロジェクトから着想を得ています。Daprはマイクロサービスの構築における課題を簡素化します。例えば、サービス間の通信を手動で設定する代わりに、Daprは簡単な方法を提供します。これにより、開発者はサービス間通信の技術的詳細に煩わされることなく、実際の機能の開発に集中できます。このLambda + NestJS通信戦略は、Daprのシンプルさと効果性に深く着想を得ています。

DaprとC#について詳しく知りたい方は、 別のリポジトリをご覧ください。

本ガイドでは同期リクエストに焦点を当てますが、非同期リクエスト(SQSを使用)については、別のガイドで後日取り上げる予定です。

デモアプリケーションはこちらのリポジトリで確認できます: https://github.com/VenyaBrodetskiy/Lambda-NestJS-Demo

デモの内容:

  • 前回のガイドに従ってローカルで動作するよう設定されたアプリケーション。
  • ラムダ間の通信を実装し、ローカル環境とデプロイ後の両方で動作させる方法(本ガイドの焦点)
  • ソリューションにキュー(SQS)を追加する方法

以降のセクションでは、ラムダ間の通信を作成する手順を分解して説明します:

  1. 簡素化されたLambda通信のためのサービス設計の理解
  2. Lambda通信サービスの構築
  3. リトライ機能を追加したLambda通信サービスの強化
  4. ローカル環境とクラウド環境をサポートするLambdaファクトリの実装

始めましょう!

Part 1. 簡素化されたLambda通信のためのサービス設計の理解

コンセプト: 目標は、複雑なロジックをカプセル化し、Daprのように使いやすくシンプルなサービスを作成することです。本質的に、このサービスはシステムの異なるコンポーネント間の通信を簡素化する橋渡しの役割を果たします。

実践例: NestJSコントローラーを使ったシンプルな例で、このサービスがどのように機能するかを示します。このコントローラーは外部リクエスト(例: フロントエンドアプリケーションやPostmanから)を処理し、別のラムダ関数と通信してデータベースからデータを取得します。

PlanControllerのコードは以下の通りです:

import { LambdaCommunicationService } from 'src/core/modules/communication';

@Controller('plan')
export class PlanController {
  private readonly logger = new Logger(PlanController.name);
  constructor(
    private readonly lambdaService: LambdaCommunicationService,
  ) {}

  @Get('/:id')
  public async getPlanById(@Param('id') id: string): Promise<PlanRes> {
    this.logger.log(`Inside ${this.getPlanById.name}, id: ${id}`);

    // service to call other lambda
    const result: PlanRes = await this.lambdaService.invoke<PlanRes>(
      Accessor.Plan,          // name of lambda to be called. `Accessor` is an enum representing different lambda functions
      `/planaccessor/${id}`,  // path of request
      HttpMethod.Get,         // the HTTP method for the request
    );
    return result;
  }

Enter fullscreen mode Exit fullscreen mode

lambdaService.invoke メソッドのシグネチャ:

LambdaCommunicationService.invoke<TResponse>(
  service: string, 
  path: string, 
  httpMethod?: HttpMethod, 
  payload?: object): Promise<TResponse>

Enter fullscreen mode Exit fullscreen mode

  • service: 呼び出すラムダ関数の名前を指定します。
  • path: リクエストのパスを指定します。
  • httpMethod: リクエストのHTTPメソッド。指定がない場合はGETがデフォルトです。
  • payload: ラムダに送信するデータを含むオプションのオブジェクト。

利点: このようなサービスを使用することで、他のラムダの呼び出しが簡単になります。必要なのは、関数名、リクエストのパス、メソッド、そして必要に応じてペイロードだけです。

この設計により、ラムダ間の相互作用のための複雑なコードを書いたり保守したりする必要が減り、開発時間を大幅に短縮できます。また、サービス間の通信方法を標準化することでエラーを最小限に抑え、一貫性と信頼性を確保します。さらに、このようなサービスはコードの可読性と保守性を向上させ、チームがコードベースを理解し、時間をかけて修正することを容易にします。

エラー処理に関する注意: try-catchブロックがないことに気づくかもしれません。これは、NestJSに組み込みの例外フィルターがあり、エラーを優雅に処理するためです。この機能の詳細は、 NestJSの例外フィルターに関するドキュメントで確認できます。また、 Lambda-NestJS Demoリポジトリ には、例外フィルターのカスタム実装が含まれていますが、これは本ガイドの範囲を超えるトピックです。

Part 2. Lambda通信サービスの構築

このパートでは、Part 1で説明したサービスを実装します:

@Injectable()
export class LambdaCommunicationService {
  private readonly logger = new Logger(LambdaCommunicationService.name);
  constructor(private lambdaFactory: LambdaFactory) {}

  public async invoke<TResponse>(
    service: string,
    path: string,
    httpMethod: HttpMethod = HttpMethod.Get,
    payload?: object,
  ): Promise<TResponse> {
    try {
      // get instance of lambda object
      const { lambda, functionName } = this.lambdaFactory.getLambda(service);

      // prepare payload
      const lambdaPayload: ICommunicationPayload = {
        httpMethod: httpMethod,
        path: path,
        body: payload ?? undefined,
        headers: {
          'Content-Type': 'application/json',
        },
      };

      const params: InvokeCommandInput = {
        FunctionName: functionName,
        Payload: JSON.stringify(lambdaPayload),
      };

      this.logger.debug(
        `Inside ${this.invoke.name}. Invoking function: ${params.FunctionName} with payload: ${params.Payload}`,
      );

      // call other lambda using aws-sdk
      const response: InvokeCommandOutput = await lambda.invoke(params);

      // handle the response
      const responsePayload = JSON.parse(Buffer.from(response.Payload).toString());
      if (RetriableStatusCodes.includes(responsePayload.statusCode)) {
        throw new CommunicationException(
          JSON.parse(responsePayload.body),
          responsePayload.statusCode,
        );
      }

      this.logger.debug(
        `Inside ${this.invoke.name}. Lambda invoke response payload: ${JSON.stringify(
          responsePayload,
          null,
          ' ',
        )}`,
      );

      // parse the response to expected type
      if (typeof responsePayload.body === 'string' && this.isJsonString(responsePayload.body)) {
        const result = JSON.parse(responsePayload.body) as TResponse;
        return result;
      }

      return responsePayload.body as TResponse;
    } catch (error: any) {
      if (error.code === 'ECONNREFUSED')
        throw new CommunicationException(
          `Failed to invoke lambda: ${service}`,
          HttpStatus.INTERNAL_SERVER_ERROR,
        );
      throw error;
    }
  }

  private isJsonString(str: string): boolean {
    try {
      JSON.parse(str);
      return true;
    } catch (e) {
      return false;
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

次に、上記のコードを分解して1つずつ説明します。

  1. 最初のステップでは、AWS SDKを使用してラムダオブジェクトをインスタンス化します。このオブジェクトは、他のラムダ関数を呼び出す役割を担います。また、呼び出す特定の関数名も取得します。このラムダオブジェクトの作成と管理は、LambdaFactoryによって効率的に処理されます。LambdaFactoryの動作を理解することは、実装の鍵であり、後のセクション(パート4)でより詳しく説明します:
const { lambda, functionName } = this.lambdaFactory.getLambda(service);

Enter fullscreen mode Exit fullscreen mode

2. 次のステップは、Lambdaペイロードと呼び出しパラメータを構築し、ラムダを呼び出すことです:

const lambdaPayload: ICommunicationPayload = {
  ...
};

const params: InvokeCommandInput = {
  ...
};

const response: InvokeCommandOutput = await lambda.invoke(params);

Enter fullscreen mode Exit fullscreen mode

3. レスポンスの処理

これは重要なステップです。responsePayload内の内部ステータスコードを理解し、呼び出しが成功したかどうかを判断する必要があります:

const responsePayload = JSON.parse(Buffer.from(response.Payload).toString());

if (RetriableStatusCodes.includes(responsePayload.statusCode)) {
  throw new CommunicationException(
    ...
  );
}

Enter fullscreen mode Exit fullscreen mode

4. レスポンスの解析

ラムダからのレスポンスは、開発者が定義したTResponse型のオブジェクトまたは文字列です。以下のように処理します:

if (typeof responsePayload.body === 'string' && this.isJsonString(responsePayload.body)) {
  const result = JSON.parse(responsePayload.body) as TResponse;
  return result;
}

return responsePayload.body as TResponse;

Enter fullscreen mode Exit fullscreen mode

このコード部分では、レスポンス内のbodyの型をチェックし、それに応じて解析します。TypeScriptを使用することで、開発者によって指定されたTResponse型にレスポンスをキャストすることができます。

Part 3. リトライ機能を追加したLambda通信サービスの強化

このパートでは、Lambda通信サービスにリトライ機能を追加します。失敗したリクエストをリトライする機能は、特に一時的なネットワークの問題や一時的なサービスの利用不能に対処する場合に重要な機能です。

注意。 ただし、HTTPメソッドの冪等性の特性により、慎重に選択することが不可欠です。GETとDELETEは、主にリトライの対象となります。なぜなら、繰り返しによって副作用につながるような状態の変化を引き起こさないからです。PUTとPATCHもリトライの対象と見なすことができます。なぜなら、それらは冪等になるように設計されており、繰り返しのリクエストが同じ状態になることを保証するからです。ただし、POSTリクエストには注意が必要です。通常、状態を変更したりリソースを作成したりするため、適切な冪等性の処理なしにリトライすると、意図しない結果につながる可能性があります。

リトライは、一時的な問題やサーバーエラーを示すエラーコードに適用する必要があります。このような場合、リピートリクエストが成功する可能性があります。一般的に、5xxシリーズのエラー(例: 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout)は、サーバー側の一時的な問題を示唆するため、リトライの適切な候補となりますが、501 Not Implementedでリトライすることは推奨されません。これは、このエラーがサーバーの永続的な制限を示しており、その後のリトライが成功する可能性が低いためです。4xxシリーズのエラー(例: 400 Bad Request, 401 Unauthorized, 404 Not Found)については、通常、クライアント側の問題を示しており、リクエストを変更せずにリトライしても解決する可能性は低いです。ただし、408 Request Timeout, 423 Locked, 429 Too Many Requestsのような一時的なエラーは例外であり、バックオフ戦略でリトライすれば成功する可能性があります。

この機能を統合するために、async-retry npmパッケージを使用します。これは、リトライロジックを簡単に実装する方法を提供します。Lambda通信サービスのinvokeメソッドは以下のように強化されます:

  1. リトライパラメータの追加: invokeメソッドには、デフォルト値が3のretriesという追加のパラメータが含まれるようになりました。このパラメータは、リクエストのリトライの最大回数を決定します。
public async invoke<TResponse>(
    service: string,
    path: string,
    httpMethod: HttpMethod = HttpMethod.Get,
    payload?: object,
    retries: number = 3,  // new parameter
  ): Promise<TResponse> {

Enter fullscreen mode Exit fullscreen mode

2. 条件付きリトライロジック: 冪等性のあるリクエストに対してのみリトライを有効にするチェックを導入します。他のHTTPメソッドの場合、有効なリトライ回数はゼロに設定されます。

// enable retries only for Retriable Http Methods requests
let effectiveRetries;
if (RetriableHttpMethods.includes(httpMethod)) {
  effectiveRetries = retries;
} else {
  effectiveRetries = 0;
}

Enter fullscreen mode Exit fullscreen mode

3. リトライメカニズム: async-retryパッケージのretry関数を使用して、実際のラムダ呼び出しロジックをラップします。この関数は、指定されたリトライ条件に基づいて呼び出しを自動的にリトライします。

let responsePayload;
await retry(
  async () => {
    this.logger.debug(
      `Inside ${this.invoke.name}. Invoking function: ${params.FunctionName} with payload: ${params.Payload}`,
    );

    const response = await lambda.invoke(params);
    responsePayload = JSON.parse(Buffer.from(response.Payload).toString());

    if (RetriableStatusCodes.includes(responsePayload.statusCode)) {
      throw new CommunicationException(
        JSON.parse(responsePayload.body),
        responsePayload.statusCode,
      );
    }
  },
  // retry configuration
  {
    retries: effectiveRetries,
    onRetry: (error) => {
      error &&
        this.logger.warn(`Error while calling lambda: ${params.FunctionName} with payload: ${
          params.Payload
        }, retrying...
        Error: ${JSON.stringify(error, null, ' ')}`);
    },
  },
);

Enter fullscreen mode Exit fullscreen mode

リトライ中のエラー処理: 呼び出し試行中にエラーが発生した場合、エラーログが生成されます。これは、失敗したラムダ呼び出しに関連する問題の監視とデバッグに役立ちます。

このリトライメカニズムを実装することで、Lambda通信サービスがより堅牢になり、一時的な障害をより優雅に処理できるようになり、システム全体の信頼性が向上します。

Part 4. ローカル環境とクラウド環境をサポートするLambdaファクトリの実装

前のセクションでは、Lambda通信サービス内でLambdaFactoryを紹介しました。

const { lambda, functionName } = this.lambdaFactory.getLambda(service);

Enter fullscreen mode Exit fullscreen mode

次に、なぜそれが重要なのか、そしてローカルとクラウドの両方でスムーズに動作させるためにどのように実装するのかを探っていきましょう。

LambdaFactoryの役割は、AWS Lambdaインスタンスの作成と設定を抽象化することです。この抽象化は、アプリケーションがローカル開発やクラウドデプロイメントといった異なる環境に、中心となるビジネスロジックを変更することなく動的に適応できるようにするために重要です。

1. LambdaFactoryの実装の理解

LambdaFactoryサービスの主要なコンポーネントを検証しましょう。

lambda-factory.service.ts:

...
import { Configuration } from 'src/config';

interface ILambdaClient {
  lambda: Lambda;
  functionName: string;
}

@Injectable()
export class LambdaFactory {
  private lambdaInstance: Lambda;

  constructor(private config: Configuration) {}

  public getLambda(service: string): ILambdaClient {
    // get function name and endpoint from configuration
    const { name: functionName, endpoint: endpoint } = this.config.getService(service);

    // for cloud
    if (!this.config.IsOffline) {
      this.lambdaInstance = this.lambdaInstance ?? new Lambda({});

      return {
        lambda: this.lambdaInstance,
        functionName: functionName,
      };
    }

    // for local development
    this.lambdaInstance = new Lambda({
      endpoint: endpoint,
    });

    return {
      lambda: this.lambdaInstance,
      functionName: functionName,
    };
  }
}

Enter fullscreen mode Exit fullscreen mode

LambdaFactoryサービスのgetLambdaメソッドは、Configurationサービスによって決定されるローカル開発またはクラウドデプロイメントのいずれかで、Lambdaクライアントインスタンスを設定するための鍵となります。これは主に2つの理由から重要です:

  1. ローカル開発: ローカルテスト、特にServerlessフレームワークを使用する場合、各Lambda関数はしばしば一意のエンドポイントを必要とします。そのため、このメソッドは各呼び出しに対して新しいLambdaオブジェクトを作成し、正確なローカルシミュレーションを保証します。
  2. クラウドデプロイメント: クラウドでは、Lambda関数はエンドポイントではなく名前で識別されます。ここでは、このメソッドは各呼び出しで同じLambdaインスタンスを再利用することでパフォーマンスを最適化し、シングルトンのようなパターンに従います。

このアプローチにより、異なる環境間での柔軟性と一貫性が確保され、開発およびデプロイメントプロセスが簡素化されます。

2. Configurationサービスの理解

LambdaFactoryでは、ConfigurationサービスのgetServiceメソッドを使用して、定義された列挙型に基づいてLambdaCommunicationServiceの特定の設定を取得しました。

configuration.service.ts:

@Injectable()
export class Configuration {
  constructor(private configService: ConfigService) {
    this.validateConfig();
  }

  get IsOffline(): boolean {
    return Boolean(this.configService.get<boolean>('IS_OFFLINE'));
  }

  public getService(service: string): IAcccessorConfig {
    try {
      switch (service) {
        case Accessor.Plan:
          return {
            name: this.configService.getOrThrow<string>('PLANACCESSOR_NAME'),
            endpoint: this.configService.getOrThrow<string>('PLANACCESSOR_ENDPOINT'),
          };
        // case Accessor.OtherAccessor:
        // ...
        default:
          throw new Error(
            `Unknown accessor type. Configuration.service misses accessor: ${service}`,
          );
      }
    } catch (e) {
      throw new Error(e);
    }
  }
  private validateConfig(): void {
    // when running application, this function checks that developer didn't forget to add necessary configs to configuration.service (mostly for enums)

    // Validate accessor configurations
    for (const accessor of Object.values(Accessor)) {
      this.getService(accessor as Accessor);
    }

    // Validate queue configuration not missed
    for (const queue of Object.values(Queue)) {
      this.getQueue(queue as Queue);
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Configurationサービスを使用する利点を得るためには、いくつかのアクションが必要です:

  • 列挙型の定義: 開発者は、設定の取得を効率化するために、さまざまなサービス用の列挙型を作成する必要があります。
  • .envファイルの維持: 必要なすべての変数で.envファイルを設定および更新する必要があります。

エラー処理と検証: このサービスには、getServiceメソッド内のエラー処理と、すべての設定が正しく完全であることを保証するためのvalidateConfigメソッドが含まれています。

3. LambdaFactoryを使用する主な利点

要約すると、LambdaFactoryは環境固有の設定の管理を簡素化し、Lambda通信サービスの堅牢性を高める強力なパターンであり、ローカル環境とクラウド環境の両方に適応できるようにします。このアプローチの利点をまとめます:

  1. 環境に依存しない: LambdaFactoryはローカル設定とクラウド設定をシームレスに切り替えるため、開発者の生産性を高め、環境固有のバグを減らします。
  2. 設定の柔軟性: Lambda設定を一元化することで、中心となるロジックを変更することなく、サービス設定の簡単な更新とメンテナンスが可能になります。
  3. 可読性の向上: 関心の分離と環境固有の詳細の抽象化により、コードの可読性と保守性が向上します。

結論

AWS LambdaとNestJSでの通信の改善に関するこのガイドを終えるにあたり、ローカル開発とクラウドでの使用の両方でうまく機能する通信システムを作成する詳細について探求しました。

サービス設計の理解と堅牢なLambda通信サービスの構築から、環境適応のためのLambdaファクトリの実装まで、このガイドは分散システムにおけるLambda通信をマスターするための包括的な道筋を提供しました。

🔗 デモリポジトリを探索する で、議論された概念の実践的な実装を確認できます。

🔍 すべてがどこから始まったのかを知りたい場合は、私の記事の最初の部分を再訪してください: 「Local Development with AWS Lambda and NestJS: Docker, Debugging, and Hot Reload」

🤝 皆様からのフィードバックは非常に貴重です!コメント、質問、洞察や最適化の共有をお気軽にお願いします。すべての貢献が、私たちの集合的な知識を高め、役立つ開発者コミュニティを築くのに役立ちます。

Happy Coding、そして次のガイドでSQSを使った非同期リクエストを探求するのを楽しみにしています!🚀