WebSocket API

API Gateway の WebSocket API でサーバーレスなリアルタイム双方向通信を実現する仕組み

AWSリアルタイム

WebSocket API とは

API GatewayWebSocket API は、サーバーレスアーキテクチャでリアルタイム双方向通信を実現する仕組みである。チャット、通知、ダッシュボードのリアルタイム更新など、サーバーからクライアントへのプッシュ配信が必要なユースケースで使われる。

通常の REST API はリクエスト/レスポンスの 1 往復で完結するが、WebSocket API は接続を維持し、サーバーとクライアントが任意のタイミングでメッセージを送受信できる。

アーキテクチャ

API Gateway WebSocket API はクライアントとの双方向通信を管理し、接続・切断・メッセージ受信のイベントごとに Lambda を呼び出す。接続 ID は DynamoDB に保存し、サーバーからクライアントへのプッシュ送信時に接続 ID を指定してメッセージを送る。

[クライアント]
   |  ^
   |  |   wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}
   v  |
[API Gateway WebSocket API]
   |  ^
   |  |   @connections API でプッシュ送信 (署名付きの API 呼び出し)
   v  |
[Lambda 関数][DynamoDB (接続管理)]

ルート:
  $connectConnectHandler (接続 ID を DynamoDB に保存)
  $disconnectDisconnectHandler (接続 ID を削除)
  $defaultMessageHandler (振り分けられなかったメッセージを処理)
  sendMessageSendHandler (カスタムルート)

ルートの振り分け方

上の sendMessage のようなカスタムルートは、ルート名を作るだけでは呼ばれない。API 側にルート選択式を設定し、受信したメッセージのどこを見てルート名を決めるかを宣言する必要がある。よく使う形は次の 1 行で、メッセージ本文を JSON として解釈し、その action プロパティの値をルートキーとして扱う。

${request.body.action}

この設定であれば、クライアントが {"action":"sendMessage","data":"hi"} を送ると sendMessage ルートの統合先が呼ばれる。ルートキーには「式を評価した結果として期待される値」を書く点に注意する。式が ${request.body.action} なのにルートキーを action と書いても一致しない。

$connect / $disconnect / $default の 3 つは事前定義ルートで、ルート選択式の評価対象ではない。$default に流れてくるのは次の 2 つの場合である。

  • ルート選択式を評価できなかった (本文が JSON でない、指定したプロパティが無い)
  • 評価できたが、その値に一致するルートが定義されていない

つまり $default は「メッセージ受信の既定ハンドラー」ではなく「振り分けに失敗したメッセージの受け皿」である。プレーンテキストのように JSON 以外のメッセージを受け取る設計にするなら、$default ルートを必ず用意しておく。また、ルート・統合・オーソライザーを追加または変更しても、ステージへデプロイし直すまでは反映されない。

接続管理

WebSocket API では、接続中のクライアントを DynamoDB で管理する。

// $connect: 接続時に接続 ID を保存
export const connectHandler = async (event: APIGatewayProxyEvent) => {
  await ddb.send(new PutCommand({
    TableName: 'Connections',
    Item: {
      connectionId: event.requestContext.connectionId,
      connectedAt: new Date().toISOString(),
    },
  }));
  return { statusCode: 200 };
};

// サーバーからクライアントにメッセージを送信
const apigw = new ApiGatewayManagementApiClient({
  endpoint: `https://${domainName}/${stage}`,
});

await apigw.send(new PostToConnectionCommand({
  ConnectionId: connectionId,
  Data: JSON.stringify({ message: 'Hello!' }),
}));

endpoint に渡すのは https:// で始まる管理用のエンドポイントで、クライアントが繋ぐ wss:// の URL とは別物である。API Gateway の既定ドメインなら https://{api-id}.execute-api.{region}.amazonaws.com/{stage} の形になり、カスタムドメインを使っている場合はステージ部分を含めない。プッシュ送信は署名を必要とする API 呼び出しなので、送信側 Lambda の実行ロールに execute-api:ManageConnections の許可を与えておく。

ブロードキャスト (全クライアントに送信)

全員に配信する処理は、接続 ID を全件読み出して 1 件ずつ送るだけに見えて、2 か所つまずく。1 つは Scan が 1 回の呼び出しで最大 1 MB 分しか返さないことで、接続が増えると残りが LastEvaluatedKey の先に取り残される。もう 1 つは切断済みの接続 ID がテーブルに残ることで、$disconnect は接続が閉じたあとに実行されるイベントであり配信は保証されないため、削除の取りこぼしが必ず出る。送信が失敗したときにその接続 ID を消す処理を、掃除の本線として持たせておく。

