CLOVER🍀

That was when it all began.

AWS SAMを使って、複数のAWS Lambda関数をLocalStackのAmazon API Gatewayのバックエンドにデプロイする(Makefileでのビルド付き)

これは、なにをしたくて書いたもの?

ここまで、何回かAWS SAMを使ってAWS Lambda関数をAmazon API Gatewayのバックエンドにデプロイすることを
試していましたが、すべて単一のAWS Lambda関数でした。

今回は、複数のAWS Lambda関数をデプロイしてみたいと思います。

AWS SAMで、複数のアプリケーションを扱う

ドキュメントを見ていてもそういうサンプルはなさそうでしたが、こちらのissueにヒントがありました。

Multiple Lambda functions from the sam.yml file possible? · Issue #5 · aws-samples/aws-serverless-samfarm · GitHub

リソースとしてAWS::Serverless::Functionを定義して、CodeUriをそれぞれ別の場所を指定すれば良い、という
話ですね。

もう一方で、AWS SAMアプリケーション自体をネストする考え方もあるようですが。

ネストされたアプリケーションの使用 - AWS Serverless Application Model

こちらは少し違う気がするので、前者のアプローチを自分でも試してみたいと思います。

環境

今回の環境は、こちら。LocalStack上で確認します。

$ localstack --version
0.13.2.1


$ python3 -V
Python 3.8.10


$ awslocal --version
aws-cli/2.4.7 Python/3.8.8 Linux/5.4.0-91-generic exe/x86_64.ubuntu.20 prompt/off


$ samlocal --version
SAM CLI, version 1.36.0

LocalStackの起動。

$ LAMBDA_EXECUTOR=docker-reuse localstack start

ローカルで利用するNode.js。

$ node --version
v14.18.2


$ npm --version
6.14.15

2つのAWS Lambda関数を持ったAWS SAMプロジェクトを作成する

まず、initでプロジェクトを作成します。

$ samlocal init

ランタイムはNode.js 14.x、アプリケーション名はsam-multiple-lambdaで作成。

Which template source would you like to use?
        1 - AWS Quick Start Templates
        2 - Custom Template Location
Choice: 1
What package type would you like to use?
        1 - Zip (artifact is a zip uploaded to S3)
        2 - Image (artifact is an image uploaded to an ECR image repository)
Package type: 1

Which runtime would you like to use?
        1 - nodejs14.x
        2 - python3.9
        3 - ruby2.7
        4 - go1.x
        5 - java11
        6 - dotnetcore3.1
        7 - nodejs12.x
        8 - nodejs10.x
        9 - python3.8
        10 - python3.7
        11 - python3.6
        12 - python2.7
        13 - ruby2.5
        14 - java8.al2
        15 - java8
        16 - dotnetcore2.1
Runtime: 1

Project name [sam-app]: sam-multiple-lambda

Cloning from https://github.com/aws/aws-sam-cli-app-templates

AWS quick start application templates:
        1 - Hello World Example
        2 - Step Functions Sample App (Stock Trader)
        3 - Quick Start: From Scratch
        4 - Quick Start: Scheduled Events
        5 - Quick Start: S3
        6 - Quick Start: SNS
        7 - Quick Start: SQS
        8 - Quick Start: Web Backend
Template selection: 1

    -----------------------
    Generating application:
    -----------------------
    Name: sam-multiple-lambda
    Runtime: nodejs14.x
    Architectures: x86_64
    Dependency Manager: npm
    Application Template: hello-world
    Output Directory: .

    Next application steps can be found in the README file at ./sam-multiple-lambda/README.md


    Commands you can use next
    =========================
    [*] Create pipeline: cd sam-multiple-lambda && sam pipeline init --bootstrap
    [*] Test Function in the Cloud: sam sync --stack-name {stack-name} --watch

作成されたディレクトリ内に移動。

$ cd sam-multiple-lambda

今回は「Hello World Example」をテンプレートに選びましたが、使わないので削除します。

$ rm -rf hello-world

作成するAWS Lambda関数は、「Hello World」を返すものと、足し算をする2つのものをTypeScriptで作成します。

$ mkdir hello calc

以下、それぞれ各AWS Lambda関数のディレクトリに対して同じことを行います。

$ cd hello

Node.jsのプロジェクト作成と、TypeScript、型宣言のインストール。

