事情があってSAMを学ぶ必要があったので。
10分だけログの残る、curlコマンドで使える掲示板を作ることにします。
使うものとしてはだいたい以下です(細かいのは省く)
- SAM
- API Gateway
- Lambda
- DynamoDB
- WAF
- EventBridge
EventBridgeは大体10分ごとにログを消すのに使います。
WAFは使い方を知っとく必要があったので強引に含めました。
リポジトリは以下
twinbird/aws-sam-chat-app - GitHub
言語はTypeScriptを使います。
AWS SAM CLIのインストール
AWSの公式ドキュメントの以下のページに従ってインストールします。
https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/install-sam-cli.html
ユーザーを作成
ルートユーザーで新しいユーザーを作成します。
以下のユーザー名でコンソール画面から作成。
limited-chat-dev-user
コンソールアクセスは許可しない。
「ポリシーを直接アタッチする」を選択して以下を追加。
AWSCloudFormationFullAccess
IAMFullAccess
AWSLambda_FullAccess
AmazonAPIGatewayAdministrator
AmazonS3FullAccess
AmazonEC2ContainerRegistryFullAccess
今回はDynamoDBも使いたいので以下も加えます。
AmazonDynamoDBFullAccess
あとはEventBridgeと
AmazonEventBridgeFullAccess
WAFも使うので。
AWSWAFFullAccess
以下を参考にした。
AWS SAM 開発者ガイド - AWS CloudFormation メカニズムによる権限の管理
アクセスキーの発行
(IAM Identity Centerが推奨されている様子だが、諸事情でここではこちらを採用)
IAMから作成したユーザーを選択し、「セキュリティ認証情報」のタブを選択します。
「アクセスキー」から「アクセスキーを作成」を選択します。
コマンドラインインターフェース(CLI)を選択します。
認証情報はCSVダウンロードしておきます。
aws-cliにプロファイル登録する
aws configure --profile limited-chat-dev
先ほど作成したアクセスキーを使います。
regionはap-northeast-1を利用しました。
プロジェクトの作成
初期化用の以下のコマンドを使ってプロジェクトを作成します。
sam init
対話式で設定できますが、結構長いです。
で作成しました。とりあえず全部載せます。
$ 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 - GraphQLApi Hello World Example
13 - Full Stack
14 - Lambda EFS example
15 - Hello World Example With Powertools for AWS Lambda
16 - DynamoDB Example
17 - 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 - dotnet8
3 - dotnet6
4 - go1.x
5 - go (provided.al2)
6 - go (provided.al2023)
7 - graalvm.java11 (provided.al2)
8 - graalvm.java17 (provided.al2)
9 - java21
10 - java17
11 - java11
12 - java8.al2
13 - nodejs20.x
14 - nodejs18.x
15 - nodejs16.x
16 - python3.9
17 - python3.8
18 - python3.12
19 - python3.11
20 - python3.10
21 - ruby3.2
22 - rust (provided.al2)
23 - rust (provided.al2023)
Runtime: 13
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]:
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]:
Would you like to set Structured Logging in JSON format on your Lambda functions? [y/N]:
Project name [sam-app]: limited-chat-app
-----------------------
Generating application:
-----------------------
Name: limited-chat-app
Runtime: nodejs20.x
Architectures: x86_64
Dependency Manager: npm
Application Template: hello-world-typescript
Output Directory: .
Configuration file: limited-chat-app/samconfig.toml
Next steps can be found in the README file at limited-chat-app/README.md
Commands you can use next
=========================
[*] Create pipeline: cd limited-chat-app && sam pipeline init --bootstrap
[*] Validate SAM template: cd limited-chat-app && sam validate
[*] Test Function in the Cloud: cd limited-chat-app && sam sync --stack-name {stack-name} --watch
ビルド&デプロイしてみる
ここまででビルドしてデプロイしてみます。
先ほど作成したプロジェクトディレクトリへ移動します。
cd limited-chat-app
まずは以下のコマンドでテンプレートファイルが正しいか確認できるようです。
$ sam validate
【ディレクトリ名】/limited-chat-app/template.yaml is a valid SAM Template
続いてビルドしてみます。
sam build
...【色々出る】
Build Succeeded
...【色々出る】
このままデプロイします。
sam deploy --guided --profile limited-chat-dev
Configuring SAM deploy
======================
Looking for config file [samconfig.toml] : Found
Reading default arguments : Success
Setting default arguments for 'sam deploy'
=========================================
Stack Name [limited-chat-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]: Y
#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]: Y
#Preserves the state of previously provisioned resources when an operation fails
Disable rollback [y/N]: y
HelloWorldFunction has no authentication. Is this okay? [y/N]: y
Save arguments to configuration file [Y/n]: Y
SAM configuration file [samconfig.toml]:
SAM configuration environment [default]:
【以下略】
コンソールの最後に出るAPI GatewayのURLにアクセスして、JSONでHello WorldされればOKです。
一度削除する
とりあえずdeployしてみたので一度リソースを消します。
$ sam delete --profile limited-chat-dev
Are you sure you want to delete the stack limited-chat-app in the region ap-northeast-1 ? [y/N]: y
Are you sure you want to delete the folder limited-chat-app in S3 which contains the artifacts? [y/N]: y
【以下略】
投稿用の関数を作る
とりあえずhelloworldの設定を置き換える
template.yamlを見ると色々な箇所にHelloWorldが存在するので全てPostMessageへ置き換えていきます。
ディレクトリ名(hello-world)やnpmのpackage.jsonも変えてみます。
DynamoDBへテーブルを用意する
template.yamlを編集します。
Resources以下へ以下を追加します。
ChatTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ChatTable
AttributeDefinitions:
- AttributeName: uuid
AttributeType: S
KeySchema:
- AttributeName: uuid
KeyType: HASH
ProvisionedThroughput:
ReadCapacityUnits: 10
WriteCapacityUnits: 10
DynamoDBへ書き込みしてみる
DynamoDBへのアクセスにライブラリを使うので、post-messageディレクトリへ移動して以下でライブラリを導入します。
cd post-message
npm i --save-dev @aws-sdk/client-dynamodb
npm i --save-dev @aws-sdk/lib-dynamodb
使い方はこちらが参考になります。
SDK for JavaScript (v3) を使用した DynamoDB の例
DynamoDB での単一項目の読み取りと書き込み
このような感じでコードを書いて
/**
* メッセージをデータベースへ保存する
*/
const storePost = async (message: string): string => {
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const uuid = crypto.randomUUID();
const now = new Date();
const createdAt = now.toLocaleString('ja-JP', { timeZone: 'UTC' });
const command = new PutItemCommand({
TableName: 'ChatTable',
Item: {
uuid: { S: uuid },
createdAt: { S: createdAt },
message: { S: message },
},
});
const response = await client.send(command);
return uuid;
};
以下でビルドします。
sam build
こんな感じでESbuildのエラーメッセージが出ました。
Esbuild Failed: ✘ [ERROR] Could not resolve "@aws-sdk/client-dynamodb"
以下を参考に解決しています。
AWS SDK V3 + Lambda を SAM deploy した際の "Could not resolve" の対処法
template.yamlを以下のように変更しました。
Externalの箇所が必要なようです。Bundleするなと指示が必要らしい。
Metadata: # Manage esbuild properties
BuildMethod: esbuild
BuildProperties:
Minify: true
Target: "es2020"
Sourcemap: true
EntryPoints:
- app.ts
External:
- "@aws-sdk/lib-dynamodb"
- "@aws-sdk/client-dynamodb"
これでsam buildが通ります。
デプロイしてみます。
2度目なので以下だけでOKです。
sam deploy --profile limited-chat-dev
curlで試してみます。
curl -X POST 【APIゲートウェイのエンドポイントURL】 -H "Content-Type: application/json" -d '{ "message" : "Hello world" }'
開発中はエラーが起きると思うので、以下で確認できます。
sam logs --region=ap-northeast-1 --profile limited-chat-dev
あるいはブラウザでCloudWatchをコンソールから見る方がわかりやすいかも。
上記までで試すと以下のようなエラーが出ていました。
INFO AccessDeniedException: User: arn:【略】 is not authorized to perform: dynamodb:PutItem on resource: arn:aws:dynamodb:【略】 because no identity-based policy allows the dynamodb:PutItem action
権限をlambdaに与えてあげる必要があるようです。
template.yamlを書き換えます。
PostMessageFunction:
Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-appli cation-model/blob/master/versions/2016-10-31.md#awsserverlessfunction 17 Properties:
CodeUri: post-message/
Handler: app.lambdaHandler
Runtime: nodejs20.x
Policies: AmazonDynamoDBFullAccess # 追加
再度ビルドとデプロイし、curlでアクセスしてみます。
ここまでで投稿用のAPIができました。
関数を追加する
まず、lambda関数を加えます。
ディレクトリを作成します。
mkdir list-messages
簡単なサンプル関数をまずは書きます。
/**
* API成功時のレスポンスを返す
*/
const successResponse = (): Response => {
return {
statusCode: 200,
body: JSON.stringify({
message: 'success',
}),
};
};
/**
* エラー発生時のレスポンスを返す
*/
const errorResponse = (): Response => {
return {
statusCode: 500,
body: JSON.stringify({
message: 'some error happened',
}),
};
};
/**
* メッセージ取得API
*
* Request Bodyに以下の形式のJSONが含まれることを期待します。
* { message: 'チャット投稿用メッセージ' }
*/
export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
try {
return successResponse();
} catch (err) {
console.log(err);
return errorResponse();
}
};
設定ファイルはsam initで作成した最初の関数からコピーしてきました。
cp -a post-message/package.json list-messages/
cp -a post-message/tsconfig.json list-messages/
cp -a post-message/jest.config.ts list-messages/
一応package.jsonのnameとdescriptionくらいは書き換えておきます。
依存モジュールをインストールしておきます。
cd list-messages
npm i
template.yamlを変更します。
これもコピペです。
ListMessagesFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: list-messages/
Handler: app.lambdaHandler
Runtime: nodejs20.x
Policies: AmazonDynamoDBFullAccess
Architectures:
- x86_64
Events:
PostMessage:
Type: Api
Properties:
Path: /listMessages
Method: get
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: true
Target: "es2020"
Sourcemap: true
EntryPoints:
- app.ts
External:
- "@aws-sdk/lib-dynamodb"
- "@aws-sdk/client-dynamodb"
ビルドします。
sam build
ビルドが通ったのでデプロイしてみます。
sam deploy --profile limited-chat-dev
新しく追加したAPI GatewayのURLにアクセスするとレスポンスが返ってきます。
とりあえず関数の追加はできたようです。
追加した関数へDynamoDBから読み出しするための処理を加える
コードを変更します。こんな感じで読み出し用の関数を用意して返す感じで。
/**
* メッセージをデータベースから取り出す
*/
const fetchPosts = async (): [Post] => {
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const command = new ScanCommand({
TableName: 'ChatTable',
});
const data = await docClient.send(command);
console.log(data);
return data.Items.map((m) => {
return {
createdAt: m.createdAt,
message: m.message,
};
});
}
もはやクドイですが、ビルドしてデプロイします。
sam build
sam deploy --profile limited-chat-dev
curlで試してみます。
curl 【取得関数のURL】
一覧が取れました。
jqを使えばちょっとそれっぽく見えるかも。
curl 【取得関数のURL】 | jq '.posts[] | .createdAt + " " + .message'
脱線する話題だけど、jqのパイプを初めて使った。便利だった。
jqコマンドで複数フィールドの値を1行に表示させる
日付でソートする
掲示板なので昇順でも降順でも良いから並べたいです。
が、DynamoDBの設計がよくわかってなかったのでこのままだと上手くソートできない感じ。
【AWS】DynamoDBのキーとソートとフィルタについて
実務ならできないだろうけど、まぁお遊びだからええやろということでLambdaでソートしてやることに。
10分以上経過した投稿を削除する
先に書いておくと、DynamoDB自体にはTTL機能というものがあるので、本来こんな機能は実装の必要がありません。
あくまでも学習用のお試しです。
有効期限 (TTL) - AWS
DynamoDBの各データを自動で削除する機能(TTL:Time to Live)を試してみた - Developers IO
AWS::DynamoDB::Table TimeToLiveSpecification - AWS
さて、最終的にはEventBridgeで定期実行したいですが、とりあえずLambdaでバッチ削除できる関数を作ります。
ディレクトリを作成します。
mkdir delete-old-messages
前回同様設定ファイルはsam initで作成した最初の関数からコピーしてきました。
cp -a post-message/package.json delete-old-messages/
cp -a post-message/tsconfig.json delete-old-messages/
cp -a post-message/jest.config.ts delete-old-messages/
package.jsonのnameとdescriptionは書き換えておきます。
次に関数を書いていきます。
vi delete-old-messages/app.ts
SDK使う部分主要なだけ抜き出すとこんな感じ。
/**
* 引数の投稿を削除する
*/
const deletePost = async (post: Post) => {
const command = new DeleteCommand({
TableName: 'ChatTable',
Key: {
uuid: post.uuid,
},
});
await docClient.send(command);
};
template.yamlも編集します。
API Gatewayと紐づける必要はないので、以下を参考にEvents以下は削除しました。
AWS SAM Lambda関数だけを単体でデプロイしたいときのテンプレート設定例
DeleteOldMessagesFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: delete-old-messages/
Handler: app.lambdaHandler
Runtime: nodejs20.x
Policies: AmazonDynamoDBFullAccess
Architectures:
- x86_64
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: true
Target: "es2020"
Sourcemap: true
EntryPoints:
- app.ts
External:
- "@aws-sdk/lib-dynamodb"
- "@aws-sdk/client-dynamodb"
とりあえず実行は管理コンソールから。一応動作しているようです。
日付の扱い方を変える
ここまで来て今さら気づいたのが、文字列をISO 8601形式にしておけば日付をQueryで絞り込めるのではということ。
Amazon DynamoDB で日時データを扱う方法
ということで変えてみました。
- const createdAt = now.toLocaleString('ja-JP', { timeZone: 'UTC' });
+ const createdAt = now.toISOString();
filterの方法はドキュメントを読んでそれっぽく書いてみました。
AWS SDK for JavaScript v3 - ScanCommand
Filterの書き方を間違えていて、何度やっても以下のエラーが出ていたけど
Invalid FilterExpression: Incorrect operand type for operator or function; operator or function: <, operand type: M
StackOverflowに救われて進めた。
DynamoDB: Query Incorrect operand type
Scanのページング?に対応する
DynamoDBのドキュメントをあれこれ読んでいるとこんなのを見つけた。
テーブルクエリ結果をページ分割する
あー、なんかこんな制約あった気がする。
この辺りを真似する。
How to paginate dynamodb results using aws sdk sendCommand - Stack Overflow
/**
* メッセージをデータベースから取り出す
*/
const fetchPosts = async (): [Post] => {
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
let data = [];
const getChunk = async (key: string): string => {
const command = new ScanCommand({
TableName: 'ChatTable',
Limit: 10,
ExclusiveStartKey: key,
});
const result = await docClient.send(command);
data.push(...result.Items);
return result.LastEvaluatedKey;
};
let key;
do {
key = await getChunk(key);
} while (key);
return data;
}
しかしキャパシティユニット無駄に使うなこれ。Query使いたいが。
EventBridgeで定期実行してみる
簡潔でありがたい。
【AWS SAM】EventBridgeでLambdaを定期実行するテンプレートの書き方
詳細はここを見ればよさそう。
AWS Serverless Application Model 開発者ガイド - ScheduleV2
なんか「年」のフィールドがある。cronにあったっけ?
あとこんなことも書かれているし、微妙にcronとは違う気がする。
cron 式の日フィールドと曜日フィールドを同時に指定することはできません。一方のフィールドに値または * (アスタリスク) を指定する場合、もう一方のフィールドで ? (疑問符) を使用する必要があります。
とりあえず以下のように書いてみた。
DeleteOldMessagesFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: delete-old-messages/
Handler: app.lambdaHandler
Runtime: nodejs20.x
Policies: AmazonDynamoDBFullAccess
Architectures:
- x86_64
Events:
ScheduledFunction:
Type: ScheduleV2
Properties:
ScheduleExpression: cron(0/10 * * * ? *)
ScheduleExpressionTimezone: "Asia/Tokyo"
State: ENABLED
Name: limited-chat-app-delete-old-messages-function-schedule
あと、deployにあたって以下のポリシーがないとダメだった。そりゃそうだ。
AmazonEventBridgeFullAccess
AWS WAFを設定してみる
これは使うケースが少なそうなんですが、諸事情で必要なので試してみます。
簡単な同一IPからのRate Limitをかけたい。
とりあえず使ったこともないのでコンソールから試してみる。
AWS WAF でアクセス数が一定回数を超えた IP アドレスを自動的にブラックリストに追加させる方法
一回作ったらコンソールからJSONがダウンロードできたので、ダウンロードして削除した。
{
"Name": "test-waf",
"Id": "【IDが入ってた】",
"ARN": "【ARNが入ってた】",
"DefaultAction": {
"Allow": {}
},
"Description": "",
"Rules": [
{
"Name": "rate-limit-rule-100-request-per-5-minutes",
"Priority": 0,
"Statement": {
"RateBasedStatement": {
"Limit": 100,
"EvaluationWindowSec": 300,
"AggregateKeyType": "IP"
}
},
"Action": {
"Block": {}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": false,
"MetricName": "rate-limit-rule-100-request-per-5-minutes"
}
}
],
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "test-waf"
},
"Capacity": 2,
"ManagedByFirewallManager": false,
"LabelNamespace": "awswaf:381491890434:webacl:test-waf:"
}
次にSAMで設定したい。
SAMでネイティブにサポートされているわけではないのかな。でもYAML書けば使えそうではある。
CloudFormationでググった方が情報出てくるかも。とググってると以下が出てきた。
Support WAF for API Gateway
参考にしつつ以下のtemplate.yamlへ書く。
API Gatewayの記述をこんな感じで分離しないとRefが取れないので面倒くさい。
Resources:
PostMessageApi:
Type: AWS::Serverless::Api
Properties:
StageName: Prod
PostMessageFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: post-message/
Handler: app.lambdaHandler
Runtime: nodejs20.x
Policies: AmazonDynamoDBFullAccess
Architectures:
- x86_64
Events:
PostMessage:
Type: Api
Properties:
Path: /postMessage
Method: post
RestApiId:
Ref: PostMessageApi
WAFの記述はマジでわからん。特にAssociationsの部分はGitHubのIssueほぼ丸パクリ。
ドキュメントもどこ見ればよいのかサッパリや。。。
(そもそも元のGitHubの人もめちゃ苦労してそう)
LimitedChatAppApiWebAcl:
Type: AWS::WAFv2::WebACL
Properties:
DefaultAction:
Allow: {}
Scope: REGIONAL
Rules:
- Action:
Block: {}
Name: rate-limit-rule-100-request-per-5-minutes
Priority: 0
Statement:
RateBasedStatement:
Limit: 100
AggregateKeyType: IP
VisibilityConfig:
SampledRequestsEnabled: true
CloudWatchMetricsEnabled: false
MetricName: rate-limit-rule-100-request-per-5-minutes
VisibilityConfig:
SampledRequestsEnabled: true
CloudWatchMetricsEnabled: true
MetricName: limited-chat-app-waf-acl
PostApiWebAclLimitedChatAppAssociations:
Type: AWS::WAFv2::WebACLAssociation
DependsOn: PostMessageApiProdStage
Properties:
WebACLArn: !GetAtt LimitedChatAppApiWebAcl.Arn
ResourceArn: !Sub 'arn:aws:apigateway:${AWS::Region}::/restapis/${PostMessageApi}/stages/Prod'
DependsOnを入れてやらないとAPI Gatewayを作る前にWAFを設定しようとして死ぬ。
参考ページ曰く、命名規則があるらしく
- <api‑LogicalId><stage‑name>Stage(Stage 名をベタ書きする場合)
- <api‑LogicalId>Stage(Stage 名を Parameters から取得する場合)
らしい。ありがたや。
AWS SAM で Amazon API Gateway の Usage Plan を作るときに API Stage not found と出たら - kakakakakku blog
こっちは同じ問題をCDKで対応された方っぽい。エラーが一緒だった。
AWS WAF couldn?t perform the operation because your resource doesn?t exist.
[AWS CDK] APIGateway+WAFv2 Web ACL構成でデプロイしようとしてちょっとハマった点 - Zenn
実行時には権限が必要なのでとりあえず以下を追加した。
AWSWAFFullAccess
環境変数の制御
Lambda内では以下のように環境変数を取得できる。
Lambda 環境変数の使用 - AWS
let region = process.env.AWS_REGION
で、ローカル実行時にはパラメータを渡すと環境変数を上書きできるらしい。
ローカルでの Lambda 関数の呼び出し
deploy時にも変えたいんだよね。こちらを参考にしてみる。
AWS Lambdaで使いたい環境変数をAWS SAM CLIでどうするか
こんな感じでtemplate.yamlを書いて。
# 環境変数用に用意
Parameters:
Secret:
Type: String
Resources:
PostMessageApi:
Type: AWS::Serverless::Api
Properties:
StageName: Prod
PostMessageFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: post-message/
Handler: app.lambdaHandler
Runtime: nodejs20.x
Policies: AmazonDynamoDBFullAccess
Architectures:
- x86_64
Environment:
Variables:
SECRET: !Ref Secret
Events:
PostMessage:
Type: Api
Properties:
Path: /postMessage
Method: post
RestApiId:
Ref: PostMessageApi
こんな感じでデプロイする。
sam deploy --profile limited-chat-dev --parameter-overrides Secret="THIS_IS_A_SECRET"
そうするとLambdaでこうできる。
let secret = process.env.SECRET
試しにレスポンスに含めてみたら取得できた。全然SECRETじゃないが。。。
ローカルでの開発環境を整える
ここまで来て?という感じだけどローカルでDynamoDBが動かせるらしいので。
とりあえずDynamoDBローカルを用意してみる
コンピュータ上で DynamoDB をローカルでデプロイする
公式の指示通り以下をdocker-compose.yamlとして保存して
version: '3.8'
services:
dynamodb-local:
command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data"
image: "amazon/dynamodb-local:latest"
container_name: dynamodb-local
ports:
- "8000:8000"
volumes:
- "./docker/dynamodb:/home/dynamodblocal/data"
working_dir: /home/dynamodblocal
データ保存先のディレクトリを用意してやる。
これをうっかりやらないとコンテナ側で書き込み権限がないので「cannot open DB[]: com.almworks.sqlite4java.SQLiteException: [] unable to open database file」とかエラーを吐く。
mkdir -p docker/dynamodb
chmod 777 docker/dynamodb
ふつーに起動する。
docker compose up -d
とりあえずawsコマンドでつないでみる。
aws dynamodb list-tables --profile limited-chat-dev --endpoint-url http://localhost:8000
結果が返ってきたらとりあえずOKだろう。
テーブルは自分で用意してやらないといけないのでこんな感じでCLIで用意した。
aws dynamodb create-table \
--table-name ChatTable \
--attribute-definitions AttributeName=uuid,AttributeType=S \
--key-schema AttributeName=uuid,KeyType=HASH \
--provisioned-throughput ReadCapacityUnits=25,WriteCapacityUnits=25 \
--profile limited-chat-dev \
--endpoint-url http://localhost:8000
SAMのlocal invokeからつなぎたい
この辺りを参考にして環境変数で切り替えられるようにする。
AWS SAM+DynamoDBのDockerローカル開発環境を作る - Zenn
まくろぐ - DynamoDB を Node.js で操作する(SDK ver.3 の場合)
まずはアプリ側でDynamoDBへ接続している個所を環境変数で切り替えられるようにする。
const config: DynamoDBClientConfig = {
endpoint: process.env.DYNAMODB_ENDPOINT,
};
const client = new DynamoDBClient(process.env.DYNAMODB_ENDPOINT ? config : {});
template.yamlも同じように変える。
# 環境変数用に用意
Parameters:
Secret:
Type: String
DynamoDbEndpoint:
Type: String
【中略】
PostMessageFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: post-message/
Handler: app.lambdaHandler
Runtime: nodejs20.x
Policies: AmazonDynamoDBFullAccess
Architectures:
- x86_64
Environment:
Variables:
DYNAMODB_ENDPOINT: !Ref DynamoDbEndpoint
参考サイトだとsam localで動く時には環境変数のAWS_SAM_LOCAL
がTrueになるそうなので、その辺を使ってもよさそう。
で、以下のように実行する。
sam local invoke ListMessagesFunction --parameter-overrides DynamoDbEndpoint="http://dynamodb-local:8000" --docker-network="limited-chat-app_default"
DynamoDbEndpointは環境変数に渡すURLだけど、これはdocker-composeで用意したホスト名で「dynamodb-local」を渡した。
--docker-networkにもdocker-composeで用意したネットワークをそのまま渡している。(CLIで表示されたもの)
sam local invoke
もdockerで動くっぽいのでlocalhostとか指定しても動かない。
環境変数はこんな感じでファイルで定義もできるらしい。
ローカルでの Lambda 関数の呼び出し - AWS
あとはAPI Gatewayとかもエミュレートできるっぽいので試す。
sam local start-api --parameter-overrides DynamoDbEndpoint="http://dynamodb-local:8000" --docker-network="limited-chat-app_default"
これでサーバーが動くので、あとはcurlとかで試せる。
curl -X POST http://localhost:3000/postMessage/ -H "Content-Type: application/json" -d '{ "message" : "Hello, sam local" }'
curl http://localhost:3000/listMessages/ | jq '.posts[] | .createdAt + " " + .message'
sam deploy
するときにはこんな感じにするのでOKだった。
sam deploy --profile limited-chat-dev --parameter-overrides='Secret="THIS_IS_A_SECRET" DynamoDbEndpoint=""'
他にも色々できそうなので、あとは必要に応じて。
sam local を使用する - AWS
テストを書く
ここまで書いてきて今さらなんだけどテストが欲しい。
一応公式がこういうページを出しているので結合テストにsam localを使うのは想定されているらしい。
自動化されたテストとの統合
E2Eのテストは上記までで作った環境で作れそうな気がする。こんな感じかな。
- docker compose upでDynamoDB Localを立ち上げる
- sam local start-api
- curlとかhttpのライブラリで実行
- なんやかんや結果を見る
何かいい情報ないのかと思ったら公式があった。
サーバーレス関数とアプリケーションのテスト - AWS
リンク先にTypescriptのテストサンプルもあった。
aws-samples/serverless-test-samples | TypeScript Test Samples - GitHub
API Gateway + Lambda + DynamoDBというまさにそのままのパターンのコードがあったので見てみる。
Unitテストの方はaws-sdk-client-mockを使ってテストしている。
テストのパターンとしてもSAMのQuickStartで作成されたものと同じで、aws-lambdaライブラリのAPIGatewayProxyEventとAPIGatewayProxyResultを関数ハンドラに渡して結果を見るというものだ。
integrationテストの方はもっと直接的で、DynamoDBへつないでデータを投入して、Axiosで直接APIを叩いて試している。
どうやってDynamoDBの接続情報を取得しているのか疑問だったけど、ライブラリは現在の環境のAWSプロファイルを使うらしい。
環境変数AWS_PROFILE
を使って明示してやればうまいこと動作しそう。
というわけで簡単なテストを用意してみる。
Unitテスト
postMessageをテストしてみる。
とりあえずmockライブラリを導入.
cd post-message
npm install -D aws-sdk-client-mock
簡単なテストコードを用意する。
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { lambdaHandler } from '../../app';
import { expect, describe, it } from '@jest/globals';
import { mockClient } from "aws-sdk-client-mock";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { PutItemCommand, DynamoDBClient } from "@aws-sdk/client-dynamodb";
// 元々あったeventのJSON
const postMessageEvent: APIGatewayProxyEvent = {
httpMethod: 'post',
body: '{ "message": "test message" }',
【略】
};
const ddbMock = mockClient(DynamoDBClient);
beforeEach(() => {
ddbMock.reset();
});
describe('Post message API Tests', function () {
it('valid parameter post', async () => {
process.env.DYNAMODB_TABLE_NAME = 'unit_test_dynamodb_table';
ddbMock.onAnyCommand().resolves({}); // 全Command に対して定義
ddbMock.on(PutItemCommand).resolves({
'$metadata': {
httpStatusCode: 200,
requestId: 'e230fba2-732b-4f44-a0f2-ddca742f63fe',
extendedRequestId: undefined,
cfId: undefined,
attempts: 1,
totalRetryDelay: 0
},
Attributes: undefined,
ConsumedCapacity: undefined,
ItemCollectionMetrics: undefined
});
const expectMessage = "TEST_MESSAGE";
postMessageEvent.body = JSON.stringify({ message: expectMessage });
const result: APIGatewayProxyResult = await lambdaHandler(postMessageEvent);
expect(result.statusCode).toEqual(200);
const resultObj = JSON.parse(result.body);
expect(resultObj.message).toEqual('success');
expect(resultObj.postMessage).toEqual(expectMessage);
expect(resultObj.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i); // UUID
});
});
実行
npm run test
結構簡単に試せる。
ただ、mockを作るオブジェクトを間違えて1時間以上ハマった。
// サンプルコードの例
const ddbMock = mockClient(DynamoDBDocumentClient);
// 実際に試した例
const ddbMock = mockClient(DynamoDBClient);
あと、eventsディレクトリが最上位階層にあったのに気付かなかった。
ここにeventを書いておいてテストからは読み込んで使う方法の方がよいのかもしれない。
Integrationテスト
基本的に直接APIを使ってクラウド側の動作確認をするだけなので、jsのfetchやawsのライブラリの知識さえあれば書ける。
ここのサンプルの通りunitテストとintegrationテストで実行を分けれるようにしておいた方が良いと思う。
雑だけどこんな感じで書いて
import { GetItemCommand, DynamoDBClient } from "@aws-sdk/client-dynamodb";
describe('Post message API integration tests', () => {
let baseApiUrl: string;
beforeAll(() => {
if (!process.env.API_URL) {
throw new Error('API_URL environment variable is not set.');
}
baseApiUrl = process.env.API_URL;
});
describe('Post valid message', () => {
it('post 1 char message', async () => {
const data = {
message: '1',
};
const url = `${baseApiUrl}/postMessage`;
// Postリクエストしてみてレスポンスを確認する
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const body = await res.json();
expect(body.message).toBe('success');
expect(body.postMessage).toBe('1');
const id = body.id;
// DynamoDBに問い合わせて確認する
const client = new DynamoDBClient({});
const command = new GetItemCommand({
TableName: 'ChatTable',
Key: {
uuid: { S: id },
},
});
const dynamoRes = await client.send(command);
expect(dynamoRes?.Item?.uuid?.S).toBe(id);
});
});
});
実行時にプロファイルやテスト環境のURLを指定して動かす感じにした。
AWS_PROFILE="limited-chat-dev" API_URL="xxxxxxx/Prod" npm run test
その他
API Gatewayでstageが作成されるバグの回避
バグらしい。たしかにApiの個所を追記するとstageが消えた。
Globals:
Function:
Timeout: 3
Api:
OpenApiVersion: 3.0.2
AWS SAMで「Stage」ステージが作られるバグを回避する
テンプレートファイルの検証
以下のコマンドでtemplate.yamlを検証できる。
sam validate