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

Cloudflare DO WebSocket Hibernation:GB秒課金99%削減

Cloudflare Durable Objects WebSocket Hibernation APIコスト最適化アーキテクチャ

DOでリアルタイムチャットを構築したら請求額が異常だった

Cloudflare Durable Objects(DO)は、リアルタイムチャット、マルチプレイヤーゲーム、コラボレーションエディタを実装するのに完璧なプラットフォームに見えます。Workersのステートレスな限界を超え、永続メモリとWebSocketを1箇所で管理できます。

しかし、チャットアプリをデプロイして数日後に請求書を見ると衝撃を受けます。同時接続者が数十人しかいないのに、コストが予想の10倍に達しているのです。

原因はGB秒(GB-seconds)課金方式にあります。標準のWebSocket APIを使用すると、クライアントが接続だけしてメッセージを一切送信しなくても、DOはメモリ上に生存し続け、その時間中ずっと課金されます。100人がチャットルームに接続したまま、たまにしかメッセージを送らなくても、DOは24時間アクティブな状態に維持されます。

Hibernation API가この問題の解決策です。クライアントが接続を維持している間んでも、DOがメモリから削除(ハイバネーション)できるようにします。新しいメッセージが届くと自動的に復帰して処理を行い、再び休眠状態に入ります。コストは実際にコードが実行されている時間にしか発生しません。

この記事では、標準WebSocket DOとHibernation APIの違い、実際のチャットアプリ実装、serializeAttachmentで状態を管理する方法、そしてコストシミュレーションを解説します。

DO課金構造の理解

Hibernation APIを使用する前に、課金構造を正確に理解しておく必要があります。

課金項目 説明 単価
リクエスト (Requests) HTTPリクエスト、RPCセッション、WebSocketメッセージ 100万件あたり $0.15
コンピュート期間 (Duration) DOがメモリにある時間 × 128MB GB-sあたり $12.50
ストレージ SQLite読み込み/書き込み、KVストレージ 別途

核心ルール: WebSocketメッセージは20:1の割合で課金されます。受信WebSocketメッセージ100件 = 課金リクエスト5件。

問題: Duration課金が爆発します。DOは最後のイベント後、最低10秒間メモリに保持され、標準のWebSocketはこのタイマーを更新し続けます。

コストシミュレーション:標準WebSocket

100チャットルーム × 50人同時接続、1日8時間アクティブ、1ヶ月基準:

Duration: 100 rooms × 1 DO × 128MB × 8時間 × 30日
= 100 × 0.128GB × 8h × 30d × 3600s/h
= 11,059,200 GB-s

コスト: 11,059,200 × $12.50 / 1,000,000
≈ $138/月 (Durationのみ)

Hibernation API使用時: メッセージがなければDOは10秒後に休眠します。実際のメッセージ処理時間は全体接続時間の1〜5%レベルです。

アクティブ時間 2% と仮定:
11,059,200 × 0.02 = 221,184 GB-s
コスト: 221,184 × $12.50 / 1,000,000
≈ $2.76/月 (Duration)

同一トラフィックにおいてDurationコスト98%削減

標準WebSocket vs Hibernation API比較

標準WebSocket(問題のあるパターン)

// src/chat-room.ts — 標準WebSocket(コスト増大パターン)
export class ChatRoom implements DurableObject {
  private sessions: Map<WebSocket, { userId: string; username: string }> = new Map();
  private state: DurableObjectState;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
  }

  async fetch(request: Request): Promise<Response> {
    const upgradeHeader = request.headers.get("Upgrade");
    if (!upgradeHeader || upgradeHeader !== "websocket") {
      return new Response("Expected WebSocket", { status: 426 });
    }

    const [client, server] = Object.values(new WebSocketPair());

    // 問題: server.accept()はDOがアクティブ状態に維持されるよう強制する
    server.accept();

    server.addEventListener("message", (event) => {
      this.handleMessage(server, event.data);
    });

    server.addEventListener("close", () => {
      this.sessions.delete(server);
    });

    const userId = new URL(request.url).searchParams.get("userId") ?? "anonymous";
    this.sessions.set(server, { userId, username: userId });

    return new Response(null, { status: 101, webSocket: client });
  }

  private handleMessage(sender: WebSocket, message: string | ArrayBuffer) {
    // 全てのセッションにブロードキャスト
    const data = JSON.parse(message as string);
    this.sessions.forEach((info, ws) => {
      if (ws !== sender && ws.readyState === WebSocket.READY_STATE_OPEN) {
        ws.send(JSON.stringify({ ...data, from: info.userId }));
      }
    });
  }
}

