AWS API Gateway の WebSocket API をちゃんと理解する

(※この記事は 別媒体に投稿した記事 のバックアップです。 canonical も設定しています)

2020-12-01

※この記事は別アカウント(hyiromori)から引っ越しました

これは コネヒト Advent Calendar 2020 1日目 の記事です。

はじめに

これは AWS のサービスの1つ、API Gateway の WebSocket API を理解するために書いた記事です。
発表当初 から「ついに WebSocket もサーバーレスで使える!」と喜び、個人的に色々試していました。
ただ自分の理解が断片的になっていたので、一度ちゃんとまとめたいと思いこの記事を書きました。

WebSocket API が提供してくれるもの

まずは、WebSocket API がどういった機能を提供してくれるのか紹介します。

サーバー管理不要で WebSocket コネクションを管理してくれる

API Gateway が WebSocket の接続から切断までを面倒みてくれます。
EC2などのサーバーを管理しなくても良い、いわゆるサーバーレスなサービスです。

接続時、切断時、メッセージ受信時にバックエンドへ送信してくれる

当然コネクションの管理だけではなく、WebSocket コネクションが接続された時、切断された時、そしてメッセージを受信した時にバックエンドへイベントを送信してくれます。
バックエンドは Lambda などのAWSサービスはもちろん、HTTP での送信もできるので自前のサーバーに送ることもできます。

API Gateway

またコネクションの接続時、切断時、メッセージ受信時でそれぞれ別のバックエンドを指定できます。
(※更にメッセージ受信時はカスタムルートというのを作ると、メッセージ内容によって別のバックエンドに送信することも可能ですが、今回は使いません)

API Gateway

WebSocket へメッセージを送信できる

WebSocket API が接続毎に発行しているコネクションIDを使って、AWS CLI や SDK で WebSocket に対してメッセージを送信できます。
このコネクションIDは接続時、切断時、メッセージ受信時のイベントから取得できます。
コネクションIDをどのように管理するかは、ユーザーの実装に委ねられています。(一覧APIのようなものは AWS にはありません)

WebSocket API の実験

WebSocket API を実際にデプロイして、その仕組みを理解していきます。

Serverless Framework を使ったサンプルで WebSocket API をデプロイする

今回は Serverless Framework を使い、数十行程度の小さなサンプルプロジェクトを作りました。
https://github.com/mryhryki/example-aws-api-gateway-websocket

コアとなる部分は index.js の部分です。
ここにメッセージ受信時、接続時、切断時の処理を実装しています。

まずメッセージ受信時の処理です。
ここでは受信したイベントからエンドポイントURLを組み立てて、コネクションIDと共に送信元に返す処理をしています。
このエンドポイントURLとコネクションIDが、WebSocket へメッセージを送信するために必要です。

const onMessage = async (event) => {
  // イベントデータからコネクションのエンドポイントURLを組み立てる
  const { body } = event
  const {connectionId, apiId, stage} = event.requestContext;
  const endpoint = `https://${apiId}.execute-api.${process.env.AWS_REGION}.amazonaws.com/${stage}`

  // 送信元コネクションにエンドポイントURLとコネクションIDを送信する
  const apiGateway = new ApiGatewayManagementApi({apiVersion: '2018-11-29', endpoint})
  const params = {Data: JSON.stringify({endpoint, connectionId, request: body}), ConnectionId: connectionId}
  await apiGateway.postToConnection(params).promise()

  return {}
}

接続時と切断時は特に何もしないので、そのまま正常終了させます。
ちなみに接続時と切断時にもメッセージ受信時と同等のイベント情報は含まれているので、ここで色々な処理を実装することも可能です。

const mockHandler = (_event, _context, callback) => callback(null, {})

そして serverless.yml で WebSocket API のイベントと Lambda を紐付けています。

onConnect:
  handler: "index.onConnect"
  events:
    - websocket:
        route: "$connect"
onMessage:
  handler: "index.onMessage"
  events:
    - websocket:
        route: "$default"
onDisconnect:
  handler: "index.onDisconnect"
  events:
    - websocket:
        route: "$disconnect"

README に従って実行すると、WebSocket API をデプロイできます。

$ npm run setup
# `.env` ファイルを編集

$ npm run deploy
(略)
endpoints:
  wss://xxxxxxxxxx.execute-api.(AWS_REGION).amazonaws.com/dev
(略)

wss://.... のURLは、後で使用するのでメモしておきます。

ちなみに使い終わったら以下のコマンドで削除できます。

$ npm run remove

wscat と AWS CLI を使って動作確認してみる

wscat という WebSocket のクライアントと AWS CLI を使って、簡単に WebSocket API の動作確認をしてみましょう。
先に結果を紹介するとこんな感じです。

websocket experiment

何をやっているか順番に説明します。

まず以下のコマンドで wscat を起動して、API Gateway の WebSocket 用エンドポイントに WebSocket で接続します。
(実際に試す場合は、デプロイ時に出力されたURLを指定してください)

$ npx wscat -c 'wss://xxxxxxxxxx.execute-api.(AWS_REGION).amazonaws.com/dev'

これは WebSocket 経由で {} というデータを送信しています。(wscat で入力した内容はそのまま WebSocket で送信されます)
送信したデータは、WebSocket API 経由で Lambda が処理をして、エンドポイントURLとコネクションIDが入ったメッセージ(JSON)を生成します。
そのメッセージを WebSocket 経由で受信して表示されています。

