API Gateway + WebSocket の基本的な使い方を調べてみた

西田@大阪です。

API Gateway で WebSocket を利用する場合の基本的な使い方や料金、Serverless Framework でのデプロイ方法について調べてみました

API Gateway で WebSocket を利用する場合、まずクライアントとAPI Gateway がステートフルなコネクションを確立します。

そして、リクエストの内容や、特殊なイベントに応じてルーティングされ、ルーティングに応じた Lambda を起動します。

起動されたLambdaの処理の中でクライアントを指定しメッセージを送信します

料金

必要な料金は以下の3つです

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

※ 東京リージョンの料金です。参考: https://aws.amazon.com/jp/api-gateway/pricing/

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

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

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

※ 常時接続のコネクションはユーザーが意図せずに保持されることも多いので注意は必要です

ルーティング

ルーティングは ルート選択式(RouteSelectionExpression)と呼ばれる、メッセージ内に含める文字列によるルーティングと 特殊なルート によってルーティングされます

ルート選択式(RouteSelectionExpression)

ルート選択式にはJSON形式で送られてるくるメッセージのJSONPathを指定します

例をあげますと、以下のJSONがクライアントから送られてきたとします

{
  "action": "actionRoute",
  "service": "serviceRoute",
  "data": {
    "parameter": "aabbcc"
  }
}

この場合ルート選択式が以下の場合actionRouteにルーティングされます

$request.body.action

また、以下のルート選択式の場合 serivceRoute にルーティングされます

$request.body.service

複数の変数を設定することも可能で、以下の場合 actionRouteserviceRoute両方にルーティングされます

${request.body.action}/${request.body.service}

参考: ルート選択式

特殊なルート

キー 説明
$connect クライアント接続時にルーティングされます。認証・認可、また接続ユーザーを管理したい場合に使えます
$disconnect クライアント切断時にルーティングされます。リソースの後処理などが必要な場合に使えます
$default すべてのルートにはずれた場合にルーティングされます。エラー処理やフォールバック、また別のバックエンドに流すためのプロキシとして使いたい場合に使えます

クライアントとの通信時のフローは以下の通りです

  1. クライアントから API Gateway に接続時に $connect に設定されたLambdaが起動します
  2. メッセージ内のボディに含まれるキーをもとにルーティングされ、ルーティングに応じたLambdaが起動します
  3. クライアントから切断、もしくはタイムアウトによる切断が行われたときに $disconnectに設定された Lambdaが起動します

Serverless Framework を使ってデプロイ

今回は Serverless Framework で python を使ってのデプロイする例をご紹介します

※ サンプルとしてコードはGithubにアップしております

serverless.yml

provider.websocketsApiRouteSelectionExpression にルート選択式を指定します

provider:
  name: aws
  runtime: python3.8
  # ルート選択式
  websocketsApiRouteSelectionExpression: $request.body.action

続いてLambdaを起動するルーティングの設定をします

  • 接続時のルーティング($connect) => handler.connect_handler
  • ルート選択式で指定された値が actionRouteの場合 => handler.action_handler
  • 切断時のルーティング($disconnect) => handler.disconnect_handler
  • デフォルトのルーティング($default) => handler.default_handler
functions:
  coonectHandler:
    handler: handler.connect_handler
    events:
      - websocket: $connect
  actionHandler:
    handler: handler.action_handler
    events:
      - websocket: actionRoute
  disconnectHandler:
    handler: handler.disconnect_handler
    events:
      - websocket: $disconnect
  defaultHandler:
    handler: handler.default_handler
    events:
      - websocket: $default

参考: Serverless Framework - AWS Lambda Events - Websocket

handler

サンプルのhandlerの一部をご説明します

クライアントから接続があった場合にルーティングされる connect_handlerです。サンプルは statusCodeに200を返しておりますが、接続を拒否したい場合は500等を返すようにします

def connect_handler(event, context):
    return {
        "statusCode": 200
    }

次にリクエストで送られてきたメッセージの action要素のValueに actionRouteという文字列が設定されていた場合に起動するaction_handlerです。

メッセージを送信してきたクライアントに対し {"message": "sample"}というメッセージを送信するといった単純な処理をおこなっています

具体的な手順としては以下です

  1. eventに渡されてくる RequestContextからconnectionIdを取得します
  2. API Gatewayに接続されているクライアントにメッセージを送信するためboto3の ApiGatewayManagementApi を操作するためのインスタンスを生成します。その際にendpoint_urlが必要となるので、requestContextに含まれるドメイン名とstageを使って生成します
  3. ApiGatewayManagementApipost_to_connectionメソッドでクライアントにメッセージを送信します
def action_handler(event, context):
    connection_id = event["requestContext"]["connectionId"]

    apigw = get_apigw_management_client(event)

    apigw.post_to_connection(
        ConnectionId=connection_id,
        Data=json.dumps({
            "message": "sample"
        })
    )

    return {
        "statusCode": 200
    }

def get_apigw_management_client(event):
    domain = event["requestContext"]["domainName"]
    stage = event["requestContext"]["stage"]

    return boto3.client('apigatewaymanagementapi', endpoint_url=f'https://{domain}/{stage}')

client.html

WebSocket 通信用のインスタンスを生成します

const ws = new WebSocket("wss://${YOUR-WEB-SOCKET-ENDPOINT}")

生成したインスタンスにメッセージ受信のハンドラを設定し、その中でJSON文字列をパースしています

ws.onmessage = function (e) {
  const data = JSON.parse((e.data))
  // ...
}

action属性にactionRouteを設定したメッセージを送信しています

const message = {
  "action": "actionRoute",
  "service": "serviceRoute",
  "data": {
    "parameter": "aabbcc"
  }
}

ws.send(JSON.stringify(message))

最後に

常時接続のサーバーを管理するのは大変なためWebSocketの利用を諦めた方も多いのではないでしょうか?お手軽に試せる上に、サーバーレスで管理もだいぶ楽になったので、これを気に試してみると良いかもしれません。