はじめに
チュートリアル: WebSocket API、Lambda、DynamoDB を使用したサーバーレスチャットアプリケーションの構築
上記を使って、WebSocketを使ったシステムをAPI Gateway、SAM、DynamoDB、Lambdaで構築する基礎を学びたいと思います。
上記資料では、CFnテンプレートが用意されているものの、API Gatewayはコンソールで作成されているので、以下の別サンプルコードも参照し、自分が理解しやすいテンプレートに作り替えます。
CFnテンプレートをSAMテンプレートに書き換える - DynamoDB
まずは一番シンプルなDynamoDBテーブル(ConnectionsTable8000B8A1)のみ移植します。
template.yaml
を作成します。
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Sample SAM Template for WebSocket
Resources:
ConnectionsTable8000B8A1:
Type: AWS::DynamoDB::Table
Properties:
KeySchema:
- AttributeName: connectionId
KeyType: HASH
AttributeDefinitions:
- AttributeName: connectionId
AttributeType: S
BillingMode: PAY_PER_REQUEST
UpdateReplacePolicy: Delete
DeletionPolicy: Delete
変更部分は以下を削除し
ProvisionedThroughput:
ReadCapacityUnits: 5
WriteCapacityUnits: 5
以下を追加しています。
BillingMode: PAY_PER_REQUEST
※ CloudWatchなど余計なリソースが作成されて、料金発生すると嫌なので
sam validate --lint
でチェックし問題なければdeployしてみます。
初回なので
sam deploy --guided
入力内容は
Stack Name [sam-app]: sam-websocket
AWS Region [us-east-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]:
Save arguments to configuration file [Y/n]:
SAM configuration file [samconfig.toml]:
SAM configuration environment [default]:
CFnテンプレートをSAMテンプレートに書き換える - Lambda(ConnectHandler)
次にLambda関数(ConnectHandler2FFD52D8)と関連するポリシー、ロールを追加します。
主な変更は
Typeを
Type: AWS::Lambda::Function
から
Type: AWS::Serverless::Function
にしています。
これはCFnとSAMの違いによるものです。
Runtimeは
Runtime: nodejs20.x
にしています。
またCodeは読みにくいのでテンプレートからファイルに独立させます。
Code:
ZipFile: |-
[以下略]
を削除し
CodeUri: connect/
とします。
Code
はCFn、CodeUri
はSAMの記述方法という違いもあります。
削除したコードは
connectディレクトリを作成し
index.jsに記載します。
connectディレクトリ内では
npm install aws-sdk
もしておきます。
aws-sdkは自力でinstallしなくても良い気がしたのですが、後ほどテスト時にNGだっとのinstallしています。
const AWS = require('aws-sdk');
const ddb = new AWS.DynamoDB.DocumentClient();
exports.handler = async function (event, context) {
try {
await ddb
.put({
TableName: process.env.table,
Item: {
connectionId: event.requestContext.connectionId,
},
})
.promise();
} catch (err) {
return {
statusCode: 500,
};
}
return {
statusCode: 200,
};
};
また
DependsOn:
- ConnectHandlerServiceRoleDefaultPolicy7DE94863
- ConnectHandlerServiceRole7E4A9B1F
は
sam validate --lint
すると警告が出ます。
W3005 Obsolete DependsOn on resource (ConnectHandlerServiceRole7E4A9B1F), dependency already enforced by a "Fn:GetAtt" at Resources/ConnectHandler2FFD52D8/Properties/Role/Fn::GetAtt
Fn::GetAtt
によって依存関係が示されているため不要とのことなので削除します。
これで警告が消えます。
PolicyやRoleは特に変更が不要だったのでそのまま移植しました。
一旦ここまででデプロイします。
sam deploy
CFnテンプレートをSAMテンプレートに書き換える - Lambda(残り部分)
上記と同様の手順で
- DisconnectHandlerCB7ED6F7
- SendMessageHandlerDCEABF13
- DefaultHandler604DF7AC
関連のリソースを移植します。
sam validate --lint
で問題がなければ
sam deploy
します。
ドキュメント上ではコンソールで作成される API Gateway + 関連リソースをSAMテンプレート化
冒頭にも書きましたが、今回参照したチュートリアルは API Gatewayをコンソールで作成しているので
以下の別のサンプルコードを元にテンプレートを作成しました。
https://github.com/aws-samples/simple-websockets-chat-app/blob/master/template.yaml
これでテンプレート作成は完了なので全体を掲載しておきます
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Sample SAM Template for WebSocket
Resources:
ConnectionsTable8000B8A1:
Type: AWS::DynamoDB::Table
Properties:
KeySchema:
- AttributeName: connectionId
KeyType: HASH
AttributeDefinitions:
- AttributeName: connectionId
AttributeType: S
BillingMode: PAY_PER_REQUEST
UpdateReplacePolicy: Delete
DeletionPolicy: Delete
ConnectHandlerServiceRole7E4A9B1F:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Action: sts:AssumeRole
Effect: Allow
Principal:
Service: lambda.amazonaws.com
Version: "2012-10-17"
ManagedPolicyArns:
- Fn::Join:
- ""
- - "arn:"
- Ref: AWS::Partition
- :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
ConnectHandlerServiceRoleDefaultPolicy7DE94863:
Type: AWS::IAM::Policy
Properties:
PolicyDocument:
Statement:
- Action:
- dynamodb:BatchWriteItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:DescribeTable
Effect: Allow
Resource:
- Fn::GetAtt:
- ConnectionsTable8000B8A1
- Arn
- Ref: AWS::NoValue
Version: "2012-10-17"
PolicyName: ConnectHandlerServiceRoleDefaultPolicy7DE94863
Roles:
- Ref: ConnectHandlerServiceRole7E4A9B1F
ConnectHandler2FFD52D8:
Type: AWS::Serverless::Function
Properties:
CodeUri: connect/
Role:
Fn::GetAtt:
- ConnectHandlerServiceRole7E4A9B1F
- Arn
Environment:
Variables:
table:
Ref: ConnectionsTable8000B8A1
Handler: index.handler
Runtime: nodejs20.x
DisconnectHandlerServiceRoleE54F14F9:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Action: sts:AssumeRole
Effect: Allow
Principal:
Service: lambda.amazonaws.com
Version: "2012-10-17"
ManagedPolicyArns:
- Fn::Join:
- ""
- - "arn:"
- Ref: AWS::Partition
- :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
DisconnectHandlerServiceRoleDefaultPolicy1800B9E5:
Type: AWS::IAM::Policy
Properties:
PolicyDocument:
Statement:
- Action:
- dynamodb:BatchWriteItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:DescribeTable
Effect: Allow
Resource:
- Fn::GetAtt:
- ConnectionsTable8000B8A1
- Arn
- Ref: AWS::NoValue
Version: "2012-10-17"
PolicyName: DisconnectHandlerServiceRoleDefaultPolicy1800B9E5
Roles:
- Ref: DisconnectHandlerServiceRoleE54F14F9
DisconnectHandlerCB7ED6F7:
Type: AWS::Serverless::Function
Properties:
CodeUri: disconnect/
Role:
Fn::GetAtt:
- DisconnectHandlerServiceRoleE54F14F9
- Arn
Environment:
Variables:
table:
Ref: ConnectionsTable8000B8A1
Handler: index.handler
Runtime: nodejs20.x
SendMessageHandlerServiceRole5F523417:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Action: sts:AssumeRole
Effect: Allow
Principal:
Service: lambda.amazonaws.com
Version: "2012-10-17"
ManagedPolicyArns:
- Fn::Join:
- ""
- - "arn:"
- Ref: AWS::Partition
- :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
SendMessageHandlerServiceRoleDefaultPolicyF9D10585:
Type: AWS::IAM::Policy
Properties:
PolicyDocument:
Statement:
- Action:
- dynamodb:BatchGetItem
- dynamodb:GetRecords
- dynamodb:GetShardIterator
- dynamodb:Query
- dynamodb:GetItem
- dynamodb:Scan
- dynamodb:ConditionCheckItem
- dynamodb:DescribeTable
Effect: Allow
Resource:
- Fn::GetAtt:
- ConnectionsTable8000B8A1
- Arn
- Ref: AWS::NoValue
Version: "2012-10-17"
PolicyName: SendMessageHandlerServiceRoleDefaultPolicyF9D10585
Roles:
- Ref: SendMessageHandlerServiceRole5F523417
SendMessageHandlerDCEABF13:
Type: AWS::Serverless::Function
Properties:
CodeUri: send-message/
Role:
Fn::GetAtt:
- SendMessageHandlerServiceRole5F523417
- Arn
Environment:
Variables:
table:
Ref: ConnectionsTable8000B8A1
Handler: index.handler
Runtime: nodejs20.x
DefaultHandlerServiceRoleDF00569C:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Action: sts:AssumeRole
Effect: Allow
Principal:
Service: lambda.amazonaws.com
Version: "2012-10-17"
ManagedPolicyArns:
- Fn::Join:
- ""
- - "arn:"
- Ref: AWS::Partition
- :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
DefaultHandlerServiceRoleDefaultPolicy2F57C32F:
Type: AWS::IAM::Policy
Properties:
PolicyDocument:
Statement:
- Action: execute-api:ManageConnections
Effect: Allow
Resource:
Fn::Join:
- ""
- - "arn:aws:execute-api:"
- Ref: AWS::Region
- ":"
- Ref: AWS::AccountId
- ":"
- "*/*/POST/@connections/*"
- Action: execute-api:ManageConnections
Effect: Allow
Resource:
Fn::Join:
- ""
- - "arn:aws:execute-api:"
- Ref: AWS::Region
- ":"
- Ref: AWS::AccountId
- ":"
- "*/*/GET/@connections/*"
Version: "2012-10-17"
PolicyName: DefaultHandlerServiceRoleDefaultPolicy2F57C32F
Roles:
- Ref: DefaultHandlerServiceRoleDF00569C
DefaultHandler604DF7AC:
Type: AWS::Serverless::Function
Properties:
CodeUri: default/
Role:
Fn::GetAtt:
- DefaultHandlerServiceRoleDF00569C
- Arn
Handler: index.handler
Runtime: nodejs20.x
manageConnections7F91357B:
Type: AWS::IAM::Policy
Properties:
PolicyDocument:
Statement:
- Action: execute-api:ManageConnections
Effect: Allow
Resource:
Fn::Join:
- ""
- - "arn:aws:execute-api:"
- Ref: AWS::Region
- ":"
- Ref: AWS::AccountId
- ":"
- "*/*/POST/@connections/*"
Version: "2012-10-17"
PolicyName: manageConnections7F91357B
Roles:
- Ref: SendMessageHandlerServiceRole5F523417
WebsocketChatAppTutorial:
Type: AWS::ApiGatewayV2::Api
Properties:
Name: websocket-chat-app-tutorial
ProtocolType: WEBSOCKET
RouteSelectionExpression: "$request.body.action"
ConnectRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref WebsocketChatAppTutorial
RouteKey: $connect
AuthorizationType: NONE
OperationName: ConnectRoute
Target: !Join
- "/"
- - "integrations"
- !Ref ConnectInteg
ConnectInteg:
Type: AWS::ApiGatewayV2::Integration
Properties:
ApiId: !Ref WebsocketChatAppTutorial
Description: Connect Integration
IntegrationType: AWS_PROXY
IntegrationUri:
Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ConnectHandler2FFD52D8.Arn}/invocations
ConnectPermission:
Type: AWS::Lambda::Permission
DependsOn:
- WebsocketChatAppTutorial
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref ConnectHandler2FFD52D8
Principal: apigateway.amazonaws.com
DisconnectRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref WebsocketChatAppTutorial
RouteKey: $disconnect
AuthorizationType: NONE
OperationName: DisconnectRoute
Target: !Join
- "/"
- - "integrations"
- !Ref DisconnectInteg
DisconnectInteg:
Type: AWS::ApiGatewayV2::Integration
Properties:
ApiId: !Ref WebsocketChatAppTutorial
Description: Disconnect Integration
IntegrationType: AWS_PROXY
IntegrationUri:
Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${DisconnectHandlerCB7ED6F7.Arn}/invocations
DisconnectPermission:
Type: AWS::Lambda::Permission
DependsOn:
- WebsocketChatAppTutorial
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref DisconnectHandlerCB7ED6F7
Principal: apigateway.amazonaws.com
SendRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref WebsocketChatAppTutorial
RouteKey: sendmessage
AuthorizationType: NONE
OperationName: SendRoute
Target: !Join
- "/"
- - "integrations"
- !Ref SendInteg
SendInteg:
Type: AWS::ApiGatewayV2::Integration
Properties:
ApiId: !Ref WebsocketChatAppTutorial
Description: Send Integration
IntegrationType: AWS_PROXY
IntegrationUri:
Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${SendMessageHandlerDCEABF13.Arn}/invocations
SendMessagePermission:
Type: AWS::Lambda::Permission
DependsOn:
- WebsocketChatAppTutorial
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref SendMessageHandlerDCEABF13
Principal: apigateway.amazonaws.com
DefaultRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref WebsocketChatAppTutorial
RouteKey: $default
AuthorizationType: NONE
OperationName: DefaultRoute
Target: !Join
- "/"
- - "integrations"
- !Ref DefaultInteg
DefaultInteg:
Type: AWS::ApiGatewayV2::Integration
Properties:
ApiId: !Ref WebsocketChatAppTutorial
Description: Default Integration
IntegrationType: AWS_PROXY
IntegrationUri:
Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${DefaultHandler604DF7AC.Arn}/invocations
DefaultPermission:
Type: AWS::Lambda::Permission
DependsOn:
- WebsocketChatAppTutorial
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref DefaultHandler604DF7AC
Principal: apigateway.amazonaws.com
Deployment:
Type: AWS::ApiGatewayV2::Deployment
DependsOn:
- ConnectRoute
- SendRoute
- DisconnectRoute
- DefaultRoute
Properties:
ApiId: !Ref WebsocketChatAppTutorial
Stage:
Type: AWS::ApiGatewayV2::Stage
Properties:
StageName: production
Description: Prod Stage
DeploymentId: !Ref Deployment
ApiId: !Ref WebsocketChatAppTutorial
Outputs:
WebSocketURI:
Description: "The WSS Protocol URI to connect to"
Value:
!Join [
"",
[
"wss://",
!Ref WebsocketChatAppTutorial,
".execute-api.",
!Ref "AWS::Region",
".amazonaws.com/",
!Ref "Stage",
],
]
API Gateway部分は、結構試行錯誤があり、必ずしもチュートリアルの再現にはなっていません。
一応、sam delete
でスタックを削除してから再度作成したもので動作確認していますが不備があればご指摘いただければ幸いです。
テスト
wscat を使用した WebSocket API への接続とメッセージの送信
上記も参照にしつつ、wscatを使いチュートリアルに記載されているテストを一通り実行します。
最後に
冒頭にも書きましたが、上記は本番での動作を想定してない学習用のコードです。
API Gateway + WebSocketの理解が浅いため間違いや改善点が多いかと思いますが、よろしければコメント欄にてご指摘いただけると大変助かります。
実際にやり切れるかはまだ未知数ですが、以後このプロトタイプを足がかりに、Discord Gateway API を Serverless実装で使用できないか試す予定です。
2024.06.17修正
→ API GatewayはWebSocketのクライアント側でサーバーとの接続を維持するような振る舞いはしないようで、DiscordのGateway APIを試すのは見送りました。
その過程で、今回のコードも可能な限り間違いや改善可能な点は修正できればと思っています。
参考までに、 Gateway API なしのシステム構成は以下にありますので、ご興味ありましたらご一読ください。
DiscordアプリをAWS SAMで作った話(概要・システム構成編)