28
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

GolangでAWS(DynamoDB + API Gateway + Lambda + Terraform)でのサーバレスWebSocketを作ってみた

Last updated at Posted at 2023-07-20

サマリー

  • WebSocketの解説
  • (サーバレスWebSocket APIを実装するにあたり)利用するAWSサービスの解説
  • 実装の注意点

対象読者

  • WebSocketについてざっくり理解したい
  • 疎結合なWebSocketのアーキテクトを知りたい
  • TerraformとAWSを利用して、サーバレスなWebSocket APIを(Golangで)実装したい
    • lambda
    • API Gateway
    • DynamoDB

ソースコード

WebSocketとは

参考記事

Webにおいて双方向通信を低コストで行う為の仕組み

インタラクティブなWebアプリケーションでは、サーバから任意のタイミングでクライアントに情報の送信とかしたい事があって、一つの配信に多数のクライアントがアクセスしていて、そこに誰かがコメントやギフトが送信したときに他のユーザーに通知したい場合がそれにあたる。

また、クライアントからのAPIコールなしで、サーバーからクライアントに情報を渡す、いわゆる「サーバープッシュ」的なことをやりたい場合にも選択肢の1つとして利用される。

そういった時に双方向通信の必要性が出てくる。

WebSocketの通信の仕組み

次の手順で通信を行う。

  1. HTTP(厳密にはそれをwebsocketにupgradeしたもの)でクライアントとサーバー間でコネクション確立
  2. 確立されたコネクション上で双方向通信

1-1 クライアント ⇒ サーバ

クライアントからサーバーにHTTPリクエストを送ってコネクションを確立するところから始まる。

まず、クライアント(ブラウザなど)から以下の様なリクエストを送る。

GET /resource HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: E4WXXXXXXXXXXXXA==

これはexample.comというhostの/resourceという相対URIに対するリクエスト。

  • 1行目の内訳は
    • Request-Lineと呼ばれるメソッド名(GET)
    • リクエストURI(/resource)
    • プロトコルバージョン(HTTP/1.1)

なので、HTTPリクエストの条件と照らし合わせると

  • Request-Lineが存在する事
  • リクエストURIが相対URIであれば、Hostヘッダーにホスト名を記載する事

より、HTTPリクエストの条件を満たしていることがわかる。

特徴的なのは

  • Upgradeヘッダ
  • Connectionヘッダ

が存在する事で、この二つでHTTPからWebSocketへのプロトコルのアップグレードを表現している。(Upgradeは単純に機能拡張するよーみたいなニュアンス)

Sec-WebSocket-Versionヘッダは、接続のプロトコルバージョンを指示するためにクライアントからサーバへ送信される。

サーバーは、送られてきたバージョンに対応出来なければコネクションを切断する。

本記事の執筆時点でのWebSocketの最新バージョンは13なので13を指定。

Sec-Websocket-Keyヘッダは、特定のクライアントとのコネクションの確立を立証する為に使われる。

サーバはSec-Websocket-Keyヘッダに指定された値を元に、新しく値を生成してSec-WebSocket-Acceptヘッダにその値を指定してレスポンスを返す。

こうすることで、クライアントは自分のSec-Websocket-Keyの値が使われているかどうかが確認出来る様になっている。(自分のリクエストに対するレスポンスである事が保証出来る。)

1-2 サーバ ⇒ クライアント

HTTP/1.1 101 OK
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Accept: 7eXXXXXXXXXXXXXXPc=
  • 1行目の内訳は
    • Status-Line
    • プロトコルバージョン(HTTP/1.1)
    • ステータスコード(101)
    • テキストフレーズ(OK)

の並びとなっている。Status-Lineが存在する時点で、HTTPレスポンスの条件を満たしている。

Upgrade、Connectionはリクエストの説明と同様で、Sec-WebSocket-AcceptSec-WebSocket-Key の説明と同様。

以上のリクエストとレスポンスのみで、WebSocketのコネクションが確立される。

これを WebSocket opening ハンドシェイクという。

確立されたコネクション上で双方向通信

上のハンドシェイク後に双方向通信が行われる。
具体的な内容が下の 実際の方針 にかかれる。

AWSのサービスについて

以下、今回利用したサービスの詳細を簡単に記述していく。

AWS Lambda

FaaS(Function as a Service)と呼ばれるもの。簡単にいうとサーバーレスを実現する。

