この記事は個人ブログと同じ内容です
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 を導入します。
(上記リンクのページでは、Mac 以外にも Linux や Windows でのインストール手順も記載があります)
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 上で確認するためのものです。
ちなみに 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-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 デプロイパイプラインを設定したりもできるらしいので次の機会にやってみたいところ。
- ローカルで開発・動作確認
- sam deploy で cloud 上の dev 環境にリソース作成・更新して動作確認
- CI/CD プロセスにて stg 環境や prod 環境へデプロイ
こんな具合で開発していけたらスムーズだなと感じました。
実際にやってみると、Lambda function の実装部分で、環境変数まわりが原因でローカルでは動作したが cloud 上で動作しなかったこともあったので、実装段階で cloud 上でもどんどん試せるような仕組みがあると良いと思いました。
その上で、意図せず stg や prod に変更がかからないようにこれらの環境への反映は sam コマンドではなく別のプロセスを経てデプロイしていく。
こんな開発フローだと心理的安全性も高まりそうです。
2023 年 7 月時点ではプレビューリリースですが、terraform とも連携できるらしい(Terraform プロジェクトで AWS SAM CLI を使う)
現在 back check 開発チームでは一緒に働く仲間を募集中です。 herp.careers https://herp.careers/v1/scouter/klIFYKELaF8Yherp.careers herp.careers