AWS SAM でテンプレートを分割する場合の API Gateway と Lambda の定義方針を整理してみた

2023.04.16

いわさです。

AWS でサーバーレスアプリケーションを構築する際に、SAM を使った IaC を導入する場合があります。
最近では AWS Application Composer という SAM で記述されたテンプレートを GUI エディターを使った視覚的に構築することが出来るサービスも登場しました。

CloudFormation でリソース数が多い場合にテンプレートを分割する場合があります。
この考え方は SAM も同様で、規模が大きくなった場合にテンプレート分割戦略を採ることが出来ます。

私がよく見るパターンは次のようなマイクロサービスでのサービスごとにテンプレートを分割するパターンです。

Best practices for organizing larger serverless applications | AWS Compute Blog より引用

上記記事ではテンプレートを分割した場合に共通コンポーネントをどう管理するかについて言及されていますが、それ以外に API Gateway の REST API を使う場合にどこで API Gateway リソースを管理すべきかという課題が出てきます。

本日は API Gateway を含むサーバーレスアプリケーションのテンプレートを分割する場合の API Gateway リソース定義方法を整理したので紹介します。

1 つの関数、1 つのテンプレートのパターン

これが最も最小の API Gateway + Lambda の構成だと思います。
SAM の利用を開始し始めた時点では次のような実装が多いのではないでしょうか。

% tree .
.
├── hello_world
│   └── app.py
├── samconfig.toml
└── template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: ---
Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get

この場合はテンプレート上に関数(AWS::Serverless::Function)のみしか登場しませんが、デプロイしてみると API Gateway も生成されています。
これは上記ハイライト部分で、API Gateway の ID を指定していないため、デフォルトで REST API が自動作成され Lambda 統合も構成ます。

API Gateway がデフォルトの設定になるので、カスタムしたい場合はAWS::Serverless::Apiを定義して関数側でRestApiIdプロパティで参照します。

2 つの関数、1 つのテンプレートのパターン

続いて先程のテンプレートにもう 1 つ関数を追加した場合です。
次のように同一テンプレート内で関数定義を追加します。

% tree .
.
├── hello_world
│   └── app.py
├── hello_world2
│   └── app.py
├── samconfig.toml
└── template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: ---
Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: hoge0410func1
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get

  HelloWorldFunction2:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: hoge0410func2
      CodeUri: hello_world2/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello2
            Method: get

先程と同様に RestApiId は明示的に指定していないので同一テンプレート内であれば同じ REST API に統合される形となります。

それぞれの関数を別テンプレートで定義しつつイベントソースを指定してみる

先程の 2 つの関数を定義してあるテンプレートを分割してみます。
関数としての分割自体は簡単だと思いますが、API Gateway をどうしたら良いか悩みますね。
ここではまず先程と同様にイベントソースを使ってみます。

% tree .
.
├── hello_world
│   └── app.py
├── hello_world2
│   └── app.py
├── samconfig.toml
├── template1.yaml
└── template2.yaml

template1.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: ---
Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: hoge0410func1
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get

template2.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: ---
Resources:
  HelloWorldFunction2:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: hoge0410func2
      CodeUri: hello_world2/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello2
            Method: get

上記のような場合は、同じスタック名の場合は API Gateway の定義が上書きされ、別のスタックにした場合はそれぞれ別の API Gateway となります。

カスタムドメインを構成して複数の API Gateway を使う場合は利用出来そうですが、それでもカスタムドメインやマッピングなどの共通部分をどこで定義するという課題は出てきます。

別テンプレートで API Gateway を作成し、外部から Rest API ID 渡す

通常の CloudFormation の分割であれば、別のスタックで API Gateway リソースをデプロイして、そのリソース ID をそれぞれの関数テンプレートに引き渡す構成を採ると思います。
以下のような形で外部から API の ID を設定してみましょう。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: ---
Parameters:
   ApiId:
     Type: string
Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: hoge0410func1
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api
          Properties:
            RestApiId: !Ref ApiId
            Path: /hello
            Method: get

実は SAM ではこの使い方はサポートされていません。
AWS::Serverless::Functionのイベントソースの設定値であるRestApiIdの仕様として外部から ID を設定することが出来ません。
ビルド時に次のように同一テンプレート内での ID でなければならないというエラーが発生します。

% sam build -t template1.yaml
Starting Build use cache

Error: [InvalidResourceException('HelloWorldFunction', "Event with id [HelloWorld] is invalid. RestApiId must be a valid reference to an 'AWS::Serverless::Api' resource in same template.")] ('HelloWorldFunction', "Event with id [HelloWorld] is invalid. RestApiId must be a valid reference to an 'AWS::Serverless::Api' resource in same template.")
Traceback:
  File "/opt/homebrew/Cellar/aws-sam-cli/1.74.0/libexec/lib/python3.8/site-packages/click/core.py", line 1055, in main
    rv = self.invoke(ctx)
  File "/opt/homebrew/Cellar/aws-sam-cli/1.74.0/libexec/lib/python3.8/site-packages/click/core.py", line 1657, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/opt/homebrew/Cellar/aws-sam-cli/1.74.0/libexec/lib/python3.8/site-packages/click/core.py", line 1404, in invoke
    return ctx.invoke(self.callback, **ctx.params)
:

この制限事項はドキュメントにも次のように記述されています。

