Slide 1

Slide 1 text

Terraformと AWS Lambdaで WebSocket通信

Slide 2

Slide 2 text

まずはこちらにアクセス! QRコードは本番デモ用になりますので、 資料には掲載しておりません。

Slide 3

Slide 3 text

ご覧のような*アプリを作成しました ● フロントエンド、バックエンド、Chrome拡張の3部分からなる ● Next.js (フロントエンド。メッセージ送信を行う) ● Go (バックエンド。WebSocketサーバ) ● React (Chrome拡張。スライドにメッセージを表示する) ● フロントエンドでコメントをすると、Chrome拡張に表示される * QRコードを読み取るとチャットアプリライクなUIが表示され、 そこでテキストを送信するとスライドにメッセージが流れるアプリ。

Slide 4

Slide 4 text

ogadra (おがどら) TypeScript / Python / Go / Terraform 大学1年の頃, スクレイピングをしたところどっぷりハマ り, それ以来プログラミングに勤しんでいる . 最近の趣味はCiv6, AWS, ネットワーク. M3 MacBook Air 欲しい. 自己紹介

Slide 5

Slide 5 text

Agenda ● システム構成図 ● 今回話すこと ● WebSocketとはなにか ● LambdaでWebSocketを使うやり方 ○ API Gateway ○ Lambda ○ DynamoDB ● 感想

Slide 6

Slide 6 text

システム構成図

Slide 7

Slide 7 text

システム構成図

Slide 8

Slide 8 text

今回話すこと・話さないこと ● WebSocketについて ● Lambdaについて ● DynamoDBについて 話すこと 話さないこと ● フロントエンドについて ● Chrome拡張について

Slide 9

Slide 9 text

プレゼンの目的 ● WebSocket通信について知る ● AWS Lambda, API Gatewayを知る ● WebSocketアプリケーションをLambdaで作れることを知る

Slide 10

Slide 10 text

そもそもWebSocket通信とは クライアント、サーバーの双方向で通信するプロトコル HTTP通信ではレスポンスという形でサーバーはメッセージを送信 → 任意のタイミングでサーバーからメッセージを送れるようになる

Slide 11

Slide 11 text

WebSocketサーバを建てるには 以下の要件を満たす必要がある 1. 接続要求に応じ、クライアントの接続情報を保持 2. 送信要求に応じ、クライアントからメッセージを受信 3. 接続情報を用い、クライアントにメッセージを送信 4. 切断要求に応じ、クライアントの接続情報を破棄

Slide 12

Slide 12 text

WebSocketサーバを建てるには 以下の要件を満たす必要がある 1. 接続要求に応じ、クライアントの接続情報を保持 2. 送信要求に応じ、クライアントからメッセージを受信 3. 接続情報を用い、クライアントにメッセージを送信 4. 切断要求に応じ、クライアントの接続情報を破棄 API Gatewayを用いて識別・対応するLambdaを発火

Slide 13

Slide 13 text

WebSocketサーバを建てるには 以下の要件を満たす必要がある 1. 接続要求に応じ、クライアントの接続情報を保持 2. 送信要求に応じ、クライアントからメッセージを受信 3. 接続情報を用い、クライアントにメッセージを送信 4. 切断要求に応じ、クライアントの接続情報を破棄 API Gatewayを用いて識別・対応するLambdaを発火 DynamoDBに保存

Slide 14

Slide 14 text

WebSocketサーバを建てるには リクエストを識別 connectionIdを付与 接続情報を 永続化

Slide 15

Slide 15 text

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 }

Slide 16

Slide 16 text

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)

Slide 17

Slide 17 text

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, ] }

Slide 18

Slide 18 text

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に対する権限をロールに付与

Slide 19

Slide 19 text

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" } }

Slide 20

Slide 20 text

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がソートキーのことです。

Slide 21

Slide 21 text

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でも検索できるよ うにしておく。

Slide 22

Slide 22 text

WebSocketサーバを建てるには (DynamoDB) パーティションキー roomId ソートキー connectionId hoge foo hoge bar hoge baz hoge qux fuga foobar fuga foobaz piyo fooqux piyo barfoo  接続時にはroomIdとconnectionIdを指定し、その情報 をDynamoDBに保存する。 接続時の処理

Slide 23

Slide 23 text

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 }

Slide 24

Slide 24 text

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に保存

Slide 25

Slide 25 text

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”のセッションにメッセージを送れば 良い。 メッセージ受信時の処理①

Slide 26

Slide 26 text

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(), })

Slide 27

Slide 27 text

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から取得

Slide 28

Slide 28 text

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からレコードを削除する。(切断扱い。) メッセージ受信時の処理②

Slide 29

Slide 29 text

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, })

Slide 30

Slide 30 text

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文でメッセージを送信

Slide 31

Slide 31 text

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, }) }

Slide 32

Slide 32 text

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から削除

Slide 33

Slide 33 text

WebSocketサーバを建てるには (DynamoDB) パーティションキー roomId ソートキー connectionId hoge foo hoge bar hoge baz hoge qux fuga foobar fuga foobaz piyo fooqux piyo barfoo  connectionIdのみをリクエストに含める。  メッセージ受信時と同様に、GSIを用いてパーティション キーを特定し、プライマリーキーでレコードを削除する。 切断時の処理

Slide 34

Slide 34 text

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 プライマリーキーを指定 (パーティションキー + ソートキー)

Slide 35

Slide 35 text

ディレクトリ構成 . ├── 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

Slide 36

Slide 36 text

ディレクトリ構成 . ├── 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関数を作成

Slide 37

Slide 37 text

感想  Lambdaは常時起動し続けるわけではないので、WebSocket通信のようなコネク ションを維持しなければならないサーバは作成が難しいと思っていたが、 DynamoDBを上手く使うことによって作成することができた。  今回の構成では、常時起動しているリソースはなく、完全に従量課金で事足りるた め、個人開発としてもお財布に優しい構成にできた。  これからもコスト意識を徹底した個人開発に勤しみたい。

Slide 38

Slide 38 text

Thanks Twitter : @const_myself