AWS SAM でローカルに閉じたサーバレスアプリケーション開発環境を構築する

この記事は個人ブログと同じ内容です

www.ritolab.com


AWS SAM を使って、ローカルに閉じた状態でのサーバレスアプリケーション開発環境を構築してみます。

AWS Serverless Application Model (SAM)

AWS SAM は、サーバーレスアプリケーションの開発とデプロイメントをシンプルに行うためのフレームワークです。

SAM は AWS CloudFormation の拡張であり、CloudFormation テンプレートにサーバーレスアプリケーションを定義することができます。

SAM は Serverless Application Model の略称です。

ローカルでのサーバレス開発環境

AWS でサーバレスアプリケーション開発を行う際に、開発時はローカルで完結させデバッグトライアンドエラーを素早く回せた方が開発効率が良いと思います。

そこで、AWS SAM と docker を使ってローカルに閉じた環境で開発ができるようにしてみます。

主に扱う AWS リソースは以下です。

また、PC は Mac での動作確認です。

AWS SAM CLI の導入

SAM ベースのアプリケーション開発環境を構築するため、AWS SAM CLI を導入します。

AWS SAM CLI のインストール

(上記リンクのページでは、Mac 以外にも LinuxWindows でのインストール手順も記載があります)

Homebrew で AWS SAM CLI をインストールします。

# sam cli インストール
brew install aws/tap/aws-sam-cli

# インストール確認
sam --version
##  SAM CLI, version 1.91.0

ベースアプリケーション作成

ローカルで API Gateway + Lambda を用いたアプリケーションを作成していきます。

AWS から公開されているチュートリアルを参考にすると分かりやすいです。

Tutorial: Deploying a Hello World application

ここでは SAM CLI を使ってベースとなるアプリケーションを作成し、そこから DynamoDB も繋げていこうと思います。

まずはアプリケーションを作成(初期化, ベース構築)するため以下のコマンドをプロジェクトルートで実行します。

sam init

sam init コマンドは新しいサーバーレスアプリケーションプロジェクトを作成するためのコマンドです。

以下のように対話型で各項目を選択していくことで、ベースとなるアプリケーションや CloudFormation の template(厳密には CloudFormation を拡張した sam 用の template)を作成してくれます。

% sam init

You can preselect a particular runtime or package type when using the `sam init` experience.
Call `sam init --help` to learn more.

Which template source would you like to use?
    1 - AWS Quick Start Templates
    2 - Custom Template Location
Choice: 1

Choose an AWS Quick Start application template
    1 - Hello World Example
    2 - Data processing
    3 - Hello World Example with Powertools for AWS Lambda
    4 - Multi-step workflow
    5 - Scheduled task
    6 - Standalone function
    7 - Serverless API
    8 - Infrastructure event management
    9 - Lambda Response Streaming
    10 - Serverless Connector Hello World Example
    11 - Multi-step workflow with Connectors
    12 - Full Stack
    13 - Lambda EFS example
    14 - DynamoDB Example
    15 - Machine Learning
Template: 1

Use the most popular runtime and package type? (Python and zip) [y/N]: N

Which runtime would you like to use?
    1 - aot.dotnet7 (provided.al2)
    2 - dotnet6
    3 - go1.x
    4 - go (provided.al2)
    5 - graalvm.java11 (provided.al2)
    6 - graalvm.java17 (provided.al2)
    7 - java17
    8 - java11
    9 - java8.al2
    10 - java8
    11 - nodejs18.x
    12 - nodejs16.x
    13 - nodejs14.x
    14 - nodejs12.x
    15 - python3.9
    16 - python3.8
    17 - python3.7
    18 - python3.10
    19 - ruby3.2
    20 - ruby2.7
    21 - rust (provided.al2)
Runtime: 11

What package type would you like to use?
    1 - Zip
    2 - Image
Package type: 1

Based on your selections, the only dependency manager available is npm.
We will proceed copying the template using npm.

Select your starter template
    1 - Hello World Example
    2 - Hello World Example TypeScript
Template: 2

Would you like to enable X-Ray tracing on the function(s) in your application?  [y/N]: N

