🐍

AWS Lambdaの構成を考える

に公開

はじめに

AWS Lambdaによるサーバーレス構成の開発を行う際、アーキテクチャ構成としてLambdalith(モノリスなLambda)や単一責任の比較を考慮することがある。そこで、今回は比較の観点からもう少し深く掘り下げてみる。

対象となる読者は、Lambdaなどのサーバーレスサービスを導入予定、または利用し始めたばかりのアーキテクト、アーキテクチャ構成を検討しているエンジニアを想定している。
またLambdaのランタイム(プログラミング言語)については、PythonやNode.jsなどのzipファイルでデプロイ可能なものを前提としているが、コンテナでのデプロイについても触れる。

Lambdalith(モノリスなLambda)と単一責任とは

Lambdalith(モノリスなLambda)とは、1つのLambda関数に複数の機能を持たせるアーキテクチャ構成である。
これに対して、単一責任は1つのLambda関数に1つの機能(APIエンドポイント、HTTPメソッドごと)を持たせるアーキテクチャ構成である。
また、その中間的な存在として、読み込みと書き込みを分離するアーキテクチャ構成もある。この手法は、コマンドクエリ分離(CQRS)を利用しており、それぞれメモリなどインフラストラクチャの最適化をできる。

データを受け渡すだけのLambdaを作らない

実際にアーキテクチャ構成の詳細に話題を移す前に、あまり広まっていないベストプラクティスを記述しよう。
AWSでサーバーレスアーキテクチャを構成する際に、API Gataway、Lambda、DynamoDBでサービスを構成することを提唱されている。しかしLambdaは利用しない方が良い。

なぜLambdaを利用しない方が良いと考えるようになったのかというと、AWSサービスによるモニタリングのシステム構成についての記事で読んだ内容がきっかけである。
Lambdaを利用する場合、多くの場合はCloudWatch Logsにログを出力するだろう。そこでエラーが発生した場合、SNSから運用・保守用のメールアドレスやSlackに通知するシステムを構築するだろう。
このシステム構築方法について、データを受け渡すだけのLambdaを作らずに、AWSサービスのみで構成することを提唱している。
これはシステム監視に限らず、メインのシステムにおいても同様のはずである。

ではLambdaを利用しない場合、どのようにシステムを構成するのかというと、API Gatewayから直接DynamoDBにアクセスすれば良い。
以下はuser_idを指定してユーザー情報を取得するAPIをswagger形式で記載している。

...
paths:
  /user:
    get:
      summary: ユーザー情報を取得する
      parameters:
        - name: user_id
          in: query
          required: true
          schema:
            type: string
      responses:
        200:
          description: 成功
          content:
            application/json:
              schema:
                type: object
          headers:
            Access-Control-Allow-Origin:
              schema:
                type: string
            Access-Control-Allow-Methods:
              schema:
                type: string
            Access-Control-Allow-Headers:
              schema:
                type: string
      x-amazon-apigateway-integration:
        uri:
          Fn::Sub: arn:aws:apigateway:${AWS::Region}:dynamodb:action/GetItem
        passthroughBehavior: when_no_templates
        httpMethod: POST
        type: aws
        credentials:
          Fn::Sub: arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-dynamodb-execution-role
        requestTemplates:
          application/json: |
            {
              "TableName": "users_$stageVariables.Environment",
              "Key": {
                "user_id": {
                  "S": "$input.params('user_id')"
                }
              }
            }
        responses:
          200:
            statusCode: 200
            responseTemplates:
              application/json: |
                #set($inputRoot = $input.path('$'))
                {
                  "user": {
                    "user_id": "$inputRoot.Item.user_id.S",
                    "mailaddress": "$inputRoot.Item.mailaddress.S",
                    "created_at": "$inputRoot.Item.created_at.S",
                    "updated_at": "$inputRoot.Item.updated_at.S"
                  }
                }
...

デプロイ単位

隠れたベストプラクティスを見てきたので、次は具体的なアーキテクチャ構成を見ていこう。

