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
52
LambdaとSQLiteでシステム構築
ogadra
1
590
hono-remix-adapter使ってみた
ogadra
0
400
Other Decks in Programming
See All in Programming
yield再入門 #phpcon
o0h
PRO
0
1.1k
ここ半年くらいでAIに作らせたR用ツール
eitsupi
0
390
そこに3びきプロダクトがいるじゃろう——生成AI時代における“価値が届かない理由”の構造
kosuket
0
510
使いながら育てる Claude Code — 開発フローの1コマンド化 × 繰り返し指摘の自動仕組み化
shiki_kakaku
0
1.8k
Building a Meta Ray-Ban display app
akkeylab
0
160
Go言語とトイモデルで学ぶTransformerの気持ち / fukuokago23-transformer
monochromegane
0
180
komatsuna「分散システムにおけるバグ分析手法」
komatsunaqa
0
270
Flow は今どうなっているか
mizdra
PRO
0
610
AIが無かった頃の素敵な出会いの話
codmoninc
1
460
20260722_microCMSで考える、AI時代のコンテンツ運用設計
yosh1
0
420
AI Engineeringは、AIプロダクトだけのものか? 〜AIがソフトウェアを作る時代の新しい当たり前〜 / No AI in your product. AI Engineering in your development.
rkaga
4
460
PHP Application における Kubernetes 内 gRPC 通信
ganchiku
0
590
Featured
See All Featured
AI: The stuff that nobody shows you
jnunemaker
PRO
9
920
[SF Ruby Conf 2025] Rails X
palkan
2
1.3k
BBQ
matthewcrist
89
10k
The Hidden Cost of Media on the Web [PixelPalooza 2025]
tammyeverts
2
470
Beyond borders and beyond the search box: How to win the global "messy middle" with AI-driven SEO
davidcarrasco
3
210
HTML-Aware ERB: The Path to Reactive Rendering @ RubyCon 2026, Rimini, Italy
marcoroth
3
440
SEOcharity - Dark patterns in SEO and UX: How to avoid them and build a more ethical web
sarafernandez
0
240
Leading Effective Engineering Teams in the AI Era
addyosmani
9
2.3k
More Than Pixels: Becoming A User Experience Designer
marktimemedia
3
490
First, design no harm
axbom
PRO
2
1.2k
Bootstrapping a Software Product
garrettdimon
PRO
307
120k
個人開発の失敗を避けるイケてる考え方 / tips for indie hackers
panda_program
123
22k
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