Would you like to enable monitoring using CloudWatch Application Insights?
For more info, please view https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch-application-insights.html [y/N]: N

Project name [sam-app]: hello-world-app

    -----------------------
    Generating application:
    -----------------------
    Name: hello-world-app
    Runtime: nodejs18.x
    Architectures: x86_64
    Dependency Manager: npm
    Application Template: hello-world-typescript
    Output Directory: .
    Configuration file: hello-world-app/samconfig.toml

    Next steps can be found in the README file at hello-world-app/README.md


Commands you can use next
=========================
[*] Create pipeline: cd hello-world-app && sam pipeline init --bootstrap
[*] Validate SAM template: cd hello-world-app && sam validate
[*] Test Function in the Cloud: cd hello-world-app && sam sync --stack-name {stack-name} --watch

アプリケーションの初期化(アプリケーションのベース作成)が完了すると、以下のようなディレクトリとファイルが作成されます。

project_root/
└── hello-world-app
    ├── README.md
    ├── events
    │   └── event.json
    ├── hello-world
    │   ├── app.ts
    │   ├── jest.config.ts
    │   ├── package.json
    │   ├── tests
    │   │   └── unit
    │   │       └── test-handler.test.ts
    │   └── tsconfig.json
    ├── samconfig.toml
    └── template.yaml

Lambda function のコードは hello-world/app.ts です。

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

/**
 *
 * Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format
 * @param {Object} event - API Gateway Lambda Proxy Input Format
 *
 * Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
 * @returns {Object} object - API Gateway Lambda Proxy Output Format
 *
 */

export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    try {
        return {
            statusCode: 200,
            body: JSON.stringify({
                message: 'hello world',
            }),
        };
    } catch (err) {
        console.log(err);
        return {
            statusCode: 500,
            body: JSON.stringify({
                message: 'some error happened',
            }),
        };
    }
};

ここまでで、ベースとなるアプリケーションを作成できました。

Lambda 動作確認

Lambda をローカルで実行し動作を確認します。

sam build コマンドを hello-world-app/ 配下で実行します。

sam build コマンドによって依存関係(外部ライブラリ)の解決とコードのビルドが行われ、ローカル環境でサーバーレスアプリケーションをテストする準備が整います。

% sam build
Starting Build use cache
Manifest is not changed for (HelloWorldFunction), running incremental build
Building codeuri: /path/to/project_root/hello-world-app/hello-world runtime: nodejs18.x metadata: {'BuildMethod': 'esbuild',
'BuildProperties': {'Minify': True, 'Target': 'es2020', 'Sourcemap': True, 'EntryPoints': ['app.ts']}} architecture: x86_64 functions:
HelloWorldFunction
Running NodejsNpmEsbuildBuilder:CopySource
Running NodejsNpmEsbuildBuilder:LinkSource
Running NodejsNpmEsbuildBuilder:EsbuildBundle

Sourcemap set without --enable-source-maps, adding --enable-source-maps to function HelloWorldFunction NODE_OPTIONS

You are using source maps, note that this comes with a performance hit! Set Sourcemap to false and remove NODE_OPTIONS: --enable-source-maps to disable
source maps.


Build Succeeded

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

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

ビルドが終わったらコマンド sam local invoke を実行し Lambda を走らせます。

sam local invoke

実行結果は以下です。

% sam local invoke
Invoking app.lambdaHandler (nodejs18.x)
Local image is up-to-date
Using local image: public.ecr.aws/lambda/nodejs:18-rapid-x86_64.

Mounting /path/to/project_root/hello-world-app/.aws-sam/build/HelloWorldFunction as /var/task:ro,delegated, inside runtime container
START RequestId: 418f2531-6b7e-4fe9-ae3c-bd0d7057e5dc Version: $LATEST
END RequestId: 418f2531-6b7e-4fe9-ae3c-bd0d7057e5dc
REPORT RequestId: 418f2531-6b7e-4fe9-ae3c-bd0d7057e5dc  Init Duration: 0.80 ms  Duration: 955.80 ms Billed Duration: 956 ms Memory Size: 128 MB Max Memory Used: 128 MB
{"statusCode":200,"body":"{\"message\":\"hello world\"}"}

