Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Terraformと AWS Lambdaで WebSocket通信

Terraformと AWS Lambdaで WebSocket通信

おがどら

March 10, 2024
Tweet

Other Decks in Programming

Transcript

  1. ご覧のような*アプリを作成しました • フロントエンド、バックエンド、Chrome拡張の3部分からなる • Next.js (フロントエンド。メッセージ送信を行う) • Go (バックエンド。WebSocketサーバ) •

    React (Chrome拡張。スライドにメッセージを表示する) • フロントエンドでコメントをすると、Chrome拡張に表示される * QRコードを読み取るとチャットアプリライクなUIが表示され、 そこでテキストを送信するとスライドにメッセージが流れるアプリ。
  2. ogadra (おがどら) TypeScript / Python / Go / Terraform 大学1年の頃,

    スクレイピングをしたところどっぷりハマ り, それ以来プログラミングに勤しんでいる . 最近の趣味はCiv6, AWS, ネットワーク. M3 MacBook Air 欲しい. 自己紹介
  3. WebSocketサーバを建てるには (API GW) resource "aws_apigatewayv2_api" "websocket_api" { name = "ws-samidare"

    protocol_type = "WEBSOCKET" route_selection_expression = "$request.body.action" } resource "aws_apigatewayv2_route" "connect" { api_id = aws_apigatewayv2_api.websocket_api.id route_key = "$connect" target = "integrations/${aws_apigatewayv2_integration.connect.id}" } resource "aws_apigatewayv2_integration" "connect" { api_id = aws_apigatewayv2_api.websocket_api.id integration_uri = aws_lambda_function.connect.invoke_arn }
  4. WebSocketサーバを建てるには (API GW) resource "aws_apigatewayv2_api" "websocket_api" { name = "ws-samidare"

    protocol_type = "WEBSOCKET" route_selection_expression = "$request.body.action" } resource "aws_apigatewayv2_route" "connect" { api_id = aws_apigatewayv2_api.websocket_api.id route_key = "$connect" target = "integrations/${aws_apigatewayv2_integration.connect.id}" } resource "aws_apigatewayv2_integration" "connect" { api_id = aws_apigatewayv2_api.websocket_api.id integration_uri = aws_lambda_function.connect.invoke_arn } WebSocket 呼び出す関数 ルートキー($connect, $disconnect, message)
  5. WebSocketサーバを建てるには (Lambda) resource "aws_lambda_function" "connect" { package_type = "Image" image_uri

    = "${aws_ecr_repository.ogadra_websocket.repository_url}:latest" image_config { command = [] entry_point = [ "/functions/connection", ] } depends_on = [ aws_iam_role_policy_attachment.dynamodb, ] }
  6. WebSocketサーバを建てるには (Lambda) resource "aws_lambda_function" "connect" { package_type = "Image" image_uri

    = "${aws_ecr_repository.ogadra_websocket.repository_url}:latest" image_config { command = [] entry_point = [ "/functions/connection", ] } depends_on = [ aws_iam_role_policy_attachment.dynamodb, ] } entry_pointを分けることで 同一イメージを使いまわした DynamoDBに対する権限をロールに付与
  7. WebSocketサーバを建てるには (DynamoDB) resource "aws_dynamodb_table" "connections" { name = "samidare-wss-connections" hash_key

    = "roomId" range_key = "connectionId" global_secondary_index { name = "connectionId-index" hash_key = "connectionId" } }
  8. WebSocketサーバを建てるには (DynamoDB) resource "aws_dynamodb_table" "connections" { name = "samidare-wss-connections" hash_key

    = "roomId" range_key = "connectionId" global_secondary_index { name = "connectionId-index" hash_key = "connectionId" } } connectionIdから逆引きできるようにする roomIdでレコードをまとめて取ってくるため ※分かりづらいですが、 hash_keyがパーティションキー、 range_keyがソートキーのことです。
  9. WebSocketサーバを建てるには (DynamoDB) パーティションキー roomId ソートキー connectionId hoge foo hoge bar

    hoge baz hoge qux fuga foobar fuga foobaz piyo fooqux piyo barfoo  パーティションキーがroomId, ソートキーが connectionIdのテーブルを作成する。 →roomId, connectionIdの複合プライマリーキー  GSI(グローバルセカンダリインデックス)に connectionIdを指定し、connectionIdでも検索できるよ うにしておく。
  10. WebSocketサーバを建てるには (DynamoDB) パーティションキー roomId ソートキー connectionId hoge foo hoge bar

    hoge baz hoge qux fuga foobar fuga foobaz piyo fooqux piyo barfoo  接続時にはroomIdとconnectionIdを指定し、その情報 をDynamoDBに保存する。 接続時の処理
  11. Lambda関数 (Connection) func Connection( ctx context.Context, req *events.APIGatewayWebsocketProxyRequest ) (Response,

    error) { data, _ := attributevalue.MarshalMap(ConnectionRecord{ roomId: req.QueryStringParameters["roomId"], ConnectionId: req.RequestContext.ConnectionID, }) db.PutItem(context.TODO(), &dynamodb.PutItemInput{ TableName: aws.String(os.Getenv("TABLE_NAME")), Item: data, }) return generateResponse( fmt.Sprintf("connected to %s", req.QueryStringParameters["roomId"])), nil }
  12. Lambda関数 (Connection) func Connection( ctx context.Context, req *events.APIGatewayWebsocketProxyRequest ) (Response,

    error) { data, _ := attributevalue.MarshalMap(ConnectionRecord{ roomId: req.QueryStringParameters["roomId"], ConnectionId: req.RequestContext.ConnectionID, }) db.PutItem(context.TODO(), &dynamodb.PutItemInput{ TableName: aws.String(os.Getenv("TABLE_NAME")), Item: data, }) return generateResponse( fmt.Sprintf("connected to %s", req.QueryStringParameters["roomId"])), nil } クエリパラメータで roomIdを指定 (メッセージには含めない) roomIdとconnectionIdを Dynamoに保存
  13. WebSocketサーバを建てるには (DynamoDB) パーティションキー roomId ソートキー connectionId hoge foo hoge bar

    hoge baz hoge qux fuga foobar fuga foobaz piyo fooqux piyo barfoo  connectionIdのみをリクエストに含める。  そのままではメッセージを送信する先のconnectionId がわからないので、GSIを用いてconnectionIdが含まれ るroomIdを特定する。  例えば、リクエスト元のconnectionIdが”foo”の場合 は、roomIdが”hoge”のセッションにメッセージを送れば 良い。 メッセージ受信時の処理①
  14. Lambda関数 (Messaging) webhookMessage := ReceiveWebhookMessage{} json.Unmarshal([]byte(req.Body), &webhookMessage) filter := expression.Name("connectionId").

    Equal(expression.Value(req.RequestContext.ConnectionID)) expr, _ := expression.NewBuilder().WithFilter(filter).Build() // GSIを使ってPrimary Keyを指定し, RoomIDを取得する out, err := db.Query(context.TODO(), &dynamodb.QueryInput{ TableName: aws.String(os.Getenv("TABLE_NAME")), IndexName: aws.String(os.Getenv("DB_GSI_NAME")), ExpressionAttributeNames: expr.Names(), ExpressionAttributeValues: expr.Values(), KeyConditionExpression: expr.Filter(), })
  15. Lambda関数 (Messaging) webhookMessage := ReceiveWebhookMessage{} json.Unmarshal([]byte(req.Body), &webhookMessage) filter := expression.Name("connectionId").

    Equal(expression.Value(req.RequestContext.ConnectionID)) expr, _ := expression.NewBuilder().WithFilter(filter).Build() // GSIを使ってPrimary Keyを指定し, RoomIDを取得する out, err := db.Query(context.TODO(), &dynamodb.QueryInput{ TableName: aws.String(os.Getenv("TABLE_NAME")), IndexName: aws.String(os.Getenv("DB_GSI_NAME")), ExpressionAttributeNames: expr.Names(), ExpressionAttributeValues: expr.Values(), KeyConditionExpression: expr.Filter(), }) メッセージ時にはroomIdを 含めていないため、 DynamoDBから取得
  16. WebSocketサーバを建てるには (DynamoDB) パーティションキー roomId ソートキー connectionId hoge foo hoge bar

    hoge baz hoge qux fuga foobar fuga foobaz piyo fooqux piyo barfoo  送信すべき対象のroomIdを特定出来たら、その roomIdに紐づけられているconnectionId全てに対して メッセージを送信する。  このとき、メッセージを送信できなかったconnectionId はDynamoDBからレコードを削除する。(切断扱い。) メッセージ受信時の処理②
  17. Lambda関数 (Messaging) // RoomIDを使って, ルーム内のConnectionIDを取得する out, err = db.Query(context.TODO(), &dynamodb.QueryInput{

    TableName: aws.String(os.Getenv("TABLE_NAME")), ExpressionAttributeNames: expr.Names(), ExpressionAttributeValues: expr.Values(), KeyConditionExpression: expr.Filter(), }) // ルーム内のConnectionIDに対して, メッセージを送信する for _, item := range out.Items { _, sendErr := svc.PostToConnection( ctx, &apigatewaymanagementapi.PostToConnectionInput{ ConnectionId: &record.ConnectionId, Data: responseMessage, })
  18. Lambda関数 (Messaging) // RoomIDを使って, ルーム内のConnectionIDを取得する out, err = db.Query(context.TODO(), &dynamodb.QueryInput{

    TableName: aws.String(os.Getenv("TABLE_NAME")), ExpressionAttributeNames: expr.Names(), ExpressionAttributeValues: expr.Values(), KeyConditionExpression: expr.Filter(), }) // ルーム内のConnectionIDに対して, メッセージを送信する for _, item := range out.Items { _, sendErr := svc.PostToConnection( ctx, &apigatewaymanagementapi.PostToConnectionInput{ ConnectionId: &record.ConnectionId, Data: responseMessage, }) 取得した接続情報全てに対し、 For文でメッセージを送信
  19. Lambda関数 (Messaging) if sendErr != nil { // 送信に失敗した場合は, DynamoDBからレコードを削除する(切断扱い)

    targetKey := map[string]string{ "roomId": record.roomId, "userId": record.UserId, } key, _ := attributevalue.MarshalMap(targetKey) db.DeleteItem(context.TODO(), &dynamodb.DeleteItemInput{ TableName: aws.String(os.Getenv("TABLE_NAME")), Key: key, }) }
  20. Lambda関数 (Messaging) if sendErr != nil { // 送信に失敗した場合は, DynamoDBからレコードを削除する(切断扱い)

    targetKey := map[string]string{ "roomId": record.roomId, "userId": record.UserId, } key, _ := attributevalue.MarshalMap(targetKey) db.DeleteItem(context.TODO(), &dynamodb.DeleteItemInput{ TableName: aws.String(os.Getenv("TABLE_NAME")), Key: key, }) } 通信に失敗したレコードを DynamoDBから削除
  21. WebSocketサーバを建てるには (DynamoDB) パーティションキー roomId ソートキー connectionId hoge foo hoge bar

    hoge baz hoge qux fuga foobar fuga foobaz piyo fooqux piyo barfoo  connectionIdのみをリクエストに含める。  メッセージ受信時と同様に、GSIを用いてパーティション キーを特定し、プライマリーキーでレコードを削除する。 切断時の処理
  22. Lambda関数 (Disconnection) selectedKeys := ConnectionRecord{} targetKey := map[string]string{ "roomId": selectedKeys.roomId,

    "userId": selectedKeys.UserId, } key, _ := attributevalue.MarshalMap(targetKey) db.DeleteItem(context.TODO(), &dynamodb.DeleteItemInput{ TableName: aws.String(os.Getenv("TABLE_NAME")), Key: key, }) return generateResponse("disconnected"), nil プライマリーキーを指定 (パーティションキー + ソートキー)
  23. ディレクトリ構成 . ├── Dockerfile ├── functions │ ├── connection │

    │ ├── functions.go -> ../../functions.go │ │ └── main.go │ ├── disconnection │ │ ├── functions.go -> ../../functions.go │ │ └── main.go │ └── messaging │ ├── functions.go -> ../../functions.go │ └── main.go ├── functions.go ├── go.mod └── go.sum
  24. ディレクトリ構成 . ├── Dockerfile ├── functions │ ├── connection │

    │ ├── functions.go -> ../../functions.go │ │ └── main.go │ ├── disconnection │ │ ├── functions.go -> ../../functions.go │ │ └── main.go │ └── messaging │ ├── functions.go -> ../../functions.go │ └── main.go ├── functions.go ├── go.mod └── go.sum connectionのENTRYPOINT disconnectionのENTRYPOINT messagingのENTRYPOINT シンボリックリンクを用いて 同一ファイルで複数のLambda関数を作成