このコードの問題点: server.accept()を使用すると、DOは絶対にハイバネーションされません。this.sessionsはインメモリのMapであるため、すべての接続セッションが常にメモリに保持されます。

Hibernation API(正しいパターン)

// src/chat-room-hibernation.ts — Hibernation API
export class ChatRoom implements DurableObject {
  private state: DurableObjectState;
  private env: Env;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    this.env = env;
  }

  async fetch(request: Request): Promise<Response> {
    const upgradeHeader = request.headers.get("Upgrade");
    if (!upgradeHeader || upgradeHeader !== "websocket") {
      return new Response("Expected WebSocket", { status: 426 });
    }

    const url = new URL(request.url);
    const userId = url.searchParams.get("userId") ?? crypto.randomUUID();
    const username = url.searchParams.get("username") ?? "Anonymous";
    const roomId = url.searchParams.get("roomId") ?? "default";

    const [client, server] = Object.values(new WebSocketPair());

    // 核心の違い: ctx.acceptWebSocket()がHibernationを有効化
    this.state.acceptWebSocket(server);

    // serializeAttachment: このWebSocketソケットにメタデータを添付
    // Hibernate後も各ソケットに接続された状態が維持される
    server.serializeAttachment({ userId, username, roomId });

    // 入室メッセージをブロードキャスト
    this.broadcast(server, {
      type: "system",
      message: `${username}さんが入室しました`,
      timestamp: Date.now(),
    });

    return new Response(null, { status: 101, webSocket: client });
  }

  // Hibernation APIのイベントハンドラー: 通常メソッドとして宣言
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
    // deserializeAttachment: Hibernate後に復帰した際に添付データを復元
    const { userId, username, roomId } = ws.deserializeAttachment() as {
      userId: string;
      username: string;
      roomId: string;
    };

    let data: any;
    try {
      data = JSON.parse(message as string);
    } catch {
      ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
      return;
    }

    // チャットメッセージの処理
    if (data.type === "message") {
      const chatMessage = {
        type: "message",
        userId,
        username,
        content: data.content,
        timestamp: Date.now(),
        id: crypto.randomUUID(),
      };

      // 履歴を保存 (DO内蔵SQLite)
      await this.state.storage.put(`msg:${chatMessage.id}`, chatMessage);

      // 部屋の全参加者にブロードキャスト
      this.broadcast(ws, chatMessage);
    }
  }

  // WebSocket接続終了ハンドラー
  async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
    const { username, roomId } = ws.deserializeAttachment() as {
      username: string;
      roomId: string;
    };

    this.broadcast(ws, {
      type: "system",
      message: `${username}さんが退室しました`,
      timestamp: Date.now(),
    });
  }

  // WebSocketエラーハンドラー
  async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
    console.error("WebSocket error:", error);
    ws.close(1011, "Internal Error");
  }

  // 現在の部屋の全WebSocketにブロードキャスト
  private broadcast(sender: WebSocket, data: object): void {
    const message = JSON.stringify(data);
    const senderAttachment = sender.deserializeAttachment() as { roomId: string };

    // getWebSockets(): Hibernate状態でも現在接続されている全ソケットを返却
    for (const ws of this.state.getWebSockets()) {
      const attachment = ws.deserializeAttachment() as { roomId: string };
      // 同じ部屋のソケットにのみ送信 (送信者を除く)
      if (ws !== sender && attachment.roomId === senderAttachment.roomId) {
        try {
          ws.send(message);
        } catch {
          // 既に閉じられたソケットは無視
        }
      }
    }
  }
}

Hibernation APIの核心概念

ctx.acceptWebSocket() vs server.accept()

server.accept() ctx.acceptWebSocket()
Hibernate可能 ❌ 不可 ✅ 可能
イベントハンドラー addEventListener webSocketMessageメソッド
セッション管理 インメモリMap getWebSockets()
状態維持 メモリ保持必須 serializeAttachment

serializeAttachment / deserializeAttachment

HibernationはDOをメモリから完全に削除します。新しいメッセージが届くと、新しいインスタンスが生成されます。そのため、各WebSocketソケットに必要なメタデータを添付しておく必要があります。

// 添付 (Hibernate前に自動シリアライズ)
server.serializeAttachment({
  userId: "user-123",
  username: "Alice",
  roomId: "room-general",
  joinedAt: Date.now(),
  permissions: ["read", "write"],
});

// 復元 (Hibernate後に復帰した際)
async webSocketMessage(ws: WebSocket, message: string): Promise<void> {
  const state = ws.deserializeAttachment() as {
    userId: string;
    username: string;
    roomId: string;
    joinedAt: number;
    permissions: string[];
  };

  // state.userId、state.usernameなどを安全に使用可能
}

