サマリー
- WebSocketの解説
- (サーバレスWebSocket APIを実装するにあたり)利用するAWSサービスの解説
- 実装の注意点
対象読者
- WebSocketについてざっくり理解したい
- 疎結合なWebSocketのアーキテクトを知りたい
- TerraformとAWSを利用して、サーバレスなWebSocket APIを(Golangで)実装したい
- lambda
- API Gateway
- DynamoDB
ソースコード
WebSocketとは
参考記事
Webにおいて双方向通信を低コストで行う為の仕組み。
インタラクティブなWebアプリケーションでは、サーバから任意のタイミングでクライアントに情報の送信とかしたい事があって、一つの配信に多数のクライアントがアクセスしていて、そこに誰かがコメントやギフトが送信したときに他のユーザーに通知したい場合がそれにあたる。
また、クライアントからのAPIコールなしで、サーバーからクライアントに情報を渡す、いわゆる「サーバープッシュ」的なことをやりたい場合にも選択肢の1つとして利用される。
そういった時に双方向通信の必要性が出てくる。
WebSocketの通信の仕組み
次の手順で通信を行う。
- HTTP(厳密にはそれをwebsocketにupgradeしたもの)でクライアントとサーバー間でコネクション確立
- 確立されたコネクション上で双方向通信
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-Accept
もSec-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は以下の三つのルートを用意する必要がある。
-
$default : RSXがAPIルート内の他のrouteKeyに一致しない場合に使用される。
例としてはエラー処理などを実装するときに利用する - $connect : クライアントが最初に接続するときの処理に使うルート
- $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を起動しておくことに比べてだいぶ低価格で構築できそう。
アーキテクチャについて
アーキテクチャ図
疎結合の為APIサーバとWebSocketは分離してある。
そして、 その部分のやりとりはWebSocketのconnectionをする必要がない(クライアントとWebSocketだけで良い) ので、RestAPIのAPI Gatewayも用意している。
この構成は Terraform で記述。
ちなみに、自社クライアントはUnityであり、こちらのライブラリを使用。
https://github.com/endel/NativeWebSocket
注意点
具体的なコードはgithubのコードをリンクさせています。
-
lambda
- 環境変数もterraformで設定
- APIサーバのBaseURLなど
- 環境変数もterraformで設定
-
APIGateway
- restAPIとwebSocketAPIでterraformの書き方だいぶ違う
- APIGateway managerのiam roleもgetとpostなどHTTPメソッド分追加する必要あり
- dynamoDB
このアーキテクトの選定理由
- サーバレスなので運用負荷が少ないから
- 疎結合なので他のアプリに転用しやすいから
- 料金が安いから
実装方針(lambdaや周辺のサービスとのやりとりについて)
簡単なシーケンス図
接続(/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¶ms2=value2...>
-
- dynamoDBに以下を保存
- partitionKey: roomId
- sortKey: userId
- attr1: connectionId
- lambdaのeventに渡されてくる
RequestContext
から**connectionId
**を取得できる。
- lambdaのeventに渡されてくる
-
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にその個数分リクエストを送信。
- もちろん送られてきたパラメタを渡す
- 具体的には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は端末ごとに行われるので、一つだけ定まるのであっている。
- APIGatewayとWebSocketの仕様上、connetcionIdしか取得できないが、dynamoDBはpartitionKey(roomId)とsortKey(userId)の複合プライマリキーでしか削除できない。
実装、運用して感じたこと
よかったところ
- 安い
- サーバレスが嬉しい
- モダン
-
ガチで疎結合
- 結構そのまんま他のアプリに使える
よくなさそうなところ
最初の実装がめっちゃくちゃ大変
-
dynamoDBの検索がめんどくさい
- プライマリキーでの検索しかできないので、connectionIdをプライマリにすると視聴者のuserIdsが取れないし、roomIdをプライマリにすると、disconnectのタイミングで該当レコードが検索できず、非常に困った
- GSIを用いることで解決。
- このくらいあるあるな要件の実装でもGSIを使わなくちゃいけないのは少し予想外だった。
- プライマリキーでの検索しかできないので、connectionIdをプライマリにすると視聴者のuserIdsが取れないし、roomIdをプライマリにすると、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
- 図にもあるが、API Gateway(REST API) を追加し、 WebSocketに対してのsendMessageはHTTPリクエストにすることで解決。
なんか不安定な時がある
- dynamoDBに保存されているconnectionIdでAPI Gateway managerでGetConnectionしても何故かnullが帰ってくる時があった
- クライアントとのconnectionが安定していない or ラグがある?
- こちらは未だ解決しておらず。