Amazon API GatewayのWebSocket APIは、接続の維持をAWSに任せ、メッセージごとにLambdaを呼び出す仕組みです。サーバーを持たずにチャットや通知を作れる反面、接続IDの保存先は自分で用意しなければなりません。この記事では、$connectと$disconnectのルートでDynamoDBに接続IDを出し入れする実装を、CLIとPythonのコードで組み立てます。接続2時間・アイドル10分・フレーム32KBの上限、切断イベントの取りこぼしへの備え、wscatでの確認手順、採用してよい条件までが、この記事で解説する具体的な対象です。ゲートウェイそのものの役割はAPIゲートウェイとは?役割・機能とリバースプロキシ/サービスメッシュとの違い・導入判断を解説で整理しています。
まとめ:API GatewayのWebSocket APIで接続管理を組むときの結論
API GatewayのWebSocket APIは、接続の受け付けと維持をAWSが担い、アプリ側は「接続IDの台帳」と「送り返す処理」だけを書く構成です。台帳はDynamoDBの1テーブルで足ります。$connectで書き込み、$disconnectで消し、送信時はpost_to_connectionで1件ずつ届けます。
実装で外せないのは3点です。$disconnectは届かないことがあるので、TTL属性で古い行を自動削除し、送信時のGoneExceptionでも行を消します。接続は2時間で必ず切れ、10分無通信でも切れるため、クライアントへの再接続とハートビートの組み込みが必要です。ブラウザのWebSocketは独自ヘッダーを送れないので、認証トークンはクエリ文字列で$connectに渡します。
採用の目安は、利用者数が読めず、常駐サーバーの運用要員を置きたくない双方向通信です。1万人規模へ同じメッセージを頻繁に配る用途や、2時間を超える常時接続が前提の用途は、コンテナで自前のWebSocketサーバーを持つほうが素直に組めます。
API GatewayのWebSocket API|ルート・接続ID・コールバックURL
REST APIやHTTP APIはリクエストごとに完結しますが、WebSocket APIは接続を保持したまま、届いたメッセージをルートに振り分けます。まず3つの部品を押さえます。
$connect・$disconnect・$defaultとカスタムルートの4種の役割
ルートの作成手順を説明した公式ドキュメントによると、あらかじめ用意されたルートは$connect・$disconnect・$defaultの3つで、これにカスタムルートを足せます。$connectは接続の確立時、$disconnectは切断後、$defaultはどのルートにも一致しないメッセージが来たときに呼ばれます。
カスタムルートのキーに$は付けられません。sendmessageやjoinのように、業務の操作名をそのまま付けます。ハンドシェイクやフレームの仕組みそのものはWebSocketとは?仕組み・ハンドシェイクからNode.js実装・運用の判断まで実装者向けに解説で扱っており、本記事はAPI Gateway側の設定に絞ります。
ルート選択式$request.body.actionでメッセージを振り分ける仕組み
どのルートに渡すかは、API単位で1つ設定する「ルート選択式」で決まります。$request.body.actionと設定すると、クライアントが送ったJSONのactionの値がルートキーとの照合対象です。{"action":"sendmessage","message":"hi"}ならsendmessageルートが動きます。
JSONでないメッセージや一致しない値は$defaultへ回り、$defaultも無ければエラーが返ります。公式ドキュメントは、評価結果が配列やオブジェクトになると文字列化されるため、単一の値を指す式にするよう勧めています。
接続ID宛てに@connections APIでサーバーから送り返す経路と権限
サーバーからクライアントへ送るときは、接続IDを指定して@connectionsというAPIを呼びます。@connectionsの使い方を説明した公式ドキュメントには、送信がPOST、状態確認がGET、強制切断がDELETEと書かれています。
この呼び出しはIAMによる認可の対象で、SigV4署名が必要です。SDKのApiGatewayManagementApiクライアントを使えば署名は自動で付きます。エンドポイントはhttps://{api-id}.execute-api.{region}.amazonaws.com/{stage}で、カスタムドメインを使う場合はステージ名を外します。
WebSocket APIの上限値|接続2時間・アイドル10分・フレーム32KBの制約
設計の前提になるのはクォータです。どれも引き上げられない値が多く、後から構成を変えにくい部分なので、先に一覧で確認します。
クォータ表で確認する接続時間・メッセージサイズ・新規接続数の上限
WebSocket APIのクォータ表から、実装に響く項目を2026年10月時点の値で抜き出しました。
| 項目 | 既定値 | 引き上げ | 実装への影響 |
|---|---|---|---|
| 接続時間 | 2時間 | 不可 | 再接続処理が必須 |
| アイドル接続タイムアウト | 10分 | 不可 | 定期送信で切断を防ぐ |
| フレームサイズ | 32KB | 不可 | 超過は1009で切断 |
| メッセージペイロード | 128KB | 不可 | 大きな本文はS3経由 |
| 新規接続数(Region単位) | 500件/秒 | 可 | 一斉再接続の山に注意 |
| 統合タイムアウト | 50ミリ秒〜29秒 | 不可 | 重い処理は非同期化 |
| ルート数 | 300 | 可 | 操作名の粒度を絞る |
同時接続数そのものには上限がありません。表の注記では、毎秒500件で2時間つなぎ続けた場合に最大3,600,000接続を扱えると説明されています。統合タイムアウトの29秒はREST APIと違って引き上げられず、ほかのAPI種別との比較はAPI Gatewayのタイムアウト:29秒の上限を引き上げる手順と504の切り分け・非同期化にまとめています。
アイドル10分で切れる前にクライアントからハートビートを送る実装
ブラウザのWebSocketには、スクリプトからpingフレームを送るメソッドがありません。WHATWGのWebSockets仕様は、pingとpongを「現在APIには公開していない」と明記しています。そのため、10分より短い間隔でアプリ層の小さなメッセージを送って接続を保ちます。
// API Gatewayのアイドル10分より短い5分間隔で、アプリ層のpingを送る
const url = "wss://abcdef123.execute-api.ap-northeast-1.amazonaws.com/production"
+ "?token=" + encodeURIComponent(idToken);
const ws = new WebSocket(url);
const timer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ action: "ping" }));
}, 5 * 60 * 1000);
ws.addEventListener("close", () => clearInterval(timer)); // 2時間の上限でも閉じるので、ここで再接続を予約する
このメッセージは課金対象です。料金ページは制御フレームのping・pongを数えないとしていますが、アプリ層のJSONは1件として数えられます。5分間隔なら1接続あたり1時間12件で、東京の単価に当てはめた試算はAPI Gatewayの料金を東京リージョンの実額で計算する:REST・HTTP・WebSocketの単価と付帯課金で確かめられます。
CLIによるWebSocket API作成とLambda・$connectの統合手順
ここからは東京リージョン(ap-northeast-1)にAPIを作ります。アカウントIDやAPI IDは例示の値なので、自分の環境の値に置き換えてください。
create-apiでWEBSOCKETプロトコルのAPIとルート選択式を作成する
WebSocket APIの作成手順どおり、--protocol-type WEBSOCKETとルート選択式を指定します。作成後にプロトコルは変えられず、エンドポイントはRegionalだけ、利用可能なTLSのバージョンは1.2と1.3だけです。接続IDを入れるDynamoDBテーブルも同時に作り、TTLを有効にしておきます。
aws apigatewayv2 create-api --name ws-chat --protocol-type WEBSOCKET \
--route-selection-expression '$request.body.action' --region ap-northeast-1
aws dynamodb create-table --table-name ws-connections \
--attribute-definitions AttributeName=connectionId,AttributeType=S \
--key-schema AttributeName=connectionId,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
aws dynamodb update-time-to-live --table-name ws-connections \
--time-to-live-specification "Enabled=true, AttributeName=expiresAt"
出力のApiIdを控えます。ルート選択式をシングルクォートで囲むのは、シェルが$requestを変数として展開しないようにするためです。
create-integrationとcreate-routeによる接続統合
Lambda関数を作った後、統合を作成してルートの--targetに指定します。API GatewayからLambdaを呼ぶ許可も忘れずに付けます。
aws apigatewayv2 create-integration --api-id abcdef123 --integration-type AWS_PROXY \
--integration-uri arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/arn:aws:lambda:ap-northeast-1:111122223333:function:ws-connect/invocations
aws apigatewayv2 create-route --api-id abcdef123 --route-key '$connect' \
--target integrations/xyz789
aws lambda add-permission --function-name ws-connect --statement-id apigw-ws \
--action lambda:InvokeFunction --principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:ap-northeast-1:111122223333:abcdef123/*"
aws apigatewayv2 create-stage --api-id abcdef123 --stage-name production --auto-deploy
xyz789はcreate-integrationが返すIntegrationIdです。$disconnectとsendmessageも同じ手順で統合とルートを作ります。引数の詳細はcreate-routeのCLIリファレンスとcreate-integrationのCLIリファレンスに載っています。
LambdaとDynamoDBで接続IDを保存・削除・配信するPythonコードの書き方
Lambdaは3本に分けます。接続の登録、削除、配信です。どれもLambdaプロキシ統合で受け、イベントのrequestContextから接続IDを取り出します。
$connectで接続IDとユーザーIDをDynamoDBへ書き込むハンドラー
$connectと$disconnectの挙動を説明した公式ドキュメントによると、$connectの統合が終わるまで接続は確立せず、失敗すれば接続そのものが張られません。書き込みに失敗したら200以外を返し、台帳に載らない接続を作らないようにします。
import os
import time
import boto3
table = boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"])
TTL_SECONDS = 2 * 60 * 60 + 300 # 接続上限の2時間に5分の余裕を足す
def lambda_handler(event, context):
ctx = event["requestContext"]
qs = event.get("queryStringParameters") or {}
# Lambdaオーソライザーが返したcontextはrequestContext.authorizerに入る
user_id = (ctx.get("authorizer") or {}).get("userId", "anonymous")
table.put_item(Item={
"connectionId": ctx["connectionId"],
"userId": user_id,
"roomId": qs.get("room", "lobby"),
"expiresAt": int(time.time()) + TTL_SECONDS,
})
return {"statusCode": 200}
接続IDは可変長の文字列で、長さや形式が変わる可能性があると公式ドキュメントに書かれています。正規表現で形式を検査したり、列の長さを固定したりしないでください。
$disconnectの取りこぼしに備えTTL属性で古い接続を自動削除する
$disconnectのハンドラーは、接続IDをキーにdelete_itemを呼ぶだけです。ただし同じ公式ドキュメントは、$disconnectを「best-effort」のイベントとし、配信を保証しないと書いています。端末の電源断や回線切れでは、行が台帳に残ることがあります。
そこで、$connectで書いたexpiresAtをDynamoDBのTTLに使います。値はエポック秒のNumber型で、期限切れの行は通常数日以内に削除され、書き込みスループットを消費しません。削除は即時ではないので、配信側でも期限切れの行を読み飛ばします。
post_to_connectionの配信とGoneException時の削除処理
sendmessageルートのLambdaは、台帳から宛先を読み、boto3のpost_to_connectionで1件ずつ送ります。この関数はGoneException・LimitExceededException・PayloadTooLargeException・ForbiddenExceptionを返しえます。
import json
import os
import time
import boto3
from boto3.dynamodb.conditions import Key
table = boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"])
def lambda_handler(event, context):
ctx = event["requestContext"]
# カスタムドメインで公開している場合はステージ名を付けない
endpoint = f"https://{ctx['domainName']}/{ctx['stage']}"
api = boto3.client("apigatewaymanagementapi", endpoint_url=endpoint)
body = json.loads(event.get("body") or "{}")
data = json.dumps({"message": body.get("message", "")}, ensure_ascii=False).encode("utf-8")
# roomIdをパーティションキーにしたGSI(room-index)で同じ部屋の接続だけを引く
items = table.query(IndexName="room-index",
KeyConditionExpression=Key("roomId").eq(body.get("room", "lobby")))["Items"]
now = int(time.time())
for item in items:
if item["expiresAt"] < now:
continue # TTLの削除待ちの行は送らない
try:
api.post_to_connection(ConnectionId=item["connectionId"], Data=data)
except api.exceptions.GoneException:
# すでに切れている接続は台帳から消す
table.delete_item(Key={"connectionId": item["connectionId"]})
return {"statusCode": 200}
GSIは事前にroomIdをキーにして作っておきます。テーブル全体をscanする書き方は公式チュートリアルにもありますが、接続数に比例して読み取り量と処理時間が伸びる書き方です。1部屋の人数が数百を超える配信では、宛先を分割してSQSに積み、複数のLambdaで並列に送る構成に切り替えます。
Lambdaのexecute-api:ManageConnections付与方針
配信用Lambdaには、@connectionsを呼ぶexecute-api:ManageConnectionsが必要です。WebSocket APIのIAM認可の公式ドキュメントに、@connectionsのARN形式が示されています。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "execute-api:ManageConnections",
"Resource": "arn:aws:execute-api:ap-northeast-1:111122223333:abcdef123/production/POST/@connections/*"
},
{
"Effect": "Allow",
"Action": ["dynamodb:Query", "dynamodb:DeleteItem"],
"Resource": [
"arn:aws:dynamodb:ap-northeast-1:111122223333:table/ws-connections",
"arn:aws:dynamodb:ap-northeast-1:111122223333:table/ws-connections/index/room-index"
]
}
]
}
権限が足りないとForbiddenExceptionになります。$connect用のLambdaにはdynamodb:PutItem、$disconnect用にはdynamodb:DeleteItemだけを付け、関数ごとにロールを分けます。
wscatで接続・送信・切断を試し認証付きの$connectを確かめる手順
デプロイしたら、ブラウザを作る前にコマンドラインで動きを確かめます。npmで入れられるwscatが手軽です。
wscatで2端末から接続しsendmessageルートの配信を確認する作業
公式のチャットアプリのチュートリアルと同じ流れで、ターミナルを2つ開いて両方から接続します。
npm install -g wscat
wscat -c "wss://abcdef123.execute-api.ap-northeast-1.amazonaws.com/production?room=lobby&token=TEST"
Connected (press CTRL+C to quit)
> {"action": "sendmessage", "room": "lobby", "message": "hello"}
< {"message": "hello"}
片方から送ったメッセージがもう片方にも届けば、ルートと配信は動いています。Ctrl+Cで切った後にDynamoDBの行が消えていれば、$disconnectも届いたと判断できる状態です。消えていない場合は、ルートの統合とadd-permissionの付け漏れを先に疑います。
ブラウザはヘッダーを付けられないためクエリ文字列でトークンを渡す
認可を掛けられるのは$connectだけで、Lambdaオーソライザーの公式ドキュメントもREQUEST型を$connectに付ける前提で書かれています。この設定はAPI全体に効き、$connectを通らない接続は存在しないため、ほかのルートもまとめて守られます。
問題はトークンの渡し方です。WHATWG仕様のコンストラクタはurlとprotocolsしか受け取らず、Authorizationヘッダーを足せません。ブラウザからはクエリ文字列で渡し、オーソライザーのIDソースにクエリ文字列を指定します。トークンが無いか無効なら、接続は401で拒否されます。
クエリ文字列はアクセスログやCDNのログに残りやすいので、渡すのは有効期限が数分の使い捨てトークンにします。ログに何が出るかはAPI Gatewayのログ設定:実行ログとアクセスログをCloudWatchに出す手順とX-Rayでの障害調査の書式で確認できます。
API GatewayのWebSocketを採用する条件と見送るべき構成の判断基準
ここまでの上限と実装の手間を踏まえ、採用の可否を条件で言い切ります。
接続数が読めず運用要員を置けない双方向通信なら採用してよい条件
採用してよいのは、次の条件がそろうときです。社内の通知、問い合わせチャット、進捗の押し出し配信がこれに当たります。
- 同時接続数が日や時間帯で大きく揺れ、常駐サーバーの台数を決めにくい
- 1件のメッセージが128KB以内に収まり、配信先が部屋単位で数百人以内
- 2時間ごとの再接続を、クライアント側で吸収できる
- WebSocketサーバーの監視やスケール設定を担う運用要員を置かない
この条件なら、使った分だけの課金で始められ、接続の維持やTLS終端を自分で持たずに済みます。サーバー側から一方向に流すだけで足りるなら、WebSocketよりSSEのほうが単純です。
2時間を超える常時接続や大量配信が主なら自前WebSocketを選ぶ場面
見送るべきなのは、1万人規模の購読者へ同じメッセージを頻繁に配る用途、128KBを超えるデータを流し続ける用途、2時間を超えて切れない接続が業務要件になっている用途です。API Gatewayの配信は接続IDごとの呼び出しなので、購読者数に比例してLambdaの実行時間と課金が増えます。コンテナで自前のWebSocketサーバーを持ち、Pub/Subで台数を増やす構成のほうが安く、速く配れます。
一方向の配信だけなら、SSEとWebSocketの違いと使い分け|WebRTC・WebTransportまで性能比較【2026年10月】で比較したSSEも候補になります。リアルタイム通信を既存の業務システムやAPIとつなぐ設計で迷ったら、API開発・システム連携の受託開発でご相談いただけます。
よくある質問
API GatewayのWebSocket APIを実装するときによく聞かれる質問を5つ取り上げます。
API GatewayのWebSocketは同時に何接続まで扱えますか?
同時接続数そのものには上限が設けられていません。制約になるのは新規接続数で、既定ではアカウント・Region単位で毎秒500件です。公式のクォータ表は、毎秒500件で2時間つなぎ続けると最大3,600,000接続になると説明しています。障害後の一斉再接続でこの毎秒500件に当たりやすいので、クライアントの再接続には乱数の待ち時間を入れて山をならします。
WebSocketの接続が2時間や10分で切れるのはなぜですか?
API GatewayのWebSocket APIには、接続時間2時間とアイドル接続タイムアウト10分の上限があり、どちらも引き上げられません。10分の無通信で切れる場合は、5分程度の間隔でアプリ層のメッセージを送ってください。2時間の上限は避けられないため、closeイベントで再接続し、再接続後に未読のメッセージを取り直す仕組みをクライアント側に持たせます。
$disconnectが呼ばれずDynamoDBに接続IDが残るのはなぜですか?
公式ドキュメントが$disconnectをbest-effortのイベントとし、配信を保証していないためです。端末の電源断や通信断では呼ばれないことがあります。対策は2つです。$connectで有効期限をエポック秒で書き込んでDynamoDBのTTLで消すことと、送信時にGoneExceptionが返った接続IDをその場で削除することです。
WebSocket APIの認証はどう実装すればよいですか?
$connectルートにLambdaオーソライザー(REQUEST型)かIAM認可を設定します。認可を掛けられるのは接続時だけで、その設定の適用範囲はAPI全体です。ブラウザのWebSocketはヘッダーを追加できないため、トークンはクエリ文字列で渡し、オーソライザーのIDソースにクエリ文字列を指定します。ログに残る前提で、有効期限の短いトークンを使ってください。
サーバー側からクライアントへメッセージを送るには何が必要ですか?
接続IDと、execute-api:ManageConnectionsの権限を持つ実行ロールが必要です。SDKのApiGatewayManagementApiクライアントを、https://{ドメイン}/{ステージ}をエンドポイントにして作り、post_to_connectionを呼びます。カスタムドメインを使う場合はステージ名を外す指定が必要です。接続が切れていればGoneExceptionが返ります。
関連記事
- APIゲートウェイとは?役割・機能とリバースプロキシ/サービスメッシュとの違い・導入判断を解説:APIゲートウェイの役割と導入判断。
- WebSocketとは?仕組み・ハンドシェイクからNode.js実装・運用の判断まで実装者向けに解説:プロトコルの仕組みと自前サーバーでの実装。
- API Gatewayの料金を東京リージョンの実額で計算する:REST・HTTP・WebSocketの単価と付帯課金:メッセージと接続時間の単価で月額を試算する手順。
- API Gatewayのタイムアウト:29秒の上限を引き上げる手順と504の切り分け・非同期化:API種別ごとの統合タイムアウトの上限。
- AWS サーバーレス ハンズオン|Lambda・DynamoDB・API GatewayでREST APIを作る手順【2026年版】:同じ部品でREST APIを作る手順。