注意事項: serializeAttachmentのデータはJSONシリアライズ可能でなければなりません。関数、クラスインスタンス、循環参照は保存できません。

getWebSockets() — 現在接続されているソケットの取得

// 部屋別の接続者数の取得
getConnectedUsers(roomId: string): number {
  return this.state.getWebSockets()
    .filter(ws => {
      const att = ws.deserializeAttachment() as { roomId: string };
      return att.roomId === roomId;
    })
    .length;
}

// 特定のユーザーにのみメッセージ送信
sendToUser(targetUserId: string, data: object): void {
  for (const ws of this.state.getWebSockets()) {
    const { userId } = ws.deserializeAttachment() as { userId: string };
    if (userId === targetUserId) {
      ws.send(JSON.stringify(data));
      break;
    }
  }
}

完全なチャットアプリの実装

Workersエントリポイント

// src/index.ts
import { Hono } from "hono";
import { ChatRoom } from "./chat-room-hibernation";

type Env = {
  CHAT_ROOM: DurableObjectNamespace;
};

const app = new Hono<{ Bindings: Env }>();

// WebSocketアップグレードハンドラー
app.get("/ws", async (c) => {
  const roomId = c.req.query("roomId") ?? "general";
  const userId = c.req.query("userId") ?? crypto.randomUUID();
  const username = c.req.query("username") ?? "Anonymous";

  // 部屋IDに基づいてDOインスタンスを選択(部屋ごとに1つのDO)
  const id = c.env.CHAT_ROOM.idFromName(roomId);
  const room = c.env.CHAT_ROOM.get(id);

  // DOへリクエストを転送
  return room.fetch(c.req.raw);
});

// 部屋情報取得API
app.get("/rooms/:roomId/info", async (c) => {
  const roomId = c.req.param("roomId");
  const id = c.env.CHAT_ROOM.idFromName(roomId);
  const room = c.env.CHAT_ROOM.get(id);
  return room.fetch(
    new Request(`https://internal/info?roomId=${roomId}`)
  );
});

export default app;
export { ChatRoom };

wrangler.jsonc設定

// wrangler.jsonc
{
  "name": "realtime-chat",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "compatibility_flags": ["nodejs_compat"],

  "durable_objects": {
    "bindings": [
      {
        "name": "CHAT_ROOM",
        "class_name": "ChatRoom"
      }
    ]
  },

  "migrations": [
    {
      "tag": "v1",
      "new_classes": ["ChatRoom"]
    }
  ],

  // OTelトレーシングも同時に有効化(コストモニタリング)
  "observability": {
    "traces": {
      "enabled": true,
      "head_sampling_rate": 0.1
    }
  }
}

クライアント実装 (Hono + Vanilla JS)

// public/client.ts
class ChatClient {
  private ws: WebSocket | null = null;
  private reconnectAttempts = 0;
  private maxReconnects = 5;

  connect(roomId: string, userId: string, username: string): void {
    const url = new URL("/ws", window.location.href);
    url.protocol = url.protocol.replace("http", "ws");
    url.searchParams.set("roomId", roomId);
    url.searchParams.set("userId", userId);
    url.searchParams.set("username", username);

    this.ws = new WebSocket(url.toString());

    this.ws.addEventListener("open", () => {
      console.log("Connected to chat room");
      this.reconnectAttempts = 0;
    });

    this.ws.addEventListener("message", (event) => {
      const data = JSON.parse(event.data);
      this.onMessage(data);
    });

    this.ws.addEventListener("close", (event) => {
      console.log(`Disconnected: ${event.code} ${event.reason}`);
      // 自動再接続 (指数バックオフ)
      if (this.reconnectAttempts < this.maxReconnects) {
        const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000);
        setTimeout(() => {
          this.reconnectAttempts++;
          this.connect(roomId, userId, username);
        }, delay);
      }
    });
  }

  sendMessage(content: string): void {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ type: "message", content }));
    }
  }

  private onMessage(data: any): void {
    // UI更新ロジック
    const event = new CustomEvent("chat-message", { detail: data });
    window.dispatchEvent(event);
  }

  disconnect(): void {
    this.ws?.close(1000, "User disconnected");
  }
}

export const chatClient = new ChatClient();

履歴管理:DO内蔵SQLite

Hibernate後もチャット履歴を維持するためには、DOの永続ストレージを使用します。

// チャット履歴の保存および取得
export class ChatRoom implements DurableObject {
  // メッセージの保存
  private async saveMessage(msg: ChatMessage): Promise<void> {
    // DO内蔵SQLite (アルファ): 構造化クエリが可能
    await this.state.storage.put(`msg:${msg.timestamp}:${msg.id}`, msg);

    // 直近100個のメッセージのみ保持 (コスト削減)
    const allKeys = await this.state.storage.list({ prefix: "msg:", limit: 200 });
    if (allKeys.size > 100) {
      // 古いメッセージの削除
      const keysToDelete = [...allKeys.keys()].slice(0, allKeys.size - 100);
      await this.state.storage.delete(keysToDelete);
    }
  }

