概要
Claude Codeにリアルタイムで複数人がテキストを同時編集できるWebアプリを作らせたら、CloudflareのDurable Objectと云ふものぞ使いけり。何それと思ったので調べたメモ。
アプリはCloudflare Workers上に構築していて、リアルタイム共同編集の部分でWebSocket接続の管理が必要だった。Claude Codeがその部分にDurable Objectsを選んだわけだが、自分はDurable Objectsが何なのか全く知らなかった。以下、Q&A形式で調べたことを並べていく。
Durable Objectsってそもそも何?
Durable Objectは、Cloudflareが動かしてくれるJavaScriptのクラスインスタンス。サーバー管理は不要。Cloudflareが適切なデータセンターを選び、クラスを起動し、あるIDに対してグローバルで1つだけのインスタンスを保証する。
わかりやすい例として、チャットルームを考える。
ユーザーAとユーザーBが同じルームに入ると、両方のWebSocket接続が同じChatRoomインスタンスに到達する。ユーザーAがメッセージを送ると、インスタンスがそれを受け取って他の接続にブロードキャストする。
Workersからは以下のようにDurable Objectのインスタンスを取得する。
// Workers: ルームIDからDOインスタンスを取得
const id = env.CHAT_ROOM.idFromName("room-123");
const room = env.CHAT_ROOM.get(id);
return room.fetch(request);idFromName("room-123")は、世界中のどこからアクセスしても同じインスタンスに到達する。チャット、共同編集、マルチプレイヤーゲーム、ライブダッシュボードなど、複数ユーザーがリアルタイムに状態を共有するものに使える。
具体的なコード例: チャットルームの場合
Durable Objectを使ったチャットルームの最小構成を見ていく。登場するファイルは3つ。
wrangler.toml(設定ファイル)
[durable_objects]
bindings = [
{ name = "CHAT_ROOM", class_name = "ChatRoom" }
]nameがWorkerのenvオブジェクトに生えるプロパティ名。class_nameが実際のJavaScriptクラス名。D1やR2と同じパターンで、Cloudflareのサービスをenv経由で使えるようにするための設定。
index.ts(Worker — ルーター)
export { ChatRoom } from "./chat-room";
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
const roomId = url.pathname.split("/").pop();
// ルームIDからDOインスタンスを取得してリクエストを転送
const id = env.CHAT_ROOM.idFromName(roomId);
const room = env.CHAT_ROOM.get(id);
return room.fetch(request);
},
};Workerはルーターとして機能する。リクエストを受け取って、URLからルームIDを抽出して、該当するDOインスタンスにリクエストを転送するだけ。WebSocketの確立後、WorkerはブラウザとDOの間には介在しない。
export { ChatRoom }が必要な点に注意。Cloudflareのランタイムがこのexportを見てDOクラスを認識する。
chat-room.ts(Durable Object本体)
export class ChatRoom implements DurableObject {
private connections = new Set<WebSocket>();
constructor(
private state: DurableObjectState,
private env: Env,
) {}
async fetch(request: Request): Promise<Response> {
// WebSocketのアップグレード
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
server.accept();
this.connections.add(server);
// このクライアントからのメッセージを他の全員にブロードキャスト
server.addEventListener("message", (event) => {
for (const ws of this.connections) {
if (ws !== server && ws.readyState === WebSocket.OPEN) {
ws.send(event.data);
}
}
});
// 切断時にSetから削除
server.addEventListener("close", () => {
this.connections.delete(server);
});
return new Response(null, { status: 101, webSocket: client });
}
}DOクラスの中身は普通のJavaScriptオブジェクト。this.connectionsはインメモリのSetで、接続中のWebSocketを保持している。メッセージが来たら、送信者以外の全接続にsendするだけ。
永続化が必要ならthis.state.storageを使う。
// 明示的に保存(自動同期ではない)
await this.state.storage.put("history", messages);
// 明示的に読み込み
const history = await this.state.storage.get("history");storage.put / storage.getは手動で呼ぶ必要がある。JavaScriptオブジェクトのプロパティを変更したら勝手にクラウドに同期される、という仕組みではない。
ブラウザ側(WebSocket接続)
// チャットルームに接続
const ws = new WebSocket("wss://example.com/api/rooms/room-123");
// サーバーからメッセージを受信
ws.addEventListener("message", (event) => {
const message = event.data;
appendToChat(message);
});
// メッセージを送信
function sendMessage(text: string) {
ws.send(text);
}
// 接続状態の監視
ws.addEventListener("open", () => console.log("connected"));
ws.addEventListener("close", () => console.log("disconnected"));ブラウザ側は通常のWebSocket APIそのまま。new WebSocket(url)で接続して、sendで送信、messageイベントで受信。Durable Objectsを意識する必要はない。URLにルームIDが含まれていれば、Worker側が対応するDOインスタンスにルーティングしてくれる。
Durable Objectsを使わない場合、WebSocketサーバーはどう作る?
Durable Objectsの便利さを理解するために、従来のWebSocketサーバー構成を見てみる。
Node.jsとwsライブラリで最小限のチャットサーバーを作るとこうなる。
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
const rooms = new Map<string, Set<WebSocket>>();
wss.on("connection", (ws, req) => {
const roomId = new URL(req.url, "http://localhost").pathname.slice(1);
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId).add(ws);
ws.on("message", (data) => {
for (const client of rooms.get(roomId)) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(data);
}
}
});
ws.on("close", () => {
rooms.get(roomId).delete(ws);
if (rooms.get(roomId).size === 0) rooms.delete(roomId);
});
});1台のサーバーならこれで動く。問題はスケーリング。ユーザーが増えてサーバーを複数台に増やすと、ユーザーAがサーバー1に、ユーザーBがサーバー2に接続される可能性がある。同じルームなのに別のサーバーにいるので、メッセージが届かない。
これを解決するためにRedisのpub/subを導入する。各サーバーがRedisにsubscribeして、メッセージが来たら自分のサーバーに接続しているクライアントにブロードキャストする。
import Redis from "ioredis";
const pub = new Redis();
const sub = new Redis();
// Redisからメッセージを受信 → ローカルのクライアントに配信
sub.on("message", (channel, message) => {
const roomId = channel.replace("room:", "");
for (const client of rooms.get(roomId) || []) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
});
// クライアントからメッセージを受信 → Redisに発行
ws.on("message", (data) => {
pub.publish(`room:${roomId}`, data);
});必要なものがどんどん増えていく。
- サーバーのプロビジョニングとスケーリング設定
- ロードバランサー(WebSocketのsticky session対応が必要)
- Redisクラスター(SPOF回避)
- プロセスの死活監視と自動再起動
- ルーム状態の永続化(別途データベース)
Durable Objectsの場合、「同じルームのユーザーは全員同じインスタンスに接続する」ことがCloudflare側で保証されるので、この複雑さが丸ごと消える。上で見たDurable Objectのコードの通り、インメモリのSetをfor-ofで回してsendするだけで済む。
従来のpub/sub(Redis等)と何が違う?
上の構成をまとめると、従来のリアルタイム構成は「複数のWebSocketサーバー + Redisのpub/subでサーバー間メッセージング」という形になる。
違いはこう。
- Redis pub/sub: ステートレスなリレー。ドキュメントの状態は別途DBに保存する必要がある
- Durable Object: ステートフル。メッセージリレーと状態が同じインメモリインスタンスに同居している
Cloudflareがエンティティごと(ルームごと、ゲームセッションごと)に小さな専用サーバーを用意してくれるようなもの。永続化ストレージとWebSocketサポートが組み込み。
Redisの場合、WebSocketサーバーAのユーザーの更新はRedisを経由してWebSocketサーバーBのユーザーに届く。Durable Objectの場合、同じルームのユーザーは全員同じインスタンスに接続しているので、インメモリのSetをfor-ofで回してsendするだけ。メッセージブローカーが不要になる。
同時リクエストの扱いも異なる。従来のNode.jsサーバーは非同期I/Oで同時に多数のリクエストを処理するため、共有状態へのアクセスで競合が起きる可能性がある。Durable Objectは単一インスタンスでシングルスレッド実行されるため、同じルームへのメッセージは自然に直列化される。ロックやキューイングの仕組みを自前で用意する必要がない。
サーバーを自分で動かさなくていい?料金は?
メリットとしては以下。
- アイドルコストがない: 誰も接続していないインスタンスはevictされる。実際の使用分だけ課金
- 運用不要: プロビジョニング、スケーリング設定、ロードバランサーの管理がいらない
料金(Cloudflareの公開レート)はこうなっている。
| サービス | 料金 |
|---|---|
| Durable Objects | $0.15/100万リクエスト + $0.50/GB月のストレージ |
| Workers | 有料プラン($5/月)で1000万リクエスト/月が含まれる |
| D1 | 500万行の読み取りが無料、以降$0.001/100万行 |
| R2 | 10GB無料、以降$0.015/GB月(エグレス料金なし) |
小〜中規模のアプリなら月$5〜10程度に収まりそう。VPSだと最低でも月$10〜20で常時稼働、さらにRedis、稼働率の管理が必要になる。
トレードオフとしては、大量の同時接続ユーザーがいる場合、リクエスト単位の課金が専用サーバーを超える可能性はある。ただ、たいていのケースではこちらの方が安くてシンプルだろう。
Durable Objectのストレージは信用できる?データは永続化される?
Durable Objectのストレージは永続化される。インスタンスのeviction、再起動、クラッシュを経ても残る。Cloudflareが耐久性を保証している。
理論上はDurable Objectのストレージだけをデータストアにすることもできる。ただ、自分のアプリでは両方を併用した。
| ストレージ | 保存内容 | 理由 |
|---|---|---|
| Durable Object KV | リアルタイムのドキュメント状態(バイナリ) | 高速、WebSocketと同じ場所に配置 |
| D1(SQLite) | プレーンテキストのスナップショット、リビジョン履歴、一覧 | クエリ可能(検索、一覧、ソート)、人間が読める、バックアップ |
D1を併用しているのは、Durable Objectのストレージを信用していないからではない。Durable ObjectのKVはKey-Value形式のみなので、SELECT * FROM notes ORDER BY updated_atやフルテキスト検索ができない。get('doc')とput('doc', binary)しかできない。
もしDurable Objectのデータが壊れたらどうする?
もしバイナリ状態が壊れた場合(同期コードのバグなど)のリカバリ手順はこう。
- Durable Objectストレージから壊れたバイナリを削除する
- D1から最新のプレーンテキストスナップショットを取得する
- 新しい状態を作成し、そのテキストで初期化する
バイナリフォーマットはopaque(不透明)で、人間が編集することはできない。壊れたら捨てて、D1のスナップショット(プレーンな文字列、常にリカバリ可能)から復元する。
実際にはこの事態は起きていない。CRDTライブラリ(Y.js)は成熟していて安定している。あくまで「もしもの時」の設計。
つまりDurable Objectsは何のためにあるもの?
通常のDBアーキテクチャの上に乗る、リアルタイム専用のレイヤーという位置づけ。
従来のWebアプリ構成はこう。
CRUDには対応できるが、リアルタイムには向いていない。ポーリングか、後付けのWebSocket + Redisが必要になる。
Durable Objectsを使うとこうなる。
Durable Objectが担当するのは「複数ユーザーが互いの変更を即座に見られる」部分。データベースはそれ以外の全て(一覧、検索、履歴、長期保存)を担当する。
DBの代替ではない。リアルタイム通信のための協調レイヤー。共同編集、チャットルーム、マルチプレイヤーゲーム、ライブダッシュボードなど、ライブなやり取りに特化したもの。
余談
自分のアプリではリアルタイム同期にY.jsを使っていて、Durable ObjectがそのWebSocketルームの役割を果たしている。Y.jsのドキュメント状態(バイナリ)をDurable Object内に保持しつつ、定期的にD1にプレーンテキストとしてスナップショットを保存する構成。Claude Codeがこの構成を選んだ理由は、結局のところ上に書いた「メッセージリレーと状態が同じ場所に同居する」という性質がCRDTの同期に適しているから、ということだろう。