Lambdaをデプロイする場合、AWS SAM、CloudFormation、Serverless FrameworkなどのIaC(Infrastructure as Code)を利用することがほとんどだろう。
その際に全ての関数を1つのプロジェクトにまとめてデプロイするのか、関数ごと/マイクロサービスごとににプロジェクトを分けてデプロイするのかを考える必要がある。

すなわち、大まかに

  • 1つのプロジェクト×Lambdalith
  • 1つのプロジェクト×単一責任
  • マイクロサービスごと×Lambdalith
  • マイクロサービスごと×単一責任

の選択肢があるということだ。

1つのプロジェクト

1つのプロジェクトについて、AWS SAMを想定して例を記載する。

template.yaml

...
Resources:
  GetUserFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: get_user.lambda_handler
      Runtime: python3.13
      CodeUri: ./src
    ...
  PostUserFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: post_user.lambda_handler
      Runtime: python3.13
      CodeUri: ./src
...

swagger.yaml

...
paths:
  /user:
    post:
      summary: ユーザ情報を登録する
      requestBody:
        $ref: '#/components/requestBodies/postUser'
      responses:
        200:
          $ref: '#/components/responses/success'
      x-amazon-apigateway-integration:
        uri:
          # PostUserFunction関数を呼び出す
          Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PostUserFunction.Arn}/invocations
        passthroughBehavior: when_no_templates
        httpMethod: POST
        type: aws_proxy
        credentials:
          Fn::Sub: arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-lambda-execution-role
...

上記の例は、POST /userを呼び出すと、PostUserFunction関数が呼び出され、GET /userを呼び出すと、GetUserFunction関数が呼び出される単一責任の形である。

マイクロサービスごと

マイクロサービスごとにプロジェクトを分ける場合は、マイクロサービスごとにsam initを実行してプロジェクトを作成し、template.yamlをそれぞれ配置することになる。
プロジェクトごとにマイクロサービスでコードが凝縮され、デプロイ時間が長引かなく、新規機能追加やバグ修正の際に、他のマイクロサービスに影響を与えないようにすることができる。

コンテナデプロイ

AWS SAMでは、パッケージタイプとしてImageZipがある。PythonやNode.jsなどのランタイムであれば、Zipを利用することが多いだろう。

例えば、少し前に形態素解析関数を作成することになったのだが、mecabなどのコンテナへのインストールを行い、AWS ECRにプッシュして、Lambda関数をデプロイすることにした。

また、Rustなどの言語で開発する場合、ZipではなくImageを利用することになるだろう。

APIフレームワークの利用

Lambdalithで開発を行う場合、どの記事を見てもAPIフレームワークを利用することが前提となっている。
その場合、リクエストのチェックをLambda関数内のAPIフレームワークの機能で実施することが多いだろう。
もしもオンプレミスやEC2などのサーバー上からサーバーレスへ移行するのであれば、APIフレームワークを利用することは良い選択肢だろう。
しかし、初めからサーバーレスで開発を行う場合、API Gatewayのリクエストバリデーション機能を積極的に利用することをお勧めする。その上でAPIフレームワークを利用せずに関数内でルーティングすることでLambdalithを実現することもできるだろう。

def lambda_handler(event, context):
    if event['httpMethod'] == 'POST':
        # POSTメソッドの処理
        pass
    elif event['httpMethod'] == 'GET':
        # GETメソッドの処理
        pass

関数のトリガー

Lambda関数のトリガーは、API GatewayやS3、SNSなど様々なものがある。
トリガーの種類によっては、Lambda関数を分ける必要があるかもしれない。
真っ先に思いつくのは、API Gatewayのタイムアウト30秒に合わせてAPI Gatewayをトリガーとする関数のタイムアウトを30秒にし、他の最大15分の関数と分けることだろう。
Lambdalithを採用するとしても、関数を切り出すことを考慮する必要がある。

おわりに

Lambdalith(モノリスなLambda)と単一責任の比較を行う際に考慮すべき観点を記載してきた。
開発体制や設計などによって、これらを組み合わせて利用することもあるだろう。
この記事を通じて、考えをまとめる手助けになれば幸いである。

Discussion