今日は、AWS Lambda で WebSocket に挑戦する企画の続きです。
前回の記事が未読の場合は、先にそちらをお読みいただければ幸いです。
前回がんばった分、今日はだいぶ構築がラクになっていますので、ぜひ!
■ 本日のお品書き
準備運動
本日作るもの
WebSocket で何をするにせよ、少なくとも2つの Lambda が必要なのでした。
- 接続: コネクションが確立したら、そのクライアントを DynamoDB に格納する
- 切断: コネクションが切れたら、そのクライアントを DynamoDB から削除する
これらの API のエンドポイントは1つですが、ルートによって、実行される Lambda が振り分けられるのでした。
図にすると以下の通りです。
今日はこれらのうち、切断時の Lambda の方を作り込んでいきます。
ただし、前回のようにイチから作るのではなく、ある程度できている SAM テンプレートを流用できそうです。
リソースのグループ分け
前回、SAM テンプレートでいくつかのリソースを構築しました。
これらを改めて見てみると、実はリソースの役割ごとに大きく3つにグループ分けできます。
- AWS::ApiGatewayV2::Api (SimpleChatWebSocket)
- AWS::ApiGatewayV2::Stage (Stage)
- AWS::ApiGatewayV2::Route (ConnectRoute)
- AWS::ApiGatewayV2::Integration (ConnectInteg)
- AWS::Lambda::Permission (OnConnectPermission)
- AWS::Serverless::Function (OnConnectFunction)
- AWS::Serverless::SimpleTable (ConnectionsTable)
イメージを摑みやすくするため、前回の成果物に色をつけた図を以下に示します。
今回のゴールは、ここに青色のグループをもう一組作るイメージです。
グループごとの意味を理解すれば、今回どこに手を加えなければならないか、自ずと分かってくるでしょう。
1. 赤色グループ: API のエンドポイント URL を決めるグループ
前回も述べたとおり、API の URL は、API ID とステージで決まります(厳密には、デプロイ時に指定するリージョンも確定要素のひとつ)。
wss://{api-id}.execute-api.{region}.amazonaws.com/{stageName}
これは、template.yalm のOutputs
セクションからも読み取れます。
Outputs: WebSocketURI: Description: "The WSS Protocol URI to connect to" Value: !Sub "wss://${SimpleChatWebSocket}.execute-api.${AWS::Region}.amazonaws.com/${Stage}"
クライアントから見ると、単一のエンドポイント URL に接続してメッセージをやりとりする形になりますから、バックエンド処理がいくら増えたとしても、この赤色グループのリソースたちは初めに一度作ってしまえばあとは手を加えることがほぼないグループになります。
なので今回も(そして次回も)この赤色グループにはノータッチでいきます。
2. 青色グループ: バックエンド処理ごとに必要となるグループ
次に青色グループですが、これはざっくり言うと、Lambda 関数を追加したくなったらセットで揃えなくてはならないリソース一式になります。
リクエストを所望の Lambda に振り分けるために ルート と 統合 リソースが必要であることは前回見た通りですし、API から Lambda を実行するためのパーミッションが必要であることも学びました。
これらは全て青色グループの仲間たちですので、Lambda を追加したくなったらセットで追加する、という原則を忘れないようにしましょう。
そのため、今回も(そして次回も)メインはこの青色グループの構築ということになります。
3. 緑色グループ: その他(アプリケーションで利用するリソース)
緑色グループは、Lambda が内部的に利用するリソースです。 今のところ、コネクションIDを格納するための DynamoDB が一つ構築されているだけです。
ここは、アプリケーション要件に応じてメンテナンスをすればよい部分です。
今回は特にリソースの追加は行いません。
本日の演習
あらためて、今回の出発地点の構成図を再掲します。
ここに、青色のグループをもう一組追加したものが今回のゴールです。
前回は、初めに SAM テンプレートの編集を行いましたが、今回は Lambda の実装を先に行いましょう。
Lambda の追加
今回作る Lambda は、WebSocket 切断時に、コネクション ID を DynamoDB テーブルから削除する処理です。
初めに、切断時の Lambda を作るため、on_connect
ディレクトリを複製して、on_disconnect
にリネームします(もともとあるon_connect
ディレクトリはそのまま残します)。
次に、template.yaml を開き、DynamoDB テーブルの削除ポリシーと、環境変数からテーブル名を取得できるよう設定を追加していきましょう。
既存のテンプレートを流用する場合は、差分の修正漏れに注意してください(特に★注意★
の2ヶ所)。
Resources: # -- 略 -- + # 切断 - Lambda + OnDisconnectFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: on_disconnect # ★注意★ + Handler: app.lambda_handler + Runtime: python3.9 + Policies: + - DynamoDBCrudPolicy: # ★注意★ + TableName: !Ref ConnectionsTable + Environment: + Variables: + CONNECTIONS_TABLE: !Ref ConnectionsTable # コネクションID テーブル ConnectionsTable: Type: AWS::Serverless::SimpleTable
Lambda の実装
続いて、テーブルからコネクションID を削除する Lambda の実装です。
event
パラメータからコネクションIDを取得する方法と、DynamoDB テーブルにアクセスする方法は、前回の説明をご参照ください。
そのため、前回のコードがほぼそのまま流用できそうです。 ただ一点だけ、put_item
API ではなくdelete_item
API を使って DynamoDB を操作する点だけが違います。
import boto3 import json import os dynamodb = boto3.resource('dynamodb') def lambda_handler(event, context): # コネクションIDを取得 connection_id = event['requestContext']['connectionId'] print(f'Connection ID: {connection_id}') # テーブルを取得 table_name = os.environ['CONNECTIONS_TABLE'] connections_table = dynamodb.Table(table_name) # テーブルからコネクションIDを削除 connections_table.delete_item(Key = {'id': connection_id}) # ★注意★ return {}
$disconnect ルートと統合リソースの追加
ここからは、今作った Lambda を、API Gateway と繋いでいきましょう。
切断時に Lambda を起動したいので、作成するルートは $disconnect
になります。
これも前回のテンプレートをコピペして、差分だけ書き換えればよいです(ただし★注意★
に注意してください。見通しの悪いIntegrationUri
にも、Lambda の ARN を参照している箇所があるため特に注意が必要です)。
Resources: # -- 略 -- + # 切断 - ルート + DisconnectRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref SimpleChatWebSocket + RouteKey: $disconnect # ★注意★ + Target: !Join + - '/' + - - 'integrations' + - !Ref DisconnectInteg # ★注意★ + # 切断 - 統合リソース + DisconnectInteg: + Type: AWS::ApiGatewayV2::Integration + DependsOn: + - OnDisconnectFunction # ★注意★ + Properties: + ApiId: !Ref SimpleChatWebSocket + IntegrationType: AWS_PROXY + IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnDisconnectFunction.Arn}/invocations # ★注意★ # 切断 - Lambda OnDisconnectFunction: Type: AWS::Serverless::Function Properties: CodeUri: on_disconnect Handler: app.lambda_handler Runtime: python3.9 Policies: - DynamoDBCrudPolicy: TableName: !Ref ConnectionsTable Environment: Variables: CONNECTIONS_TABLE: !Ref ConnectionsTable # コネクションID テーブル ConnectionsTable: Type: AWS::Serverless::SimpleTable
パーミッション の追加
最後に、API Gateway から、Lambda を実行できるようにします。
Resources: # -- 略 -- # 切断 - ルート DisconnectRoute: Type: AWS::ApiGatewayV2::Route Properties: ApiId: !Ref SimpleChatWebSocket RouteKey: $disconnect Target: !Join - '/' - - 'integrations' - !Ref DisconnectInteg # 切断 - 統合リソース DisconnectInteg: Type: AWS::ApiGatewayV2::Integration DependsOn: - OnDisconnectFunction Properties: ApiId: !Ref SimpleChatWebSocket IntegrationType: AWS_PROXY IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnDisconnectFunction.Arn}/invocations + # 切断 - パーミッション + OnDisconnectPermission: + Type: AWS::Lambda::Permission + DependsOn: + - SimpleChatWebSocket + - OnDisconnectFunction # ★注意★ + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref OnDisconnectFunction # ★注意★ + Principal: apigateway.amazonaws.com # 切断 - Lambda OnDisconnectFunction: Type: AWS::Serverless::Function Properties: CodeUri: on_disconnect Handler: app.lambda_handler Runtime: python3.9 Policies: - DynamoDBCrudPolicy: TableName: !Ref ConnectionsTable Environment: Variables: CONNECTIONS_TABLE: !Ref ConnectionsTable # コネクションID テーブル ConnectionsTable: Type: AWS::Serverless::SimpleTable
動作確認
DynamoDB テーブルのゴミデータ削除
前回の演習で、DynamoDB テーブルにコネクションID が残ってしまっている場合は、それを削除する必要があります。
接続確認
sam deploy
実行後、wscat
で WebSocket のエンドポイントに接続します。
このタイミングで、接続 Lambda が実行されるはずですので、念の為に CloudWatch Logs と DynamoDB テーブルを確認してみましょう。
DynamoDB テーブルには、コネクションIDが1件登録されています。 もちろん、前回のゴミデータではなく、正真正銘、たった今実行して CloudWatch Logs に出力した、出来立てのコネクション ID です。
どうやら、接続時の Lambda はデグレード*1せず、期待どおり動いているようです。
では、切断時にこのコネクションIDが、DynamoDB テーブルから無事に削除されるかどうかを検証してみましょう。
切断確認
では、Ctrl + C で接続を切ってしまいます。
ターミナルからは、いっけん何も起きていないように見えますが、果たして Lambda は実行されているでしょうか。
CloudWatch Logs を見ると、確かに OnDisconnectFunction
が実行された形跡があります。
そして、DynamoDB テーブルからも、先ほどまでいたコネクション ID が消え去っていることが確認できます。
これで、接続 Lambda と切断 Lambda の実装と動作確認が完了しました。 お疲れ様でした。
まとめ
- WebSocket マイクロサービスのためのリソースは、便宜的に3つに色分けできる
- このうち、
- 赤色グループは API エンドポイント単位のグループ
- 青色グループは Lambda 単位のグループ
- 緑色グループは Lambda が使うリソースのグループ
- Lambda を追加したい場合は、青色グループをもう1セット構築するイメージ
次回はいよいよ、任意のメッセージを送受信する部分をフロントエンド込みで作り込んでいきます。
あれ……?
今回はちょっと短く感じる。
まぁ、前回の素地がよかったので、かなり流用が利いたということでしょう。
ところで、DynamoDB って何に使っているの?
接続 Lambda は書き込むだけ、切断 Lambda は消し込むだけで、肝心の中身はどこにも使われていないような……。
それは、次回のお楽しみということで……。
おまけ: 本日の template.yaml
AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: "" Globals: Function: Timeout: 3 Resources: SimpleChatWebSocket: Type: AWS::ApiGatewayV2::Api Properties: Name: SimpleChatWebSocket ProtocolType: WEBSOCKET RouteSelectionExpression: "$request.body.message" ConnectRoute: Type: AWS::ApiGatewayV2::Route Properties: ApiId: !Ref SimpleChatWebSocket RouteKey: $connect Target: !Join - '/' - - 'integrations' - !Ref ConnectInteg ConnectInteg: Type: AWS::ApiGatewayV2::Integration DependsOn: - OnConnectFunction Properties: ApiId: !Ref SimpleChatWebSocket IntegrationType: AWS_PROXY IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnConnectFunction.Arn}/invocations OnConnectPermission: Type: AWS::Lambda::Permission DependsOn: - SimpleChatWebSocket - OnConnectFunction Properties: Action: lambda:InvokeFunction FunctionName: !Ref OnConnectFunction Principal: apigateway.amazonaws.com DisconnectRoute: Type: AWS::ApiGatewayV2::Route Properties: ApiId: !Ref SimpleChatWebSocket RouteKey: $disconnect Target: !Join - '/' - - 'integrations' - !Ref DisconnectInteg DisconnectInteg: Type: AWS::ApiGatewayV2::Integration DependsOn: - OnDisconnectFunction Properties: ApiId: !Ref SimpleChatWebSocket IntegrationType: AWS_PROXY IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnDisconnectFunction.Arn}/invocations OnDisconnectPermission: Type: AWS::Lambda::Permission DependsOn: - SimpleChatWebSocket - OnDisconnectFunction Properties: Action: lambda:InvokeFunction FunctionName: !Ref OnDisconnectFunction Principal: apigateway.amazonaws.com OnConnectFunction: Type: AWS::Serverless::Function Properties: CodeUri: on_connect Handler: app.lambda_handler Runtime: python3.9 Policies: - DynamoDBWritePolicy: TableName: !Ref ConnectionsTable Environment: Variables: CONNECTIONS_TABLE: !Ref ConnectionsTable OnDisconnectFunction: Type: AWS::Serverless::Function Properties: CodeUri: on_disconnect Handler: app.lambda_handler Runtime: python3.9 Policies: - DynamoDBCrudPolicy: TableName: !Ref ConnectionsTable Environment: Variables: CONNECTIONS_TABLE: !Ref ConnectionsTable ConnectionsTable: Type: AWS::Serverless::SimpleTable Stage: Type: AWS::ApiGatewayV2::Stage Properties: StageName: Prod ApiId: !Ref SimpleChatWebSocket AutoDeploy: true Outputs: WebSocketURI: Description: "The WSS Protocol URI to connect to" Value: !Sub "wss://${SimpleChatWebSocket}.execute-api.${AWS::Region}.amazonaws.com/${Stage}"
*1:正常に動作していたプログラムが、別の改修の影響を受けて正しく動作しなくなること。