Lambda 関数がローカルで実行できたことを確認できました。

API Gateway エンドポイントからの動作確認

続いて、API Gateway で作成されるエンドポイントを使って Lambda の実行を確認します。

API Gateway の設定は hello-world-app/template.yaml に記載があります。

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello-world/
      Handler: app.lambdaHandler
      Runtime: nodejs18.x
      Architectures:
        - x86_64
      ### Lambda に API Gateway トリガーを追加 ###
      Events: 
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get
      ### [GET] https://xxxxxx/hello ###

[GET] /hello というエンドポイントになっていることが確認できます。

api を動作させるため、以下の sam コマンドを実行します。

sam local start-api

コンテナが起動しエンドポイントがマウントされます。

% sam local start-api
Initializing the lambda functions containers.
Local image is up-to-date
Using local image: public.ecr.aws/lambda/nodejs:18-rapid-x86_64.

Mounting HelloWorldFunction at http://127.0.0.1:3000/hello [GET]

先程 template.yaml で確認したものと同じ [GET] http://127.0.0.1:3000/hello にリクエストができるようになりました。

リクエストしてみます。

curl http://127.0.0.1:3000/hello

## => {"message":"hello world"}

API へリクエストし、レスポンスが返されたことを確認できました。

API Gateway をシュミレートした動作確認もローカルで行えました。

実際にこの template.yaml は CloudFormation の設定を sam 用に拡張したものになっているため、ここでの動作確認が行えた場合は実際に AWS 上にリソースを作成する場合も連携面や実装ロジックに関しては問題なく動作するであろう。ということが言えます。(これらは最後に、実際に AWS cloud 上にデプロイして、実際の AWS リソース上でも動作確認を行ってみます)

DynamoDB を絡めたローカル動作確認

ローカルに閉じた状態で DynamoDB まで含めて動作確認を行う場合は、別途 DynamoDB のコンテナを作成する必要があります。

それには AWS から提供されている dynamodb-local が便利です。

プロジェクトルート直下に docker-compose.yml を作成し、DynamoDB local を定義します。

また、今回は動作確認のため dynamodb-admin のコンテナも一緒に作成しています。これは、DynamoDB に収録されているデータを GUI 上で確認するためのものです。

aaronshaf/dynamodb-admin

ちなみに JetBrains の IDE を使っている場合は DynamoDB の database connection を実現できるプラグイン が用意されていますが、有料のため、今回はコンテナで用意しています。

version: '3.8'
services:
  dynamodb-local:
    image: "amazon/dynamodb-local:latest"
    container_name: dynamodb-local
    command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data"
    ports:
      - "8000:8000"
    volumes:
      - "./docker/dynamodb:/home/dynamodblocal/data"
    working_dir: /home/dynamodblocal
    networks:
      - default

  dynamodb-admin:
    image: aaronshaf/dynamodb-admin:latest
    container_name: dynamodb-admin
    environment:
      - DYNAMO_ENDPOINT=dynamodb-local:8000
    ports:
      - "8001:8001"
    depends_on:
      - dynamodb-local
    networks:
      - default

networks:
  default:
    name: dynamodb-local-network

定義したら docker compose up でコンテナを起動させます。

コンテナが起動したらブラウザから localhost:8001 にアクセスすると dynamodb-admin の画面にアクセスできます。

Scan, Query はもちろん、テーブル作成・削除などもできるので入れておくと便利です。

DynamoDB のローカル環境を構築したので、アプリケーションと繋げます。

環境変数

環境変数を読み込めるようにします。アクセスする DynamoDB をローカルの DynamoDB コンテナへ向けるためです。

project_root/local_env_vars.json

{
  "HelloWorldFunction": {
    "DDB_ENDPOINT": "http://dynamodb-local:8000"
  }
}

hello-world-app/template.yaml

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello-world/
      Handler: app.lambdaHandler
      Runtime: nodejs18.x
      Architectures:
        - x86_64
      Policies:
        - AmazonDynamoDBFullAccess # 動作確認用リソースのため FullAccess にしていますが適宜適切なポリシーを指定します
      ### 追加ここから ###
      Environment:
        Variables:
          DDB_ENDPOINT: ''
      ### 追加ここまで ###