$ npm init -y
$ npm i -D typescript
$ npm i -D -E prettier
$ npm i -D @types/node@v14 @types/aws-lambda
$ mkdir src

TypeScriptの設定ファイル。srcディレクトリにソースコードを配置して、ビルド結果はdistディレクトリに出力します。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": [
    "src"
  ]
}

Prettierの設定。

.prettierrc.json

{
  "singleQuote": true
}

package.jsonのscriptsは、こんな感じにしておきました。npm run buildで、AWS SAMでデプロイするための
準備ができます。

  "scripts": {
    "build": "tsc --project . && cp package*.json dist",
    "build:watch": "tsc --project . --watch",
    "format": "prettier --write src"
  },

依存関係。

  "devDependencies": {
    "@types/aws-lambda": "^8.10.89",
    "@types/node": "^14.18.4",
    "prettier": "2.5.1",
    "typescript": "^4.5.4"
  }

ソースコードを作成して(後述)、ビルド。

$ npm run build

こういう結果になります。

$ tree dist
dist
├── app.js
├── package-lock.json
└── package.json

0 directories, 3 files

これを、calcディレクトリに対しても行います。

ソースコードは、それぞれこちら。

hello/src/app.ts

import { APIGatewayProxyEvent, Context } from 'aws-lambda';

export const lambdaHandler = async (event: APIGatewayProxyEvent, context: Context) => {
  return {
    statusCode: 200,
    body: {
      message: 'Hello World'
    }
  }
};

calc/src/app.ts

import { APIGatewayProxyEvent, Context } from 'aws-lambda';

export const lambdaHandler = async (event: APIGatewayProxyEvent, context: Context) => {
  if (event.body) {
    const request = JSON.parse(event.body);

    const a = parseInt(request.a, 10);
    const b = parseInt(request.b, 10);

    return {
      statusCode: 200,
      body: {
        result: a + b
      }
    }
  } else {
    return {
      statusCode: 400,
      body: {
        message: 'missing body'
      }
    }
  }
};

デプロイする

では、AWS SAM CLIを使ってデプロイします。template.yamlがあるディレクトリに移動。

$ cd ..

テンプレートは、このようにしました。

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  sam-multiple-lambda

  Sample SAM Template for sam-multiple-lambda

Globals:
  Function:
    Timeout: 3

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello/dist
      Handler: app.lambdaHandler
      Runtime: nodejs14.x
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get
  CalcFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: calc/dist
      Handler: app.lambdaHandler
      Runtime: nodejs14.x
      Architectures:
        - x86_64
      Events:
        Calc:
          Type: Api
          Properties:
            Path: /calc
            Method: post

Outputs:
  HelloWorldApi:
    Description: "API Gateway endpoint URL for Prod stage for Hello World function"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
  CalcApi:
    Description: "API Gateway endpoint URL for Prod stage for Calc function"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/calc/"

AWS::Serverless::Functionは2つ定義して、それぞれCodeUriでデプロイ対象のファイルを指すようにしています。

  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello/dist
      Handler: app.lambdaHandler
      Runtime: nodejs14.x
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get
  CalcFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: calc/dist
      Handler: app.lambdaHandler
      Runtime: nodejs14.x
      Architectures:
        - x86_64
      Events:
        Calc:
          Type: Api
          Properties:
            Path: /calc
            Method: post

ビルドします。

$ samlocal build

それぞれのAWS Lambda関数に対してビルドが行われていることが確認できます。

Building codeuri: /path/to/sam-multiple-lambda/hello/dist runtime: nodejs14.x metadata: {} architecture: x86_64 functions: ['HelloWorldFunction']
Running NodejsNpmBuilder:NpmPack
Running NodejsNpmBuilder:CopyNpmrc
Running NodejsNpmBuilder:CopySource
Running NodejsNpmBuilder:NpmInstall
Running NodejsNpmBuilder:CleanUpNpmrc
Building codeuri: /path/to/sam-multiple-lambda/calc/dist runtime: nodejs14.x metadata: {} architecture: x86_64 functions: ['CalcFunction']
Running NodejsNpmBuilder:NpmPack
Running NodejsNpmBuilder:CopyNpmrc
Running NodejsNpmBuilder:CopySource
Running NodejsNpmBuilder:NpmInstall
Running NodejsNpmBuilder:CleanUpNpmrc

