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

# サマリー

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

# 対象読者

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

# ソースコード

https://github.com/mormorbump/GolangApigwDynamodbWebsocketApp

# WebSocketとは

参考記事

- [RFC6455 - WebSocket Protocol](https://triple-underscore.github.io/RFC6455-ja.html)
- [Microsoft - Windows 8 と WebSocketプロトコル](https://learn.microsoft.com/ja-jp/archive/msdn-magazine/2012/december/windows-8-networking-windows-8-and-the-websocket-protocol)

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-Accept`も`Sec-WebSocket-Key` の説明と同様。

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

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

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

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

# AWSのサービスについて

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

## AWS Lambda

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

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

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

## API Gateway

**APIの管理や実行を容易にする**しくみ。[WebSocket API](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-websocket-api-overview.html)が最近追加された。

今回は

- 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通信のコネクション](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/websocket-api-chat-app.html)を表す**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](https://dev.classmethod.jp/articles/conceptual-learning-about-dynamodb-gsi/)

## サーバーレスとは

クラウドサービス提供側(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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208038/535a7a1c-eccd-7a5a-4f5a-925d94713cbb.png)


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

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

この構成は **[Terraform](https://github.com/mormorbump/GolangApigwDynamodbWebsocketApp/tree/main/terraform)** で記述。

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

### 注意点

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

- **lambda**
    - 環境変数もterraformで設定
        - APIサーバのBaseURLなど
- **APIGateway**
    - [restAPI](https://github.com/mormorbump/GolangApigwDynamodbWebsocketApp/blob/main/terraform/apigw_restapi.tf)と[webSocketAPI](https://github.com/mormorbump/GolangApigwDynamodbWebsocketApp/blob/main/terraform/apigw_websocket.tf)でterraformの書き方だいぶ違う
    - APIGateway managerの[iam roleもgetとpostなどHTTPメソッド分追加する必要](https://www.notion.so/AWS-DynamoDB-API-Gateway-Lambda-Terraform-WebSocket-Golang-266dc6d40b9b40a39d29b1bf8fb0e2ec)あり
- **dynamoDB**
    - [partitionKey, sortKey, ttl, GSIなど全部terraformで設定](https://github.com/mormorbump/GolangApigwDynamodbWebsocketApp/blob/main/terraform/dynamodb.tf)
    - GSIを利用するときは、[iam roleでindexも追加する必要](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/iam-policy-specific-table-indexes.html)あり

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

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

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

## 簡単なシーケンス図

![websocket_server_sequence.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208038/df5413e2-66f9-c2b7-f787-a9e8d6460acb.png)


## **接続(/connection)**

- クライアントから**WebSocketモードのApi Gatewayにリクエスト**してもらい、**Connetcionを作成**(接続テストで[wscat](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-how-to-call-websocket-api-wscat.html)を使おう)
    - `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**で送る
        - 例
    
    ```json
    {
        "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は端末ごとに行われるので、一つだけ定まるのであっている。

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

## よかったところ

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

## よくなさそうなところ

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

- **dynamoDBの検索がめんどくさい**
    - プライマリキーでの検索しかできないので、connectionIdをプライマリにすると視聴者のuserIdsが取れないし、roomIdをプライマリにすると、[disconnectのタイミングで該当レコードが検索できず](https://www.notion.so/AWS-DynamoDB-API-Gateway-Lambda-Terraform-WebSocket-Golang-266dc6d40b9b40a39d29b1bf8fb0e2ec)、非常に困った
        - GSIを用いることで解決。
    - このくらいあるあるな要件の実装でも[GSIを使わなくちゃいけない](https://www.notion.so/AWS-DynamoDB-API-Gateway-Lambda-Terraform-WebSocket-Golang-266dc6d40b9b40a39d29b1bf8fb0e2ec)のは少し予想外だった。

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

- いちいち**デプロイ → 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 ラグがある？
    - こちらは未だ解決しておらず。