// 1 接続に送る。既に切れていた接続 ID はここで掃除する
const send = async (connectionId: string, payload: string) => {
  try {
    await apigw.send(new PostToConnectionCommand({
      ConnectionId: connectionId,
      Data: payload,
    }));
  } catch (err) {
    // 接続確立前・切断後の送信は GoneException で返る
    // SDK v3 の例外は name と $metadata を持つ形なので、v2 の err.statusCode では判定できない
    if (err instanceof Error && err.name === 'GoneException') {
      await ddb.send(new DeleteCommand({
        TableName: 'Connections',
        Key: { connectionId },
      }));
      return;
    }
    throw err;
  }
};

// DynamoDB から全接続 ID を取得 (1 ページ 1 MB 上限のためページを繰る)
const payload = JSON.stringify({ message: 'Broadcast!' });
let lastKey: Record<string, any> | undefined;

do {
  const page = await ddb.send(new ScanCommand({
    TableName: 'Connections',
    ProjectionExpression: 'connectionId',
    ExclusiveStartKey: lastKey,
  }));
  await Promise.allSettled(
    (page.Items ?? []).map(conn => send(conn.connectionId, payload)),
  );
  lastKey = page.LastEvaluatedKey;
} while (lastKey);

上限値と切断コード

WebSocket API の設計は、次の上限値を知っているかどうかで結果が変わる (2026 年 8 月時点)。

項目引き上げ
接続の最大継続時間2 時間不可
アイドルタイムアウト10 分不可
フレームサイズ32 KB不可
メッセージのペイロード128 KB不可
新規接続数1 秒あたり 500 (アカウント・リージョンごと)
統合のタイムアウト50 ミリ秒 - 29 秒不可

同時接続数そのものに上限は設けられていない。制約になるのは 1 秒あたりの新規接続数の方で、多数のクライアントが一斉に繋ぎ直す場面 (デプロイ直後や障害復旧時) で先に当たる。

継続時間の 2 時間とアイドル 10 分は変更できないため、接続はいずれ切れるものとして組む。10 分間メッセージのやり取りが無いだけで切られるので、アプリケーション層で定期的に短いメッセージを流すか、切断されたら再接続して状態を取り直す作りにしておく。

サイズの上限は 2 段ある。1 メッセージのペイロードは 128 KB までで、さらに 1 フレームは 32 KB までなので、32 KB を超えるメッセージは送信側が複数フレームに分割して送る必要がある。この分割の要求は @connections からのプッシュ送信にも同じく適用される。上限を超えたメッセージやフレームを送ると、その接続は閉じられる。

API Gateway が接続を閉じるときのコードは切り分けの手がかりになる。

コード意味
1001アイドル 10 分、または継続時間 2 時間に到達した
1003バイナリメディアタイプを送った (WebSocket API では扱えない)
10081 つのクライアントからのリクエストが多すぎる
1009メッセージまたはフレームが上限を超えている

SSE との使い分け

サーバーからクライアントへ流すだけで足りるなら SSE (Server-Sent Events) でも成立し、接続 ID の保存と掃除が不要になる分だけ運用が軽くなる。クライアントからも随時送るのか、配信だけなのかで選ぶ。

観点WebSocket APISSE (Server-Sent Events)
通信方向双方向サーバー → クライアント
AWS 実装API Gateway WebSocketLambda Function URL
接続管理DynamoDB で自前管理不要
適するケースチャット、ゲーム通知、ダッシュボード

採用するときの判断基準

  • 切断は例外ではなく前提: 2 時間の上限とアイドル切断があるため、クライアント側の再接続と状態の取り直しは必須の実装になる。ここを省くと「画面を開いたまま放置すると更新が止まる」という形で表に出る。
  • 接続 ID の掃除を 2 系統持つ: $disconnect での削除だけに頼らず、送信失敗時の削除と、connectedAt を使った期限切れ削除の両方を用意する。残った接続 ID は送信失敗と無駄な課金になって積み上がる。
  • 1 メッセージで運べる量: ペイロード 128 KB を前提に、大きなデータは本体を別の場所に置いて参照だけを流す設計へ寄せる。
  • 一方向で足りるなら選ばない: 接続管理という運用の手数が消えるため、通知やダッシュボード更新のような用途では SSE の方が構成が単純になる。

この記事は役に立ちましたか?

関連用語

関連する記事