Build Succeeded

Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

Commands you can use next
=========================
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {stack-name} --watch
[*] Deploy: sam deploy --guided

あとは、Amazon S3バケットを作成して

$ awslocal s3 mb s3://my-bucket

デプロイ。

$ samlocal deploy --stack-name my-stack --region us-east-1 --s3-bucket my-bucket

2つのAWS Lambda関数を含めてデプロイされました。

CloudFormation events from stack operations
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ResourceStatus                             ResourceType                               LogicalResourceId                          ResourceStatusReason
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------
CREATE_COMPLETE                            AWS::CloudFormation::Stack                 HelloWorldFunctionRole                     -
CREATE_COMPLETE                            AWS::CloudFormation::Stack                 CalcFunctionRole                           -
CREATE_COMPLETE                            AWS::CloudFormation::Stack                 ServerlessRestApiProdStage                 -
CREATE_COMPLETE                            AWS::CloudFormation::Stack                 ServerlessRestApiDeploymentd73aeb0c06      -
CREATE_COMPLETE                            AWS::CloudFormation::Stack                 ServerlessRestApi                          -
CREATE_COMPLETE                            AWS::CloudFormation::Stack                 HelloWorldFunctionHelloWorldPermissionPr   -
                                                                                      od
CREATE_COMPLETE                            AWS::CloudFormation::Stack                 HelloWorldFunction                         -
CREATE_COMPLETE                            AWS::CloudFormation::Stack                 my-stack                                   -
CREATE_COMPLETE                            AWS::CloudFormation::Stack                 CalcFunctionHelloWorldPermissionProd       -
CREATE_COMPLETE                            AWS::CloudFormation::Stack                 CalcFunction                               -
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------

OutputsでもURLは出力していますが、AWS CLIでREST APIのIDを取得して

$ REST_API_ID=$(awslocal apigateway get-rest-apis --query 'items[0].id' --output text)

動作確認。

$ curl http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/hello
{"message": "Hello World"}



$ curl -XPOST http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/calc -d '{"a": 5, "b": 3}'
{"result": 8}

2つのAWS Lambda関数が、同じAmazon API GatewayのREST APIのリソースに紐付けられていることが確認できました。

OKですね。

sam build時にnpm run buildを実行する

ここまででやりたいことは確認できましたが、毎回各AWS Lambda関数のディレクトリに移動してnpm run buildするのも
面倒な気がします。

いい方法はないかな?と思ったのですが、sam build時にMakefileを動かせるようです。こちらを試してみましょう。

この部分ですね。

リソースに BuildMethod エントリがある Metadata リソース属性が含まれている場合、sam build は BuildMethod エントリの値に従ってそのリソースを構築します。BuildMethod の有効な値は、1) Lambda ランタイムの識別子の 1 つ、または 2) makefile 識別子です。

makefile 識別子 — リソース用のビルドターゲットのコマンドを実行します。この場合、makefile が Makefile と命名されており、build-resource-logical-id という名前のビルドターゲットが含まれている必要があります。

sam build - AWS Serverless Application Model

Lambdaランタイム識別子はnodejs14.xのような記述のことですが、こちらはデフォルトの挙動な気がするので
ここでは飛ばします。

Lambda ランタイム - AWS Lambda

Metadataについては、こちらに記載があります。

Metadata 属性 - AWS CloudFormation

Makefileの例はないかな?と思ったら、カスタムランタイムのページに書かれていました。

カスタムランタイムの構築 - AWS Serverless Application Model

Makefileは、CodeUriで指定したディレクトリにMakefileという名前で存在する必要があります。

Makefile の場所は、関数リソースの CodeUri プロパティによって指定され、Makefile と命名される必要があります。

また、ビルドターゲットの名前はbuild-[リソース論理ID]である必要があります。このターゲットが見つからない場合、
sam buildコマンドは失敗します。

ここまでの情報をもとに、template.yamlを修正してみます。

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello
      Handler: app.lambdaHandler
      Runtime: nodejs14.x
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get
    Metadata:
      BuildMethod: makefile
  CalcFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: calc
      Handler: app.lambdaHandler
      Runtime: nodejs14.x
      Architectures:
        - x86_64
      Events:
        Calc:
          Type: Api
          Properties:
            Path: /calc
            Method: post
    Metadata:
      BuildMethod: makefile