  // 新規接続者へ直近の履歴を送信
  private async sendHistory(ws: WebSocket): Promise<void> {
    const history = await this.state.storage.list({
      prefix: "msg:",
      limit: 50,
      reverse: true,  // 最新順
    });

    const messages = [...history.values()].reverse();
    ws.send(JSON.stringify({ type: "history", messages }));
  }
}

Alarm API:非アクティブな部屋の自動クリーンアップ

古くなったチャットルームのDOインスタンスを定期的にクリーンアップすると、ストレージコストも削減できます。

export class ChatRoom implements DurableObject {
  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    // Alarm設定: 24時間後に非アクティブ状態を確認
    this.state.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000);
  }

  // Alarmが発火すると呼び出される
  async alarm(): Promise<void> {
    const activeConnections = this.state.getWebSockets().length;

    if (activeConnections === 0) {
      // 接続がなければ古いメッセージを削除
      const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; // 7日前
      const allMsgs = await this.state.storage.list({ prefix: "msg:" });

      const toDelete: string[] = [];
      for (const [key, value] of allMsgs) {
        const msg = value as ChatMessage;
        if (msg.timestamp < cutoff) {
          toDelete.push(key);
        }
      }

      if (toDelete.length > 0) {
        await this.state.storage.delete(toDelete);
        console.log(`Cleaned ${toDelete.length} old messages`);
      }
    }

    // 次のAlarmを予約
    await this.state.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000);
  }
}

コスト最適化チェックリスト

実際のプロダクションで適用すべき最適化リスト:

Hibernationの有効化:

WebSocketメッセージの最適化:

ストレージの削減:

モニタリング:

トラブルシューティング:Hibernationが機能しない場合

症状:Durationコストが依然として高い

原因1: server.accept()の代わりに ctx.acceptWebSocket() を使用していない
原因2: webSocketMessage ハンドラー内部で実行時間の長い非同期処理を実行している
原因3: DOのイベントループを占有するタイマーや反復処理が存在する

// ❌ 誤ったパターン: setIntervalがDOを常に起こしたままにする
constructor(state: DurableObjectState) {
  this.state = state;
  setInterval(() => this.cleanup(), 60000); // Hibernateの妨げ
}

// ✅ 正しいパターン: Alarm APIの使用
constructor(state: DurableObjectState) {
  this.state = state;
  // コンストラクタでの重い処理は禁止
}

async fetch(request: Request): Promise<Response> {
  // 初回接続時のみAlarmを設定
  const alarmTime = await this.state.storage.getAlarm();
  if (!alarmTime) {
    await this.state.storage.setAlarm(Date.now() + 60000);
  }
  // ...
}

症状:Hibernate後に deserializeAttachment エラーが発生する

原因: serializeAttachment にJSONシリアライズ不可能な値が含まれている
解決策: 純粋なJSON互換オブジェクトのみを添付する

// ❌ シリアライズ不可
server.serializeAttachment({
  userId: "user-123",
  handler: () => {}, // 関数はシリアライズ不可
  socket: server,   // 循環参照
});

// ✅ シリアライズ可能
server.serializeAttachment({
  userId: "user-123",
  username: "Alice",
  roomId: "general",
  permissions: ["read", "write"],
  joinedAt: Date.now(), // 数値、文字列、配列のみ
});

結論

Cloudflare Durable Objectsは、リアルタイムWebSocketアプリのための最高のエッジランタイムです。ただし、標準WebSocket APIとHibernation APIの違いを理解していないとコストが爆発します

核心的な転換点:

変更前 変更後 효과
server.accept() ctx.acceptWebSocket() Hibernateの有効化
Map<WebSocket, data> serializeAttachment メモリ状態の除去
addEventListener webSocketMessage メソッド DOイベントモデルへの移行
setInterval Alarm API 定期処理のコスト削減

この4つのパターン変更だけで、Duration課金が95〜99%削減されます。クライアントが接続を維持している間、会話がなければDOは休眠し、メッセージが到着すると復帰して処理を行い、再び休眠します。

月間数百万のWebSocket接続を処理するプロダクションアプリにおいて、この差は月間数百ドルのコスト削減につながります。

関連記事:Cloudflare Workers OpenTelemetry: OTLP オブザーバビ리티 コスト削減で、DOを含むシステム全体の分散トレーシング設定方法を確認しましょう。