effidevFlutter・Cloudflareエッジ・クラウドコスト最適化

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

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

Durable Objects(DO)でリアルタイムチャットルームやマルチプレイヤーゲームルームを作ったことがあるなら、請求書で奇妙な項目を目にしたことがあるはずです。メッセージがほとんど行き交わない深夜の時間帯でも、Duration(GB-s)コストが昼間とほぼ同じペースで積み上がる現象です。犯人はほぼ間違いなく、server.accept() + addEventListener の組み合わせで書かれた標準WebSocketコードです。

要点まとめ

問題の発端:常駐型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が積み上がり、残りのほとんどの時間は無料ということです。

ただし、いつでもハイバネーションされるわけではありません。ドキュメントはハイバネーションを妨げる要因を明記しています。

一方、プロトコルレベルの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);
  }
}

変更点をまとめると:

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オブジェクトとその中の変数)であり、接続や永続データではありません。整理すると:

そのため移行の核心は、「コンストラクタが再度呼ばれても問題ないように」状態管理を再設計することです。実務では通常、次のように分けます。

  1. 接続ごとにのみ必要な短いメタデータ(ニックネーム、色、最後のハートビート時刻など) → serializeAttachment
  2. ルーム全体で共有する状態だが再計算可能なもの(現在の接続者数、オンラインユーザー一覧) → コンストラクタでthis.ctx.getWebSockets()を回し、各ソケットのattachmentを読んでその場で再構築
  3. 永続的に残すべきデータ(チャット履歴、ゲームスコア、ルーム設定) → 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.tomllimits.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)を使用しています。

結果

区分 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、同じ計算式でメッセージ頻度だけを変えてみると、削減幅は大きく変わります。

パターンは明確です。Duration削減額そのものはトラフィックとほぼ無関係に常に大きいものの(上記3つのシナリオすべてでDurationだけを見れば99%以上減っています)、メッセージ頻度が高くなるほどリクエスト課金が総コストに占める割合が大きくなり、全体の削減率は低くなります。 1秒間に数十回も状態をブロードキャストするような高頻度のリアルタイムゲームであれば、Hibernation APIの導入とは別に、ティックレートを下げる、あるいはデルタ圧縮や関心領域(interest management)によってメッセージ自体を減らす最適化のほうが、コスト削減により大きく貢献します。

移行チェックリストとよくある落とし穴

まとめ

通常のaddEventListenerベースのWebSocketコードが悪いコードというわけではありません。ただ、Durable Objectsの課金モデルとの相性が悪いだけです。DOは動いている時間の分だけ課金され、標準WebSocket APIは接続が生きている限りDOを動かし続けます。 Hibernation APIはこの2つを切り離し、接続はエッジに残しつつコンピュートだけを眠らせることで、構造的な無駄をなくします。

移行そのものは、APIの表面積で見れば大きくありません。accept()acceptWebSocket()、イベントリスナー → クラスメソッド、インメモリ状態 → attachment/storage。しかし、「コンストラクタはいつでも再び呼び出されうる」という前提をコードベース全体に染み込ませること、そしてカスタムハートビートのようなハイバネーションを妨げる隠れたタイマーを取り除くことが、実際の作業のほとんどを占めます。上記のチェックリストに沿って一つずつ確認していけば、トラフィックパターンによって差はあるものの、チャットやターン制ゲームのようにアイドル時間が長いワークロードでは、90%前後のコスト削減を難なく期待できます。