> {}
< {"endpoint":"https://lxhspujxjd.execute-api.us-east-1.amazonaws.com/dev","connectionId":"WqK8Ie4IIAMCEdA=","request":"{}"}

そうして取得したエンドポイントURLとコネクションIDを使い、別のターミナルから AWS CLI で {"test":"data"} というメッセージを送信します。

$ aws apigatewaymanagementapi post-to-connection \
    --data '{"test":"data"}' \
    --endpoint-url 'https://lxhspujxjd.execute-api.us-east-1.amazonaws.com/dev' \
    --connection-id 'WqK8Ie4IIAMCEdA='

そうすると wscat 側で {"test":"data"} というメッセージを受信します。

< {"test":"data"}

これで API Gateway を経由した WebSocket でのデータ受信が確認できました。

ポイントは、エンドポイントURLとコネクションIDがあれば、AWS CLI や SDK を使ってメッセージの送信ができるということです。
特にコネクションIDをどのように管理していくかを、実装時に注意していく必要があります。

WebSocket API の実装例

WebSocket API を使って様々な実装が可能ですが、ここでは2つの例を紹介します。

Lambda + DynamoDB

サーバーレスに実装するのなら、おそらくスタンダードな構成です。
AWSからの発表時にも、この構成でサンプルが紹介されていました。
[発表]Amazon API GatewayでWebsocketが利用可能 | Amazon Web Services ブログ

構成図にするとこんな感じです。
(Cloudcraft というサービスで作っています)

Lambda + DynamoDB

バックエンドの処理を Lambda で実行し、コネクションIDを管理するデータストアとして DynamoDB を使用します。
サーバーレスな構成で、コネクション数が増えても自動的にスケールし、料金も従量課金です。

ただし、別のアプリケーションサーバーと連携する場合は、何らか連携する仕組みを作る必要があります。
実験で行ったように WebSocket 経由でコネクションIDを取得し、クライアントが別途アプリケーションサーバーに送信する、というのも1つの手です。
しかし、この辺りはアプリケーションの要件によって異なるので、その都度考えていく必要があります。

HTTPバックエンド

WebSocket 自体の管理を API Gateway に任せ、残りは全て自前のHTTPサーバーで受け付ける例です。
構成図としてはこんな感じです。(シンプルなのであまり意味はないですが)

http backend

自前のサーバーなので、後はコネクションIDの管理などは RDS なり Redis なり自由に実装できます。
自由度は高いですが、自前のHTTPサーバーの管理が必要になります。

また、HTTPバックエンドを指定して送られてくるデータを調べてみたのですが、コネクションIDは開始時にしか送られていないようでした。
HTTPバックエンドを使ったことがなく詳細に調べてもいないので、対応する方法は他にあるかもしれません。
もし詳しい方がおられましたらコメントいただけると幸いです。

以下に調査した結果を載せておきます。

コネクション開始時

POST /connect HTTP/1.1
content-length: 0
host: 1d5e52b811b9.ngrok.io
sec-websocket-extensions: permessage-deflate; client_max_window_bits
sec-websocket-key: I84zv8KHNRzb3Szo8cireQ==
sec-websocket-version: 13
user-agent: AmazonAPIGateway_lxhspujxjd
x-amzn-apigateway-api-id: lxhspujxjd
x-amzn-trace-id: Root=1-5fc14bec-16170b9451248e1e10b6e6ae
x-forwarded-for: 115.162.104.13, 3.216.142.111
x-forwarded-port: 443
x-forwarded-proto: https, https

メッセージ受信時

POST /default HTTP/1.1
content-length: 2
content-type: application/json; charset=UTF-8
host: 1d5e52b811b9.ngrok.io
user-agent: AmazonAPIGateway_lxhspujxjd
x-amzn-apigateway-api-id: lxhspujxjd
x-forwarded-for: 3.216.142.44
x-forwarded-proto: https

{}

コネクション切断時

POST /disconnect HTTP/1.1
content-length: 0
host: 1d5e52b811b9.ngrok.io
user-agent: AmazonAPIGateway_lxhspujxjd
x-amzn-apigateway-api-id: lxhspujxjd
x-api-key:
x-forwarded-for: , 3.216.142.125
x-forwarded-proto: https
x-restapi:

WebSocket API の料金

WebSocket API は接続時間と、メッセージの送受信量に応じて課金されます。
ここでは簡単に料金を紹介します。
(最新の正確な料金は 公式ドキュメント を確認してください)

注意事項

  1. ap-northeast-1 (東京リージョン) の料金です。
  2. 日本円への変換は $1 = 100円 で計算しています。

接続時間に応じた料金

100万分の接続に対して、$0.315かかります。
例えば、1万人のユーザーが1人あたり月に1000分(約16時間)接続したら31.5円かかる計算です。

メッセージの送受信量に応じた料金

メッセージの送受信量は、32KB単位で計算されます。
1メッセージ32KB以下であれば、10億回につき$1.26かかります。
例えば、1万人のユーザーが32KB以下のメッセージを1人あたり月に10万回送受信したら126円かかる計算です。

おわりに

API Gateway の WebSocket API は、WebSocket のコネクションを管理してくれるできるサービスです。
エンジニアはコネクションをIDで管理して、受信時と送信時の処理だけ集中して実装できます。
手軽に WebSocket を扱えるようになるので、リアルタイムなメッセージ送受信が必要になった時は積極的に使っていきたいですね。