DDB_ENDPOINT の値を空にしているのは、外から値を指定するためです。

ローカルでの実行時に local_env_vars.json からの値を読み込んで DDB_ENDPOINT の値を上書きします。

(実際は DynamoDB のエンドポイントを指定したいのはローカルのみで、cloud 上の DynamoDB であれば指定は不要のため、template.yaml に Environment を指定せずとも環境変数を指定できれば一番良いと思います。)

Lambda function

hello-world/app.ts で DynamoDB に put するコードを記述します。

DynamoDB my-table に、 id と timestamp を書き込む簡単なものです。

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb'
import crypto from 'crypto'

const client = new DynamoDBClient({
    region: 'ap-northeast-1',
    endpoint: process.env.DDB_ENDPOINT !== '' ? process.env.DDB_ENDPOINT : undefined,
})

export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    try {
        const id = crypto.randomUUID()
        const timestamp = Date.now().toString()

        const input = {
            TableName: 'my-table',
            Item: {
                id: {
                    S: id,
                },
                timestamp: {
                    S: timestamp,
                },
            },
        }

        const command = new PutItemCommand(input)
        const response = await client.send(command)

        return {
            statusCode: 200,
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                message: 'success',
            }),
        }
    } catch (err) {
        console.error(err)
        return {
            statusCode: 500,
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                message: 'some error happened',
            }),
        }
    }
}

(もはや hello world の文脈は完全に消えましたが動作確認したいのでこのままいきます)

template

hello-world-app/template.yaml に、DynamoDB リソースを追加します。

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello-world/
      Handler: app.lambdaHandler
      Runtime: nodejs18.x
      Architectures:
        - x86_64
      Policies:
        - AmazonDynamoDBFullAccess
      Environment:
        Variables:
          DDB_ENDPOINT: ''
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: "es2020"
        Sourcemap: true
        EntryPoints:
          - app.ts
  ### 追加ここから ###
  DynamoDBTable:
    Type: AWS::Serverless::SimpleTable
    Properties:
      TableName: my-table
      PrimaryKey:
        Name: timestamp
        Type: String
      ProvisionedThroughput:
        ReadCapacityUnits: 1
        WriteCapacityUnits: 1
  ### 追加ここまで ###

ビルド & 動作確認

実装が済んだのでビルドして Lambda function を実行してみます。

# ビルド
sam build

# lambda 関数 を実行
sam local invoke --docker-network dynamodb-local-network --env-vars local_env_vars.json
  • --docker-network dynamodb-local-network
    • オプションでローカルの DynamoDB コンテナネットワークを指定しています。これによって Lambda のコンテナと DynamoDB のコンテナ間の通信を可能にしています。
  • --env-vars local_env_vars.json
    • ローカルの DynamoDB にリクエストを送信するようにエンドポイントを記述した環境変数ファイルを読み込んでいます。

dynamodb-admin から、ローカルの DynamoDB に値が insert されたか確認してみます。

insert されました。

API Gateway シュミレートで試す場合も同様にオプションを指定すれば動作します。

# ローカル環境で API Gateway と Lambda 関数を起動する
sam local start-api --docker-network dynamodb-local-network --env-vars local_env_vars.json

# エンドポイントにリクエスト
curl http://127.0.0.1:3000/hello

ここままで、ローカルに閉じた状態で、そして AWS リソースを cloud 上に作成することなく、API Gateway と Lambda function, そして DynamoDB を使った実装と動作確認までを行うことができました。

AWS へデプロイし動作確認

これまで構築したものを実際に AWS cloud 上でも構築して動作確認を行ってみます。

まずは sam build コマンドを実行しビルドします。.aws-sam ディレクトリが作成され、そこにアプリケーションの依存関係とファイルがデプロイ用に作成されます。

 % sam build
Starting Build use cache
Manifest is not changed for (HelloWorldFunction), running incremental build
Building codeuri: /path/to/project_root/hello-world-app/hello-world runtime: nodejs18.x metadata: {'BuildMethod': 'esbuild', 'BuildProperties': {'Minify':
True, 'Target': 'es2020', 'Sourcemap': True, 'EntryPoints': ['app.ts']}} architecture: x86_64 functions: HelloWorldFunction
Running NodejsNpmEsbuildBuilder:CopySource
Running NodejsNpmEsbuildBuilder:LinkSource
Running NodejsNpmEsbuildBuilder:EsbuildBundle