RestApiId

  • RestApi リソースの識別子です。これには、指定されたパスとメソッドを持つオペレーションが含まれている必要があります。通常、このテンプレートで定義される AWS::Serverless::Api リソースを参照するように設定されます。
  • このプロパティを定義しない場合は、AWS SAM が生成された OpenApi ドキュメントを使用してデフォルトの AWS::Serverless::Api リソースを作成します。そのリソースには、RestApiId を指定しない同じテンプレート内の Api イベントによって定義されるすべてのパスとメソッドの和集合が含まれます。
  • これは、別のテンプレートで定義された AWS::Serverless::Api リソースを参照できません。

別テンプレートで API Gateway を定義する場合は OpenAPI Specification(OAS) を使う

イベントソースのRestApiIdは利用出来ないということがわかりました。
別の API 定義方法として DefinitionBody に OAS を指定することが出来ます。

API Gateway では言語拡張されており OAS 上で Lambda 統合を定義することも出来ます。
ここでは事前に関数を含むテンプレートをデプロイしておき、その ARN を取得して API Gateway に引き渡します。

template1.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: ---
Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: hoge0410func1
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
Outputs:
  HelloWorldFunction:
    Value: !GetAtt HelloWorldFunction.Arn

template3.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: ---
Parameters:
  Function1Arn:
    Type: String
  Function2Arn:
    Type: String
Resources:
  HogeApi:
    Type: AWS::Serverless::Api
    Properties:
      Name: hoge0410api
      StageName: hoge0410stage
      DefinitionBody:
        openapi: '3.0'
        info: {}
        paths:
          /hello1:
            get:
              x-amazon-apigateway-integration:
                httpMethod: POST
                type: aws_proxy
                uri: !Sub arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${Function1Arn}/invocations
              responses: {}
          /hello2:
            get:
              x-amazon-apigateway-integration:
                httpMethod: POST
                type: aws_proxy
                uri: !Sub arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${Function2Arn}/invocations
              responses: {}

今回はデプロイ時にパラメータを直接指定しました。
デプロイしてみます。

% sam deploy -t template3.yaml --parameter-overrides 'Function1Arn=arn:aws:lambda:ap-northeast-1:123456789012:function:hoge0410func1 Function2Arn=arn:aws:lambda:ap-northeast-1:123456789012:function:hoge0410func2'

                Managed S3 bucket: aws-sam-cli-managed-default-samclisourcebucket-b2ke18r5t3j
                A different default S3 bucket can be set in samconfig.toml
                Or by specifying --s3-bucket explicitly.

        Deploying with following values
        ===============================
        Stack name                   : sam-app
        Region                       : ap-northeast-1
        Confirm changeset            : True
        Disable rollback             : False
        Deployment s3 bucket         : aws-sam-cli-managed-default-samclisourcebucket-b2ke18r5t3j
        Capabilities                 : ["CAPABILITY_IAM"]
        Parameter overrides          : {"Function1Arn": "arn:aws:lambda:ap-northeast-1:123456789012:function:hoge0410func1", "Function2Arn": "arn:aws:lambda:ap-northeast-1:123456789012:function:hoge0410func2"}
        Signing Profiles             : {}

Initiating deployment
=====================
File with same data already exists at b85950bf435450e6727ebc5557b783c7.template, skipping upload

:

Successfully created/updated stack - sam-app in ap-northeast-1

次のように単一の API Gateway で複数の関数を関連付けしつつ、テンプレートを分けることが出来ました。

ちなみに、API Gateway は OAS じゃなければ設定出来ない詳細パラメータも結構多いので、OAS で定義出来るものは寄せたほうが良いと私は思っています。
利用出来る拡張機能の一覧については次を参考にしてください。

API Gateway と Lambda を別テンプレートで管理する際のリソースベースポリシー

API Gateway と Lambda のテンプレートを分割した場合、リソースベースポリシーの自動設定がされないため次のように明示的にリソースベースポリシーの設定が必要になります。(API Gateway 側で Invoke 時の IAM ロールで許可することも可能)

:
  SamplePermission:
    Type: AWS::Lambda::Permission
    Properties:
      Principal: 'apigateway.amazonaws.com'
      Action: 'lambda:InvokeFunction'
      FunctionName: !Ref Function1Arn
      SourceArn: !Join
        - ''
        - - !Sub 'arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:'
          - !Ref HogeApi
          - '/*'
:

さいごに

本日は AWS SAM でテンプレートを分割する場合の API Gateway と Lambda の定義方針を整理してみました。

結論としては、API Gateway 管理用のテンプレートを用意し、API 定義(DefinitionBody)内で Lambda 統合を定義して、関数はそれぞれのテンプレートで管理しても OK、とするのが良いだろうということでまとめました。
API Gateway を本格的に使用すると OpenAPI 拡張機能を使ってカスタマイズするシーンがどうしても出てきがちなので、その点からも最初から OAS に寄せたほうが安全かなという温度感です。

また、サービスごとに API Gateway を定義してカスタムドメインでまとめる方法も考えられますが、API Gateway に紐づくオーソライザーなどのオブジェクト管理や使用量プランなど考慮しなければいけないことが出てきます。
そのあたりの課題が解決出来そうであればサービスごとに API Gateway を分割する方法も採用出来そうという温度感です。その場合でもカスタムドメインなどの共通部分はどこで管理するかというのは考える必要がありますが RestApiId の制限事項は回避出来そうですね。