今回はここにGolangで書いたWebSocket接続や双方向送信のロジックをデプロイする。

ソースコードがここに置かれるということ。

API Gateway

APIの管理や実行を容易にするしくみ。WebSocket APIが最近追加された。

今回は

  • WebSocketコネクション用 ⇒ WebSocket API
  • メッセージ送信用 ⇒ REST API

とふたつ用意する。これはAPIサーバとWebSocketサーバとさらにWebSocketクライアントが別々のため。

WebSocketAPIは以下の三つのルートを用意する必要がある。

  1. $default : RSXがAPIルート内の他のrouteKeyに一致しない場合に使用される。
    例としてはエラー処理などを実装するときに利用する
  2. $connect : クライアントが最初に接続するときの処理に使うルート
  3. $disconnect :クライアントが切断する処理に使うルート。実装は任意で、切断処理をカスタマイズする場合に利用

connectされると、それの識別子であるconnectionIdが発行される。(後述のdynamoDBで保存)

AWS DynamoDB

1桁ミリ秒単位で規模に応じたパフォーマンスを実現する高速で柔軟な NoSQL データベースサービスキーバリューストア。あとサーバレス

WebSocket通信のコネクションを表すConnectionIdを保管するために利用する。

dynamoDBはprimaryKeyの概念が特殊なため、以下で解説する。

ParititionKey(hashKey)とSortKey(rangeKey)

dynamoDBにはpartitionKeyのみをプライマリキーとする場合と、partitionKeyとsortKeyの組み合わせでユニークとする複合プライマリキーの二種類が利用できる。

dynamoDBは、基本PrimaryKeyでしか検索できない。

今回は、

  • parititionKey: roomId
  • sortKey: userId

の複合プライマリキーで設計。

ただ、それだと切断時にconnectionIdからの逆引きができないため、以下のGSIを利用

GSI(Global Secondary Index)

基本primaryKeyでしか検索できないdynamoDBを、別のkeyで検索できるようにする仕組み。

indexを追加している。GSIとしてconnectionIdを設定することで逆引きを実装

詳しくはこれ

コンセプトから学ぶAmazon DynamoDB【GSI篇】 | DevelopersIO

サーバーレスとは

クラウドサービス提供側(AWS等)が実際のサーバーを用意し、さらに管理を行うSaaS(Software as a Service)やFaaS(Function as a Service)が、『サーバーレス』に該当。

つまり、実際にはアプリケーションが稼働するサーバーが存在するものの、利用側の管理者はクラウドにあるサーバの存在を意識せずに、アプリケーションやデータベースの利用ができるクラウド利用形態のことを『サーバーレス』という。

今回紹介しているサービスは全部サーバレス

料金

リージョン毎に値段が違うので、ap-northeastの場合を記述。

API Gateway でコネクションを保持し続ける料金 100万分あたり $0.315
メッセージの送受信回数 100万メッセージあたり $1.26
Lambdaの利用料金 1msあたり $0.000001667 ※メモリ1Gの場合

1000人の非常にアクティブユーザーがいると仮定して、それぞれ1日に2時間接続し1000メッセージをやり取りするとして

360万分(約 $1) + 3000万メッセージ(約 $30) + Lambdaの利用料金

月$50程度となるので、常時接続のためのEC2を起動しておくことに比べてだいぶ低価格で構築できそう。

アーキテクチャについて

アーキテクチャ図

serverless_websocket.drawio.png

疎結合の為APIサーバとWebSocketは分離してある。

そして、 その部分のやりとりはWebSocketのconnectionをする必要がない(クライアントとWebSocketだけで良い) ので、RestAPIのAPI Gatewayも用意している。

この構成は Terraform で記述。

ちなみに、自社クライアントはUnityであり、こちらのライブラリを使用。
https://github.com/endel/NativeWebSocket

注意点

具体的なコードはgithubのコードをリンクさせています。

このアーキテクトの選定理由

  • サーバレスなので運用負荷が少ないから
  • 疎結合なので他のアプリに転用しやすいから
  • 料金が安いから

実装方針(lambdaや周辺のサービスとのやりとりについて)

簡単なシーケンス図

websocket_server_sequence.png

