Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
Speaker Deck
Features
Speaker Deck
PRO
Sign in
Sign up for free
Search
Search
Terraformと AWS Lambdaで WebSocket通信
Search
おがどら
March 10, 2024
Programming
260
1
Share
Embed
Copy iframe code
Copy JS code
Copy link
Start on current slide
Terraformと AWS Lambdaで WebSocket通信
おがどら
March 10, 2024
More Decks by おがどら
See All by おがどら
任意のドメインを破壊
ogadra
0
44
LambdaとSQLiteでシステム構築
ogadra
1
560
hono-remix-adapter使ってみた
ogadra
0
390
Other Decks in Programming
See All in Programming
Vue × Nuxt × Oxc どこまで使える?実運用の現在地
andpad
0
170
LLM Plugin for Node-REDの利用方法と開発について
404background
0
170
脅威をエンジニアリングの糧にして――現場編 / Turning Threats into Engineering Fuel — Field Edition
nrslib
0
270
Hunting Vulnerabilities in Symfony with LLMs
vinceamstoutz
0
540
LLMによるContent Moderationの本番運用の裏側と品質担保への挑戦
suikabar
2
560
ローカルLLMでどこまでコードが書けるか -拡張版 / How much code can be written on a local LLM Extended
kishida
2
950
AIチームを指揮するOSS「TAKT」活用術 / How to Use “TAKT,” an OSS Tool for Orchestrating AI Teams
nrslib
6
880
Contextとはなにか
chiroruxx
0
290
Webフレームワークの ベンチマークについて
yusukebe
0
160
Lemonade + Foundry Toolkit でお手軽アプリ開発
seosoft
1
320
「なぜそう決めたのか」を残し続ける仕組み ― Notion AI カスタムエージェント × Slack連携による設計判断の自動記録 - NIKKEI Tech Talk #47
niftycorp
PRO
0
110
Signal Forms: Beyond the Basics @ngBaguette 2026 in Paris
manfredsteyer
PRO
0
240
Featured
See All Featured
From π to Pie charts
rasagy
0
210
Visual Storytelling: How to be a Superhuman Communicator
reverentgeek
2
560
The Success of Rails: Ensuring Growth for the Next 100 Years
eileencodes
47
8.2k
Amusing Abliteration
ianozsvald
1
200
Dominate Local Search Results - an insider guide to GBP, reviews, and Local SEO
greggifford
PRO
0
190
jQuery: Nuts, Bolts and Bling
dougneiner
66
8.5k
The AI Search Optimization Roadmap by Aleyda Solis
aleyda
1
5.9k
Tell your own story through comics
letsgokoyo
1
950
Digital Projects Gone Horribly Wrong (And the UX Pros Who Still Save the Day) - Dean Schuster
uxyall
0
1.7k
Ecommerce SEO: The Keys for Success Now & Beyond - #SERPConf2024
aleyda
1
2k
Building AI with AI
inesmontani
PRO
1
1.1k
ReactJS: Keep Simple. Everything can be a component!
pedronauck
666
130k
Transcript
Terraformと AWS Lambdaで WebSocket通信
まずはこちらにアクセス! QRコードは本番デモ用になりますので、 資料には掲載しておりません。
ご覧のような*アプリを作成しました • フロントエンド、バックエンド、Chrome拡張の3部分からなる • Next.js (フロントエンド。メッセージ送信を行う) • Go (バックエンド。WebSocketサーバ) •
React (Chrome拡張。スライドにメッセージを表示する) • フロントエンドでコメントをすると、Chrome拡張に表示される * QRコードを読み取るとチャットアプリライクなUIが表示され、 そこでテキストを送信するとスライドにメッセージが流れるアプリ。
ogadra (おがどら) TypeScript / Python / Go / Terraform 大学1年の頃,
スクレイピングをしたところどっぷりハマ り, それ以来プログラミングに勤しんでいる . 最近の趣味はCiv6, AWS, ネットワーク. M3 MacBook Air 欲しい. 自己紹介
Agenda • システム構成図 • 今回話すこと • WebSocketとはなにか • LambdaでWebSocketを使うやり方 ◦
API Gateway ◦ Lambda ◦ DynamoDB • 感想
システム構成図
システム構成図
今回話すこと・話さないこと • WebSocketについて • Lambdaについて • DynamoDBについて 話すこと 話さないこと •
フロントエンドについて • Chrome拡張について
プレゼンの目的 • WebSocket通信について知る • AWS Lambda, API Gatewayを知る • WebSocketアプリケーションをLambdaで作れることを知る
そもそもWebSocket通信とは クライアント、サーバーの双方向で通信するプロトコル HTTP通信ではレスポンスという形でサーバーはメッセージを送信 → 任意のタイミングでサーバーからメッセージを送れるようになる
WebSocketサーバを建てるには 以下の要件を満たす必要がある 1. 接続要求に応じ、クライアントの接続情報を保持 2. 送信要求に応じ、クライアントからメッセージを受信 3. 接続情報を用い、クライアントにメッセージを送信 4. 切断要求に応じ、クライアントの接続情報を破棄
WebSocketサーバを建てるには 以下の要件を満たす必要がある 1. 接続要求に応じ、クライアントの接続情報を保持 2. 送信要求に応じ、クライアントからメッセージを受信 3. 接続情報を用い、クライアントにメッセージを送信 4. 切断要求に応じ、クライアントの接続情報を破棄
API Gatewayを用いて識別・対応するLambdaを発火
WebSocketサーバを建てるには 以下の要件を満たす必要がある 1. 接続要求に応じ、クライアントの接続情報を保持 2. 送信要求に応じ、クライアントからメッセージを受信 3. 接続情報を用い、クライアントにメッセージを送信 4. 切断要求に応じ、クライアントの接続情報を破棄
API Gatewayを用いて識別・対応するLambdaを発火 DynamoDBに保存
WebSocketサーバを建てるには リクエストを識別 connectionIdを付与 接続情報を 永続化
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サーバを建てるには (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)
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, ] }
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に対する権限をロールに付与
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" } }
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がソートキーのことです。
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でも検索できるよ うにしておく。
WebSocketサーバを建てるには (DynamoDB) パーティションキー roomId ソートキー connectionId hoge foo hoge bar
hoge baz hoge qux fuga foobar fuga foobaz piyo fooqux piyo barfoo 接続時にはroomIdとconnectionIdを指定し、その情報 をDynamoDBに保存する。 接続時の処理
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 }
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に保存
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”のセッションにメッセージを送れば 良い。 メッセージ受信時の処理①
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(), })
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から取得
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からレコードを削除する。(切断扱い。) メッセージ受信時の処理②
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, })
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文でメッセージを送信
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, }) }
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から削除
WebSocketサーバを建てるには (DynamoDB) パーティションキー roomId ソートキー connectionId hoge foo hoge bar
hoge baz hoge qux fuga foobar fuga foobaz piyo fooqux piyo barfoo connectionIdのみをリクエストに含める。 メッセージ受信時と同様に、GSIを用いてパーティション キーを特定し、プライマリーキーでレコードを削除する。 切断時の処理
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 プライマリーキーを指定 (パーティションキー + ソートキー)
ディレクトリ構成 . ├── 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
ディレクトリ構成 . ├── 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関数を作成
感想 Lambdaは常時起動し続けるわけではないので、WebSocket通信のようなコネク ションを維持しなければならないサーバは作成が難しいと思っていたが、 DynamoDBを上手く使うことによって作成することができた。 今回の構成では、常時起動しているリソースはなく、完全に従量課金で事足りるた め、個人開発としてもお財布に優しい構成にできた。 これからもコスト意識を徹底した個人開発に勤しみたい。
Thanks Twitter : @const_myself