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

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の有効化:
-
server.accept()→this.state.acceptWebSocket(server)へ移行 - イベントリスナー →
webSocketMessage,webSocketClose,webSocketErrorメソッドへ移行 -
Map<WebSocket, data>→serializeAttachment/deserializeAttachmentへ移行 -
this.sessionsインメモリ保存の除去
WebSocketメッセージの最適化:
- 不要なPing/Pongメッセージの削除(クライアント → サーバー方向)
- CloudflareがPingを自動処理するため、サーバー側のハートビートは不要
- メッセージのバッチ処理によるリクエスト数削減
ストレージの削減:
- メッセージ履歴の上限設定(100〜200個)
- Alarm APIによる非アクティブ部屋の定期クリーンアップ
- 大容量ペイロードはR2に保存し、DOには参照のみを保持
モニタリング:
- CloudflareダッシュボードでDO Duration指標を毎週確認
- OTelを用いて実際のアクティブ時間を計測(Active Duration / Total Connection Timeの比率)
- 部屋別の接続者数メトリクスの収集
トラブルシューティング: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を含むシステム全体の分散トレーシング設定方法を確認しましょう。