接続(/connection)

  • クライアントからWebSocketモードのApi Gatewayにリクエストしてもらい、Connetcionを作成(接続テストでwscatを使おう)
    • wscat -c "wss://<api-id>.execute-api.<region>.amazonaws.com/<stage>"
      • リクエストパラメータはstringParamsで送る
      • wscat -c wss://<api-id>.execute-api.<region>.amazonaws.com/<stage>?<params1=value1&params2=value2...>
  • dynamoDBに以下を保存
    • partitionKey: roomId
    • sortKey: userId
    • attr1: connectionId
      • lambdaのeventに渡されてくる RequestContext
        から**connectionId**を取得できる。
    • ttl(制限時間)
      • ttlを設定することで、dynamoDBはttlを過ぎたら自動的にレコードを削除してくれる。

コメントの送信(/send_message)

  • 外部サーバからAPIサーバにリクエストしてもらう

  • APIサーバから**API Gateway(RestAPI)**にリクエスト

    • POST https://qb5tb8b4n3.execute-api.ap-northeast-1.amazonaws.com/dev/send_message
      • リクエストパラメータはstringParamsで送る
    {
        "platformUserId": 111,
        "roomId": "XXXXXXXXXXXXX",
        "message": "hello world",
        "name": "dummy", 
        "imageUrl": "<path>",
    }
    
    • クライアントから来たpartitionKey(roomId)とsortKey(userId)で、dynamoDBからconnectionIdを検索
    • そのConnectionIdで、ApiGatewayにconnectionがあるか確認
    • Connectionが確認できたら、roomIdでdynamoDBからuserIdsを引っ張る
      • 一つのroomを複数のuserが見ているので、roomIdで検索すると複数userIdが取れる
  • API Gateway(RestAPI)API Gateway(WebSocket API)にAPI Gateway manager経由でリクエストすることで、クライアントに返却。

    • 具体的にはuserIdsを使ってWenSocketモードのApiGatewayにその個数分リクエストを送信
      • もちろん送られてきたパラメタを渡す

切断(/disconnection)

  • シーケンス図に書いてなくてごめんなさい
  • クライアントからWebSocketモードのApiGatewayにリクエスト
    • この時点でWebSocket自体はdisconnectされるが、dynamoDBにレコードが残っているのでそれを削除したい
  • dynamoDBのレコードの削除
    • APIGatewayとWebSocketの仕様上、connetcionIdしか取得できないが、dynamoDBはpartitionKey(roomId)とsortKey(userId)の複合プライマリキーでしか削除できない。
      • そこで、GSIを使って(connectionIdをIndexとして)dynamoDBから検索
      • partitionKey(roomId)とsortKey(userId)が取得できる
    • partitionKeyとSortKeyによってレコードが一つ定まるので、それを削除することでwebSocketとdynamoDBの状態が同じとなる。
      • disconnectは端末ごとに行われるので、一つだけ定まるのであっている。

実装、運用して感じたこと

よかったところ

  • 安い
  • サーバレスが嬉しい
  • モダン
  • ガチで疎結合
    • 結構そのまんま他のアプリに使える

よくなさそうなところ

最初の実装がめっちゃくちゃ大変

サーバレスなためローカルでのデバッグも非常にしづらい

  • いちいちデプロイ → CloudWatchのログで確認というフローを繰り返さなければならず、開発イテレーションの効率が非常に悪かった。

ログの取得がバチくそ大変

  • デバッグの話と似ているかも(サーバレスの宿命。。。)
  • cloudWatchに流してるけど、勝手に時間ごとにpartitionされてしまうため見辛い。
    • APIサーバ経由でAPI GatewayにsendMessageしているため、APIサーバ側でログの出力を実装することである程度解決した。
    • ただ、これでもlambda内部でエラーになった場合は結局Cloudwatchを見なくちゃいけない。。。

疎結合にする点でアーキテクトに悩む

  • API Gateway(WebSocket API)だけで実装しようとすると、 APIサーバとの無駄なconnectionが発生してしまう
    • 図にもあるが、API Gateway(REST API) を追加し、 WebSocketに対してのsendMessageはHTTPリクエストにすることで解決。
      • WebSocket ↔ クライアント間はちゃんとconnectionがあるのでこれでok

なんか不安定な時がある

  • dynamoDBに保存されているconnectionIdでAPI Gateway managerでGetConnectionしても何故かnullが帰ってくる時があった
    • クライアントとのconnectionが安定していない or ラグがある?
    • こちらは未だ解決しておらず。
28
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?