投稿日
Amazon API Gateway の WebSocket API を理解する
もくじ
はじめに
デザイン&エンジニアリング部の竹谷(たけたに)です。
※よく「たけや」と間違われるのでふりがなを振ってみました
先日参画している案件で、外部システムと WebSocket プロトコルを使った通信を行う API サーバー( WebSocket サーバー)を作成する機会がありました。そこで AWS のサービスの一つである Amazon API Gateway の WebSocket API を使って作成しました。
WebSocket サーバーを作成するのは初めてでしたが、 API Gateway を使うことで比較的簡単に作成できました。やはり世界シェアトップクラスのクラウドサービスなだけありますね。いつも本当にお世話になっています。
今回はその時に得た知見をまとめ、紹介したいと思います。
API Gateway の選定理由
まずは、 WebSocket サーバーを作成するにあたり、 API Gateway を選定した理由について説明します。
1点目は WebSocket 接続数を考慮しなくても良くなる点です。 API Gateway の WebSocket API では WebSocket 接続を自動で管理してくれるため、接続数が多くなることによる不具合が軽減されます。
2点目はセキュリティについて考慮することが少なくなる点です。 API Gateway の WebSocket API ではアクセスを制御するための機能がサポートされています。また、 DDos 攻撃の対策などもしてくれています。
3点目はサーバレス構成にしたかったからです。本案件では Websocket サーバー以外のバックエンド構成をすべてサーバレスで構成していたため、 Websocket サーバーもサーバレスにしたいという意図がありました。
以上が API Gateway を選定した理由です。
WebSocket API の概要
まずは全体像をイメージしてもらうために、 WebSocket サーバーの構成図をお見せします。それから実装するにあたって知っておいたほうが良い WebSocket API の仕様について説明します。
ルート
上記の図の中に出てきているルートという言葉について説明します。
ルートとは、クライアントから API Gateway に対して贈られたリクエストやメッセージに対して、どのバックエンドサービス( Lambda 関数や他の AWS のサービスなど)を実行するかを設定しておくものです。 API Gateway では、それ自体に何か処理をさせることは基本的にできません。そのため、 API Gateway を通して他のバックエンドサービスを実行し、処理を行ったりレスポンスを返したりします。
接続と切断
WebSocket といえば、双方向通信を実現するためのプロトコルですが、まずはクライアントからリクエストを送りコネクションを確立する必要があります。API Gateway で WebSocket サーバーを立ち上げると特に何も設定しなくても wscat などのツールを使うことでコネクションを確立できます。しかし、このままだとただ繋がるだけなので、認証や接続の履歴などを残すことができません。
そこで、 WebSocket API ではコネクションを確立するためのリクエストが送られてきた際に $connect ルートという事前に定義されたルートを呼び出します。このルートに紐づく Lambda 関数などに認証機能などの処理を記述できます。
同様に、切断した際には $disconnect ルートを呼び出し、切断後の処理を行うことができます。
ルート選択式とメッセージの受信
クライアントからのリクエストによって WebSocket 通信が確立された後にメッセージのやり取りができるようになります。クライアントからのメッセージによって呼び出されるルートは2種類あり、カスタムルートと $default ルートです。
カスタムルートにはルートキーが設定されており、クライアントから送信された JSON メッセージをルート選択式を用いて評価します。そして、評価した値とルートキーが一致するカスタムルートが呼びだされます。
$default ルートは特殊なルートで、上記のルート選択式に該当しないメッセージが送信されてきた場合や、カスタムルートがない場合などに呼び出されるルートです。
ルート選択は以下の図のような流れです。
最後にルートと構成図についてまとめておきます。
ルート | 説明 |
---|---|
$connect ルート | 接続時に呼び出されるルート |
$disconnect ルート | 切断時に呼び出されるルート |
カスタムルート | メッセージ受信時に呼び出されるルート ルート選択式で評価された値に一致するルートキーのカスタムルートが呼び出される |
$default ルート | メッセージ受信時に呼び出されるルート カスタムルートが呼び出されないときなどに呼び出される |
メッセージの送信
サーバー側から接続しているクライアントに対してメッセージを送信する場合には、 API Gateway で用意されている @connections API を利用して以下のようにリクエストします。
POST https://{api-id}.execute-api.{region}.amazonaws.com/{stage}/@connections/{connection-id}
# {} 内の値はAPIや接続ごとに異なります。
# connection-id はクライアントとの接続を識別するものです。( API Gateway によって自動で採番されます)
connection-id は $connect ルートで呼び出される Lambda 関数などで取得できます(参考)。これによってクライアント側にリクエストボディの中身を送信できます。また、 GET メソッドでステータスの取得、 DELETE メソッドで接続の切断をサーバー側から行うことができます。これらの URL を毎回組み立てるのは面倒なのでライブラリが用意されている場合が多くあります。以下は Node.js の実装です。
import {
ApiGatewayManagementApiClient,
DeleteConnectionCommand,
GetConnectionCommand,
PostToConnectionCommand,
} from "@aws-sdk/client-apigatewaymanagementapi";
const client = new ApiGatewayManagementApiClient({
// API Gateway のエンドポイント
endpoint: "https://{api-id}.ref.execute-api.{region}.amazonaws.com/{stage}",
});
// ステータスを取得
client
.send(new GetConnectionCommand({ ConnectionId: "connection-id" }))
.catch((error) => {}); // エラーハンドリング
// メッセージを送信
client
.send(
new PostToConnectionCommand({
ConnectionId: "connection-id",
Data: Buffer.from("message"),
}),
)
.catch((error) => {}); // エラーハンドリング
// 接続を切断
client
.send(
new DeleteConnectionCommand({
ConnectionId: "connection-id",
}),
)
.catch((error) => {}); // エラーハンドリング
注意した点・工夫した点
次に、API GatewayのWebSocket APIを使ってサーバーを立てる場合に注意すべき点や工夫できる点について説明します。
Basic 認証
API Gateway の WebSocket API では、 $connect ルートでのみリクエストヘッダーがサポートされています。なので、接続時に Basic 認証を行うことができます。具体的には、クライアントが接続してきた際に、 $connect ルートで紐づけられている Lambda 関数で Authorization ヘッダーを読み取り、サーバー側で保管している認証情報と照らし合わせます。実装例は以下です。
import auth from "basic-auth";
// $connect ルートに紐づけられている Lambda 関数
const handler = (event) => {
const authorizationHeader = event.headers["Authorization"];
if (authorizationHeader === undefined) {
return { statusCode: 401 };
}
// 暗号化されたヘッダーをデコード
const credential = auth.parse(authorizationHeader);
// 認証情報が正しいかチェック
if (credential?.name !== "name" || credential.pass !== "pass") {
return { statusCode: 401 };
}
// 後続の処理を実装
};
Basic 認証以外の認証方法についてはドキュメントのこちらを参照してください。
クォータ
AWS の各サービスには AWS Service Quotas (クォータ)という制限があります。このクォータはデフォルトから引き上げ可能な項目もあればそうでない項目もあります。引き上げられない項目の場合はその制限にかからないように工夫する必要があります。API Gateway も例外なくクォータが設定されており、引き上げ不可能な項目としては「 WebSocket API の接続時間」や「アイドル接続のタイムアウト」などがあります。この項目のクォータは以下です。
項目名 | クォータ |
---|---|
WebSocket API の接続時間 | 2 時間 |
アイドル接続のタイムアウト | 10 分 |
アイドル接続とは、サーバーとクライアントの間でメッセージのやり取りがされていない状態の接続で、これが10分間継続すると API Gateway 側から自動的に切断されてしまいます。対策としてはサーバー側もしくはクライアント側から10分以内の間隔で定期的にメッセージを送り、アイドル接続の状態をリセットするということが挙げられます。この対策を行っていたとしても、そもそもの WebSocket API の接続時間が2時間と定められているため、必要に応じてクライアント側で再接続を行うなどの工夫が必要です。
その他のクォータについてはこちらを参照してください。
パスパラメータがサポートされていない
クライアントからサーバーへ接続のリクエストをする際の URL にパスパラメータが含まれていた場合、 API Gateway の WebSocket API ではステージ名までのパスしかサポートしていないためエラーが発生します。これを回避するためにはクライアントによるパスパラメータの使用を不可にするのですが、外部システムがクライアントとして接続にくる場合、その仕様によってはリクエストにパスパラメータが含まれる可能性があります。(今回作成したサーバーに接続にくるクライアントはそうでした)
具体例としては以下のようなリクエストです。
wss://example.com/v1/{id}
# wss://example.com/v1 : WebSocket サーバーの URL
# id : クライアントを識別するための id
これに対応するために別の AWS のサービスである CloudFront を使い、以下のようなシステム構成にしました。
CloudFront は通常静的コンテンツの配信などに使われるサービスですが、今回は CloudFront の CloudFront Functions という機能を使うことで、パスパラメータを含むリクエストに対処していきます。CloudFront Functions では CloudFront を通過するリクエストとレスポンスに対して変更を行う関数を実行できます。そのため、以下のような関数を実行することでパスパラメータをクエリパラメータに変換して WebSocket API に渡しました。
const handler = (event) => {
const request = event.request;
// パスパラメータをクエリパラメータに変換
request.querystring["_path"] = { value: request.uri };
// パスパラメータを削除(上書き)
request.uri = "/";
return request;
};
CloudFront Functions のイベントオブジェクトの構造はこちらで確認できます。
$default ルートの活用
今回作成したサーバーでは、外部システムの仕様上メッセージに配列形式を用いていました。配列形式であってもルート選択式で評価し、カスタムルートを呼び出すことは可能です。( $request.body.[0] のようにする)しかし、配列形式以外のメッセージが来た場合には $default ルートを呼び出すという設計にしたかったのですが、 API Gateway からエラーレスポンスがあったため取り扱いに困る場面がありました。
ちなみにドキュメントには以下のような記載があります。
「ルート選択式をメッセージに対して評価できない場合や、一致するルートが見つからない場合、API Gateway は $default ルートを呼び出します。」(参考)
「ルート選択式をメッセージに対して評価できない場合」とあるため、このケースでは $default ルートが呼び出されるはずです。これはおかしいぞ?と思いサポートに問い合わせてみたところ、現在の API Gateway の仕様と返されてしまいました、、
ルート選択式にオブジェクトの属性名を指定した場合は、オブジェクト形式以外のメッセージを受信したときに $default ルートで処理されますが、外部システムの仕様上メッセージをオブジェクトにできませんでした。そこで、ルート選択式を \$default とすることで常に $default ルートを呼び出せる API Gateway の仕様を使うことにしました。受信したメッセージはすべて$defaultルートを呼び出し、ルーティングを委任する設計にすることで、上記の仕様を回避できました。
※この記事を執筆するにあたり上記の仕様を再度確認したところ、ルート選択式に配列要素を指定し、配列形式以外のメッセージを送信しても問題なく $default ルートが呼び出されました。おそらく修正が入ったのだと思います。
WebSocket API のよかったところ
WebSocket サーバーを構築するにあたり、 API Gateway の WebSocket API を使ってよかったところを上げていきたいと思います。
1つはコネクションの管理を任せられるという点です。自前で WebSocket サーバーを構築する場合、どのくらいのコネクション数までなら負荷に耐えられるのか、ずっと接続しっぱなしのコネクションがある場合にどうするのかなど考慮する点がたくさんあります。しかし、 API Gateway の WebSocket API ではクォータで接続数の上限が把握できたり、通信のない接続は自動で切断してくれたり、と考慮する必要がなかったため、処理の実装に時間を割くことができました。
次に開発がとても容易になるという点です。上記の通り、コネクション管理だけでなく、ルートを使ったルーティングなどもしてくれるため、実際には処理を行うための Lambda 関数を実装するだけで WebSocket サーバーを構築できました。また、サーバーからのメッセージ送信についても@connections API を用いることで自前で実装することなく簡単に行うことができました。
まとめ
今回は API Gateway の WebSocket API について理解したことや、実際にサーバーを立てる際に注意したことなどをまとめました。外部システムの仕様上特殊な使い方になった部分もありましたが、おおむね使いやすく、スムーズに開発できました。大まかな理解や注意する点を踏まえて、これから WebSocket サーバーを構築する際の一助になれば幸いです。
最後まで読んでいただきありがとうございました。