Sourcemap set without --enable-source-maps, adding --enable-source-maps to function HelloWorldFunction NODE_OPTIONS

You are using source maps, note that this comes with a performance hit! Set Sourcemap to false and remove NODE_OPTIONS: --enable-source-maps to disable source maps.

Build Succeeded

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

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

次に、sam deploy --guided コマンドを使用してアプリケーションをデプロイします

--guided オプションを使用すると、デプロイに関する設定を対話的に進めていくことができます。

% sam deploy --guided

Configuring SAM deploy
======================

    Looking for config file [samconfig.toml] :  Found
    Reading default arguments  :  Success

    Setting default arguments for 'sam deploy'
    =========================================
    Stack Name [hello-world-app]:
    AWS Region [ap-northeast-1]:
    #Shows you resources changes to be deployed and require a 'Y' to initiate deploy
    Confirm changes before deploy [Y/n]:
    #SAM needs permission to be able to create roles to connect to the resources in your template
    Allow SAM CLI IAM role creation [Y/n]:
    #Preserves the state of previously provisioned resources when an operation fails
    Disable rollback [y/N]:
    HelloWorldFunction has no authentication. Is this okay? [y/N]: y
    Save arguments to configuration file [Y/n]:
    SAM configuration file [samconfig.toml]:
    SAM configuration environment [default]:

.
.
(略)
.
.

Successfully created/updated stack - hello-world-app in ap-northeast-1

デプロイが完了すると、AWS 上に各リソースが作成されたことが確認できました。

実際にエンドポイントにリクエストしてみると、DynamoDB へ値が保存されることも確認できます。

AWS SAM を使って開発したアプリケーションが cloud 上でも動作することを確認できました。

AWS 上に作成したリソースを削除

デプロイを行って AWS 上に作成したものを削除します。

sam delete コマンドを実行すると、先程の sam deploy コマンドで cloud 上に作成されたリソースを削除できます。

% sam delete
    Are you sure you want to delete the stack hello-world-app in the region ap-northeast-1 ? [y/N]: y
    Are you sure you want to delete the folder hello-world-app in S3 which contains the artifacts? [y/N]: y
        - Deleting S3 object with key hello-world-app/34de905deffe7387dd11e9e1537e199
        - Deleting S3 object with key hello-world-app/d395b65c7264033b843198cf68b6e9b7.template
    - Deleting Cloudformation stack hello-world-app

Deleted successfully

AWS 上に作成したリソースが綺麗に削除されました。コマンド 1 つで関連リソース全て落とせるので、不要な課金も生まなくて安心です。

あとがき

サーバレスアプリケーションの開発をローカルに閉じた状態で進めていけるのはとても便利でした。

AWS SAM では他にも CI/CD デプロイパイプラインを設定したりもできるらしいので次の機会にやってみたいところ。

  1. ローカルで開発・動作確認
  2. sam deploy で cloud 上の dev 環境にリソース作成・更新して動作確認
  3. CI/CD プロセスにて stg 環境や prod 環境へデプロイ

こんな具合で開発していけたらスムーズだなと感じました。

実際にやってみると、Lambda function の実装部分で、環境変数まわりが原因でローカルでは動作したが cloud 上で動作しなかったこともあったので、実装段階で cloud 上でもどんどん試せるような仕組みがあると良いと思いました。

その上で、意図せず stg や prod に変更がかからないようにこれらの環境への反映は sam コマンドではなく別のプロセスを経てデプロイしていく。

こんな開発フローだと心理的安全性も高まりそうです。

2023 年 7 月時点ではプレビューリリースですが、terraform とも連携できるらしい(Terraform プロジェクトで AWS SAM CLI を使う)

AWS SAM CLI Terraform のサポート


現在 back check 開発チームでは一緒に働く仲間を募集中です。 herp.careers https://herp.careers/v1/scouter/klIFYKELaF8Yherp.careers herp.careers