2つのAWS Lambda関数のCodeUriからdistディレクトリの記述を削除し、MetadataおよびBuildMethod: makefileを
追加しました。

Makefileは、それぞれこのように用意。

hello/Makefile

build-HelloWorldFunction:
        rm -rf node_modules
        npm i
        npm run build
        cd dist && \
          npm i --production && \
          cp -R * ${ARTIFACTS_DIR}

calc/Makefile

build-CalcFunction:
        rm -rf node_modules
        npm i
        npm run build
        cd dist && \
          npm i --production && \
          cp -R * ${ARTIFACTS_DIR}

ARTIFACTS_DIRという環境変数は、後でまた出てきますがビルド結果を配置するディレクトリになります。

cdコマンドの後に&&でつなげて実行していますが、これはMakefileに記述された各行はサブシェルで実行されるため、
次のコマンドには影響しないからですね。

GNU make / Recipe Execution

また、このMakefileおよびコマンドは/tmp領域にファイルをコピーしてから実行されるようなのですが(pwdで
見るとCodeUriとは全然違うディレクトリになっています)、最初にnode_modulesをして再インストールしないと
うまく動きませんでした…。

では、ビルドはMakefile側で行うことになるはずなので、過去のビルド結果は削除。

$ rm -rf hello/dist calc/dist
$ rm -rf .aws-sam

ビルド。

$ samlocal build

「Building codeuri」というメッセージ以降が「Running CustomMakeBuilder」と変化します。

Building codeuri: /path/to/sam-multiple-lambda/hello runtime: nodejs14.x metadata: {'BuildMethod': 'makefile'} architecture: x86_64 functions: ['HelloWorldFunction']
Running CustomMakeBuilder:CopySource
Running CustomMakeBuilder:MakeBuild
Current Artifacts Directory : /path/to/sam-multiple-lambda/.aws-sam/build/HelloWorldFunction
Building codeuri: /path/to/sam-multiple-lambda/calc runtime: nodejs14.x metadata: {'BuildMethod': 'makefile'} architecture: x86_64 functions: ['CalcFunction']
Running CustomMakeBuilder:CopySource
Running CustomMakeBuilder:MakeBuild
Current Artifacts Directory : /path/to/sam-multiple-lambda/.aws-sam/build/CalcFunction

Build Succeeded

Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

Commands you can use next
=========================
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {stack-name} --watch
[*] Deploy: sam deploy --guided

この最中にMakefileが実行され、複数のAWS Lambda関数のビルドができるようになります。

また、「Current Artifacts Directory」というのがMakefile内でARTIFACTS_DIRという環境変数で格納されていた値に
なります。

Current Artifacts Directory : /path/to/sam-multiple-lambda/.aws-sam/build/HelloWorldFunction

Current Artifacts Directory : /path/to/sam-multiple-lambda/.aws-sam/build/CalcFunction

ビルド結果。

a$ tree .aws-sam
.aws-sam
├── build
│   ├── CalcFunction
│   │   ├── app.js
│   │   ├── package-lock.json
│   │   └── package.json
│   ├── HelloWorldFunction
│   │   ├── app.js
│   │   ├── package-lock.json
│   │   └── package.json
│   └── template.yaml
└── build.toml

3 directories, 8 files

なお、先ほどの記載しましたが、make自体は別の場所で実行されるため、CodeUriで示した場所にはビルド結果
(今回の場合TypeScriptファイルのビルド結果やnpm installの結果など)は残っていません。

$ ll hello/dist calc/dist
ls: 'hello/dist' にアクセスできません: そのようなファイルやディレクトリはありません
ls: 'calc/dist' にアクセスできません: そのようなファイルやディレクトリはありません

あとは、この状態のままデプロイが可能です。

$ samlocal deploy --stack-name my-stack --region us-east-1 --s3-bucket my-bucket

複数のAWS Lambda関数を、まとめてビルドできるようになりました、と。

まとめ

AWS SAMを使って、複数のAWS Lambda関数をLocalStack上のAmazon API Gatewayのバックエンドにデプロイして
みました。

これ自体はあっさりいったのですが、数が増えると面倒になるなぁと思ってMakefileの方を調べ始めたらなかなか
大変でした…。

とりあえず、確認したいことはできたので良しとしましょう。