Durable Objects の WebSocket Hibernation API でリアルタイムサーバーのコストを90%削減する

Durable Objects(DO)でリアルタイムチャットルームやマルチプレイヤーゲームルームを作ったことがあるなら、請求書で奇妙な項目を目にしたことがあるはずです。メッセージがほとんど行き交わない深夜の時間帯でも、Duration(GB-s)コストが昼間とほぼ同じペースで積み上がる現象です。犯人はほぼ間違いなく、server.accept() + addEventListener の組み合わせで書かれた標準WebSocketコードです。
要点まとめ
- Durable ObjectsのDuration(GB-s)課金は、DOが実際に処理をしているかどうかとは無関係に、メモリに常駐している壁時計時間(wall-clock time) を基準に課金されます。
- 標準WebSocket API(
ws.accept()+addEventListener)は、接続が1つでも開いている限りDOをメモリに保持し続けるため、アイドル時間にも課金がそのまま積み上がります。 - WebSocket Hibernation API(
ctx.acceptWebSocket()+webSocketMessage/webSocketClose/webSocketError)は、クライアント接続はCloudflareネットワークエッジに維持したまま、DOインスタンスだけをメモリから降ろします。ハイバネーション中はGB-s課金が一切発生しません。 - 以下では、公式料金表(2026年7月時点のdevelopers.cloudflare.comのドキュメント)をそのまま当てはめて計算したシミュレーション結果として、1秒に1回程度メッセージがやり取りされる「標準的なアクティブ度」のチャットルーム/ゲームルーム2,000個を1か月運用した場合、総コストが約90.6%削減されるという結果が出ました。実際の削減幅はトラフィックパターンによって49%〜98%の範囲で大きく変わります。
問題の発端:常駐型WebSocketがアイドル時間にも課金され続ける理由
Cloudflareの公式料金ドキュメントは、Durable ObjectsのDuration課金を次のように定義しています。Durationは**「Objectがアクティブな状態、またはアイドル状態だがハイバネーション対象になっていない間」の壁時計時間で課金**され、DOに割り当てられた128MBのメモリを基準に、実際のメモリ使用量とは無関係に計算されます。Workers Paidプラン基準で月40万GB-sが無料枠に含まれ、超過分は100万GB-sあたり$12.50です。
ここでの核心的な落とし穴は「アクティブな状態」の定義です。fetch()ハンドラの中でnew WebSocketPair()を作り、server.accept()を呼び出した後server.addEventListener("message", ...)でメッセージを受け取る標準的な方式では、このDOは次のイベントを受け取るためにJSのイベントリスナーをメモリに保持し続けなければなりません。つまり、接続しているクライアントがたった1人でもいれば、実際にメッセージがやり取りされていない時間帯でもDOインスタンスは追い出され(evict)ずに生き続けなければならず、その時間すべてがGB-sとして請求されます。
これはWorkersのCPU時間課金とはまったく異なる軸です。Workers PaidプランのCPU時間(月3,000万ms込み、超過時は100万msあたり$0.02)は実際にコードが実行されている時間だけを課金しますが、DOのDurationはコードが実行されずにただ待機しているだけでも課金されます。深夜3時に誰もチャットを打っていなくても、ゲームロビーに接続したまま席を外しているユーザーがいても、DOは生き続けなければならないため課金は続きます。
Hibernation APIが正確に何をするのか
WebSocket Hibernation APIはこの構造を逆転させます。公式ドキュメントによれば、DOがアイドル状態になると**「メモリから追い出され」ますが、「WebSocketクライアントはCloudflareネットワークに接続されたまま残ります」。クライアント側から見れば接続が切れたことは一度もなく、ping/pongも正常にやり取りされます。新しいイベント(メッセージの到着、接続の終了など)が入ってくると、ランタイムがコンストラクタを再度呼び出し**てDOインスタンスを再生成し、そのイベントを処理した後、再び眠らせます。
課金の観点で最も重要な一文はこれです。「ハイバネーション中はDuration(GB-s)課金が発生しません」。つまりDOが実際にメッセージを処理する短い瞬間にのみGB-sが積み上がり、残りのほとんどの時間は無料ということです。
ただし、いつでもハイバネーションされるわけではありません。ドキュメントはハイバネーションを妨げる要因を明記しています。
- アラーム(alarm)、受信中のリクエスト、予定されたコールバックはハイバネーションを妨げます。
setTimeout/setIntervalを使用している場合、タイマーが生きている間はハイバネーションされません。- 標準の
ws.accept()で受け付けた(ハイバネーション非対応の)WebSocketが1つでもあれば、その接続がDOを起こしたままにします。
一方、プロトコルレベルのping/pongフレームは例外です。ドキュメントは**「受信したpingフレームには自動的にpongが応答され、このping/pong処理はハイバネーションを妨げない」**と明記しています。つまり、よく使われる「30秒ごとにsetIntervalでpingを送る」パターンはHibernation APIとは相性が悪く、ほとんどの場合そのまま取り除いてしまって構いません。
移行: addEventListenerからwebSocketMessage/webSocketCloseへ
既存コード(ハイバネーション非対応)
export class ChatRoom {
constructor(state, env) {
this.state = state;
this.sessions = new Map(); // ws -> { username }
}
async fetch(request) {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept(); // 표준 accept — 하이버네이션 불가
this.sessions.set(server, { username: null });
server.addEventListener("message", (event) => {
this.broadcast(event.data);
});
server.addEventListener("close", () => {
this.sessions.delete(server);
});
// 30초마다 ping — setInterval이 DO를 계속 깨워 둔다
const heartbeat = setInterval(() => server.send("ping"), 30000);
server.addEventListener("close", () => clearInterval(heartbeat));
return new Response(null, { status: 101, webSocket: client });
}
broadcast(message) {
for (const ws of this.sessions.keys()) ws.send(message);
}
}
このコードはありふれた自然な書き方ですが、this.sessionsというインメモリMapとsetIntervalタイマーの両方が、DOをハイバネーション不可の状態にしてしまいます。
Hibernation API適用コード
export class ChatRoom {
constructor(ctx, env) {
this.ctx = ctx;
this.env = env;
// 생성자는 하이버네이션 후 깨어날 때마다 다시 호출된다.
// 인메모리 Map을 여기서 채우지 않고, getWebSockets()로 복원한다.
}
async fetch(request) {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// ws.accept() 대신 acceptWebSocket() — 하이버네이션 가능
this.ctx.acceptWebSocket(server);
// 연결별 메타데이터는 attachment로 저장 (최대 16,384바이트)
server.serializeAttachment({ username: null, joinedAt: Date.now() });
return new Response(null, { status: 101, webSocket: client });
}
// addEventListener 대신 DO 클래스 메서드로 정의
async webSocketMessage(ws, message) {
const data = JSON.parse(message);
const meta = ws.deserializeAttachment() ?? {};
if (data.type === "join") {
meta.username = data.username;
ws.serializeAttachment(meta);
}
this.broadcast(JSON.stringify({ from: meta.username, text: data.text }));
}
async webSocketClose(ws, code, reason, wasClean) {
ws.close(code, reason);
}
async webSocketError(ws, error) {
// 비정상 종료 로깅 등
console.error("WebSocket error:", error);
}
broadcast(message) {
for (const ws of this.ctx.getWebSockets()) ws.send(message);
}
}
変更点をまとめると:
server.accept()→this.ctx.acceptWebSocket(server)addEventListener("message", ...)→async webSocketMessage(ws, message)クラスメソッドaddEventListener("close", ...)→async webSocketClose(ws, code, reason, wasClean)- エラー処理は**
async webSocketError(ws, error)**として分離 - インメモリの
Mapでセッションを保持していた部分は、this.ctx.getWebSockets()(接続中の全ソケットを取得)と**ws.serializeAttachment()/ws.deserializeAttachment()**(接続ごとのメタデータ、最大16,384バイト、structured cloneでシリアライズ)に置き換え setIntervalのハートビートは完全に削除 — ping/pongはランタイムがプロトコルレベルで自動処理
wrangler.tomlでは、SQLiteバックエンドのDOを使うのが今どきの推奨経路です。
[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatRoom"]
チャットルーム・ゲームルームのパターン: 1つのDOで複数クライアントを調整する
チャットルームであれ、ターン制/リアルタイムのマルチプレイヤーゲームルームであれ、パターンは同じです。ルーム1つ = DOインスタンス1つにマッピングし、そのルームに接続しているすべてのクライアントのWebSocketを同じDOに保持させます。この構造では、ctx.getWebSockets()が「このルームにいる全クライアント」をそのまま返してくれるため、別途タグ付けやフィルタリングのロジックなしにブロードキャストが可能です。
ゲームルームであれば、webSocketMessageで受け取った入力をゲーム状態に反映し、毎ティックごと(または入力があるたび)にgetWebSockets()で走査してスナップショットを配信する形で拡張すればよいでしょう。ただし、DO1つあたりのスループットはソフトリミットとして1,000 req/s程度と知られており、受信メッセージサイズの上限は32MiBです。非常に人数の多いルーム(数百〜数千人同時接続)を1つのDOで処理しようとすると、まずこの上限にぶつかる可能性があるため、大規模ルームは複数のDOにシャーディングするか、ブロードキャストのファンアウトをバッチにまとめることを検討すべきです。
セッションごとの状態(ニックネーム、チーム、キャラクターの位置など)はserializeAttachmentでWebSocketオブジェクトに紐付けておけば、ハイバネーションをまたいで維持されます。ただし、attachmentには16,384バイトの上限があるため、ルーム全体の状態やチャットログのように大きくなっていくデータはここに入れず、DOの永続ストレージ(this.ctx.storage)に置くのが正解です。
ハイバネーション後、目覚めたときに状態を復元する戦略
何が生き残り、何が消えるのか
ハイバネーションが消し去るのはDOインスタンス(JavaScriptオブジェクトとその中の変数)であり、接続や永続データではありません。整理すると:
- 生き残るもの: WebSocket接続そのもの(クライアントは切断を感じない)、
serializeAttachmentで保存した接続ごとのメタデータ(最大16KB)、this.ctx.storageに書き込んだ永続データ。 - 消えるもの: コンストラクタで作ったインメモリの
Map/Set/通常の変数、実行中だったsetTimeout/setIntervalのタイマー、クロージャにキャプチャされた状態。
そのため移行の核心は、「コンストラクタが再度呼ばれても問題ないように」状態管理を再設計することです。実務では通常、次のように分けます。
- 接続ごとにのみ必要な短いメタデータ(ニックネーム、色、最後のハートビート時刻など) →
serializeAttachment - ルーム全体で共有する状態だが再計算可能なもの(現在の接続者数、オンラインユーザー一覧) → コンストラクタで
this.ctx.getWebSockets()を回し、各ソケットのattachmentを読んでその場で再構築 - 永続的に残すべきデータ(チャット履歴、ゲームスコア、ルーム設定) →
this.ctx.storage.get/putでSQLiteバックエンドに保存
export class ChatRoom {
constructor(ctx, env) {
this.ctx = ctx;
// 인메모리 캐시는 "복원 가능한 뷰"로만 취급한다.
// 실제 참조는 필요할 때마다 getWebSockets()로 다시 얻는다.
}
onlineUsernames() {
return this.ctx.getWebSockets()
.map((ws) => ws.deserializeAttachment()?.username)
.filter(Boolean);
}
}
ハートビートはsetIntervalではなくプロトコルレベルに任せる
先述の通り、プロトコルのping/pongはランタイムが自動処理し、ハイバネーションを妨げません。アプリケーションレベルで「このユーザーがまだ生きているか」を確認するカスタムハートビートがどうしても必要な場合は、setIntervalで直接実装するのではなく、DOのアラーム(alarm)APIを活用して周期的に目覚め、状態をチェックする方式に切り替えることを勧めます。アラームもハイバネーションを妨げる要因の一つではありますが、setIntervalのようにDOを永久に掴んだままにするのではなく、予定された時刻にだけ短く目覚めて再び眠ることができるからです。
CPU実行時間の上限もそのまま適用される
webSocketMessageハンドラも結局のところWorkersランタイムの上で動くコードなので、CPU時間の上限(デフォルト30秒、wrangler.tomlのlimits.cpu_msで最大5分まで調整可能)がそのまま適用されます。重い同期処理(大量のソート、圧縮など)をメッセージハンドラの中で回すと、この上限に引っかかる可能性があるため注意が必要です。
コスト比較: 常時アクティブなDO vs Hibernation適用DO
料金体系(Workers Paidプラン、2026年7月時点の公式ドキュメント)
| 項目 | 無料枠 | 超過料金 |
|---|---|---|
| リクエスト(HTTPリクエスト・RPC・WebSocketメッセージ・アラーム) | 100万件/月 | 100万件あたり$0.15 |
| Duration(GB-s、wall-clock、128MB固定基準) | 40万GB-s/月 | 100万GB-sあたり$12.50 |
| SQLiteストレージ容量 | 5GB-月 | GB-月あたり$0.20 |
| SQLite行読み取り | 250億行/月 | 100万行あたり$0.001 |
| SQLite行書き込み | 5,000万行/月 | 100万行あたり$1.00 |
Workers Paidプラン自体は月額$5の基本料金にリクエスト1,000万件とCPU 3,000万msが含まれており、DOの課金はこれとは別に上乗せされます。(参考までに、SQLiteバックエンドのDOはWorkersFreeプランでも1日単位の上限—リクエスト10万件/日、Duration 13,000 GB-s/日、行読み取り500万/日、行書き込み10万/日、ストレージ5GB—の範囲で使用できるため、プロトタイピングには無料プランでも十分です。)
計算モデルと前提条件
以下の数値は、実際の請求書をキャプチャしたものではなく、上記の公式料金計算式をそのまま当てはめたシミュレーションです。Cloudflareが料金ドキュメントで提示している例と同じ計算式(アクティブ秒数 × 128MB/1GB = GB-s)を使用しています。
- チャットルーム/ゲームルームのDO2,000個を30日間(2,592,000秒)ずっと運用
- 常時アクティブ(ハイバネーション未適用): 各ルームに最低1人以上接続しており、DOが1か月間ずっとメモリに常駐
- Hibernation適用: 各ルームで平均1秒に1回メッセージがやり取りされ(1msg/sec)、メッセージ処理(ブロードキャスト含む)に平均10msかかると仮定 — この場合、DOは全体の時間のうち約1%だけ「起きている」ことになります。
結果
| 区分 | Durationコスト/月 | リクエストコスト/月(両者同一) | 合計/月 |
|---|---|---|---|
| 常時アクティブDO | $8,289.40 | $777.45 | $9,066.85 |
| Hibernation適用DO | $77.94 | $777.45 | $855.39 |
総コスト削減幅は約90.6% — ヘッドラインの「90%」が特定のシナリオでのみ成立する誇張表現ではなく、1秒に1回程度メッセージがやり取りされる、ごく平凡なアクティブ度でも実際に到達する数値だということです。
トラフィックパターンによって削減幅は変わる
同じ2,000個のDO、同じ計算式でメッセージ頻度だけを変えてみると、削減幅は大きく変わります。
- 低頻度(チャットルーム、ターン制ゲーム、平均5秒に1回メッセージ、処理15ms): 合計コスト$8,444.77 → $175.25、削減約97.9%
- 中頻度(1秒に1回メッセージ、処理10ms): 合計コスト$9,066.85 → $855.39、削減約90.6%
- 高頻度(10Hzリアルタイムゲームティック、処理5ms): 合計コスト$16,065.25 → $8,185.57、削減約49.0%
パターンは明確です。Duration削減額そのものはトラフィックとほぼ無関係に常に大きいものの(上記3つのシナリオすべてでDurationだけを見れば99%以上減っています)、メッセージ頻度が高くなるほどリクエスト課金が総コストに占める割合が大きくなり、全体の削減率は低くなります。 1秒間に数十回も状態をブロードキャストするような高頻度のリアルタイムゲームであれば、Hibernation APIの導入とは別に、ティックレートを下げる、あるいはデルタ圧縮や関心領域(interest management)によってメッセージ自体を減らす最適化のほうが、コスト削減により大きく貢献します。
移行チェックリストとよくある落とし穴
-
ws.accept()の呼び出しをすべてthis.ctx.acceptWebSocket(ws)に置き換えたか -
addEventListener("message"/"close")をwebSocketMessage/webSocketCloseクラスメソッドに移したか、webSocketErrorも追加したか - コンストラクタにあったインメモリのセッションMapを取り除き、必要な値は
serializeAttachment(接続ごと、16,384バイト上限)またはthis.ctx.storage(永続、ルーム全体)に移したか - アプリケーションレベルの
setInterval/setTimeoutハートビートを削除するか、アラームAPIに置き換えたか — プロトコルのping/pongはランタイムが自動処理するため、カスタムハートビートが本当に必要かどうかから見直す - 大規模ルーム(数百〜数千の同時接続)を1つのDOに詰め込みすぎていないか — ソフトリミット1,000 req/s、メッセージサイズ32MiB上限を確認
-
wrangler.tomlにnew_sqlite_classesマイグレーションを追加してSQLiteバックエンドに切り替えたか(Hibernation APIと併用することをCloudflareが推奨する組み合わせ) - 重い同期処理が
webSocketMessageの中に残っていて、CPU上限(デフォルト30秒)に引っかからないか
まとめ
通常のaddEventListenerベースのWebSocketコードが悪いコードというわけではありません。ただ、Durable Objectsの課金モデルとの相性が悪いだけです。DOは動いている時間の分だけ課金され、標準WebSocket APIは接続が生きている限りDOを動かし続けます。 Hibernation APIはこの2つを切り離し、接続はエッジに残しつつコンピュートだけを眠らせることで、構造的な無駄をなくします。
移行そのものは、APIの表面積で見れば大きくありません。accept() → acceptWebSocket()、イベントリスナー → クラスメソッド、インメモリ状態 → attachment/storage。しかし、「コンストラクタはいつでも再び呼び出されうる」という前提をコードベース全体に染み込ませること、そしてカスタムハートビートのようなハイバネーションを妨げる隠れたタイマーを取り除くことが、実際の作業のほとんどを占めます。上記のチェックリストに沿って一つずつ確認していけば、トラフィックパターンによって差はあるものの、チャットやターン制ゲームのようにアイドル時間が長いワークロードでは、90%前後のコスト削減を難なく期待できます。