Cloudflare ハンズオン教科書|2026-08 版
第 8 章

Durable Objects — ステートフルな分散オブジェクト

「世界に 1 つのインスタンス」で協調とリアルタイムを手に入れる

この章で学ぶこと

世界に 1 つのインスタンス — DO のメンタルモデル

Durable Objects は「コンピュートとストレージが合体した特別な Worker」です。KV・R2・D1 が「Worker から呼び出す外部ストレージ」だったのに対し、DO は JavaScript クラスのインスタンスそのものが状態を持ち、専用のストレージを抱えたまま Cloudflare のネットワーク上で動きます。核となる保証は次の 3 つです。

東京からもロンドンからも、名前が同じなら届く先は世界でただ 1 つ 東京のブラウザ ロンドンのブラウザ 東京の Worker ロンドンの Worker DO "room-42" ①②を 1 件ずつ直列処理 SQLite(同じマシン上) HTTP HTTP ① RPC ② RPC 名前が違えば別インスタンス 別の名前 DO "room-7"
図 8-1 クライアントは Worker にしか触れず、Worker がバインディング経由で同じ 1 つのインスタンスへ届けます。①②が別大陸から同時に来ても中で 1 件ずつ処理され、SQLite が同じ箱に同居していることが、ロックなしで「読み取り→加算→書き込み」を書ける理由です。破線が示すとおり、名前を変えれば状態も SQLite も別の箱になります。

これは分散システムでいう Actor モデルの実装です。1 つの DO インスタンス=1 つのアクターが、メッセージ(HTTP/RPC リクエスト)を 1 件ずつ処理し、自分専用の状態を読み書きします。既習のストレージ製品と比べると位置づけが分かります。

製品整合性アクセス単位得意なこと
KV(第 5 章)結果整合キー読み取り中心の設定・キャッシュ配布
D1(第 7 章)単一プライマリの SQLデータベースアプリ全体のリレーショナルデータ
Durable Objects強整合(インスタンス内)オブジェクト(ID ごと)協調・カウンタ・チャット・ゲームなどリアルタイムな状態共有

ライフサイクルも押さえておきましょう。DO は事前にプロビジョニングするものではなく、最初のアクセス時に暗黙に作られます。

  1. 最初のリクエスト(またはアラーム)到着時に constructor() が実行され、続けて呼び出されたメソッドが動く(アクティブ状態)
  2. 処理が終わるとメモリ上でアイドルになり、休止(hibernation)可能な条件を満たしていれば約 10 秒の無活動でメモリから退避される
  3. 休止するとインメモリ状態は消える。次のイベントで constructor() から再構築される
  4. 休止できない状態(setTimeout / setInterval の予約、標準 API の WebSocket、送信側の接続などがある場合)では 70〜140 秒の無活動後にメモリから完全に退避される
最初のリクエスト(またはアラーム)が来ると起動する 起動 アクティブ constructor() 実行 処理完了 アイドル メモリ上で待機 休止できる 休止できない 休止(10 秒後) Duration 課金が止まる 完全退避(70〜140 秒後) 退避まで課金され続ける ホストの判断で 次のイベントで constructor() から再構築(インメモリ状態は消える) ストレージ(SQLite)はどの状態でも残り続ける
図 8-2 分岐点は「休止できるか」です。条件を満たせば 10 秒で退避して Duration 課金が止まりますが、setTimeout や標準 WebSocket が残っていると 70〜140 秒メモリに居座り、その間ずっと課金されます。どちらの経路でも破線のループで再構築が起き、インメモリ状態は消えます。生き残るのは下段のストレージだけなので、真実はそこに置きます。

つまりインメモリ状態はいつ消えてもおかしくない前提で、残したいデータは必ずストレージへ書きます。シャットダウンフックは提供されないため、「終了時にまとめて保存」ではなく「処理しながら都度保存」が公式に推奨される設計です。

もう 1 つ重要な制約として、DO はインターネットから直接リクエストを受けません。必ず Worker(または別の DO)がバインディング経由で呼び出します。Worker が玄関、DO が奥の個室というイメージです。

クラス定義・wrangler 設定・呼び出し

DurableObject クラスとバインディング

DO の実体は cloudflare:workers の DurableObject 基底クラスを継承した、ふつうの export クラスです。コンストラクタは ctx(DurableObjectState — ストレージや WebSocket 管理を持つ)と env(バインディング)を受け取り、super(ctx, env) の呼び出しが必須です。public メソッドはそのまま RPC メソッドとして Worker から呼び出せます(compatibility date 2024-04-03 以降)。HTTP の Request/Response をそのまま流したい場合(WebSocket など)は fetch() ハンドラを実装します。

呼び出す側の Worker には、wrangler.jsonc で durable_objects.bindings を宣言します。name が env に生えるプロパティ名、class_name が対象クラスです。

exports 宣言でクラスを登録する(旧 migrations)

バインディングだけではネームスペース(そのクラスの DO 群)は作られません。Worker が export する DO クラスを wrangler.jsonc の exports フィールドで宣言することで、初回デプロイ時に Cloudflare がネームスペースをプロビジョニングします。新規クラスは SQLite ストレージバックエンド("storage": "sqlite")のみ指定できます。

{
  "durable_objects": {
    "bindings": [
      { "name": "MY_DURABLE_OBJECT", "class_name": "MyDurableObject" }
    ]
  },
  "exports": {
    "MyDurableObject": { "type": "durable-object", "storage": "sqlite" }
  }
}

以前は migrations 配列("migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyDurableObject"] }] のような形式)でクラスの作成・リネーム・削除を管理していました。この旧方式も引き続きサポートされますが、exports とは排他で、両方書くと設定検証で拒否されます。また一度 exports でデプロイした Worker は migrations へ戻せません。公式ドキュメントの例には旧方式のものも残っているので、読み替えられるよう両方の形を覚えておきましょう。クラスの削除・リネーム・別 Worker への移管も exports のエントリ(tombstone)で宣言的に行えます。

ID とスタブ — idFromName / getByName

特定のインスタンスにアクセスするには、まず名前から DurableObjectId(64 桁 16 進数)を作り、そこからスタブ(クライアント)を取得します。getByName() はこの 2 段階をまとめたショートカットです。

// 2 段階: 名前 → ID → スタブ
const id = env.MY_DURABLE_OBJECT.idFromName("foo");
const stub = env.MY_DURABLE_OBJECT.get(id);

// ショートカット(同じインスタンスに届く)
const stub2 = env.MY_DURABLE_OBJECT.getByName("foo");

// public メソッドを RPC として呼び出す
const greeting = await stub2.sayHello();

スタブを作った時点ではまだ何も起きず、メソッドを呼んだ瞬間に初めてリクエストが送られ、必要ならインスタンスが起動します。名前由来の ID は初回アクセス時のみ「世界中で同名のオブジェクトが同時に作られていないか」の確認が入るため、数百 ms 程度の遅延が乗ることがあります(以後はキャッシュされます)。この確認を省ける newUniqueId()(ランダム ID)や、文字列化した ID を復元する idFromString() も用意されています。DO 側からは this.ctx.id.name で自分の名前を参照できます。

getByName() はここをまとめたショートカット 名前 "foo" ID(64 桁) スタブ インスタンス起動 idFromName get(id) 呼び出し ここまで通信ゼロ(インスタンスはまだ起きない) 初回は一意性確認で 数百 ms かかる
図 8-3 左の 2 本の矢印はローカルな計算にすぎず、スタブを作った時点ではまだ何も起きていません。ネットワークが動くのは 3 本目、最初のメソッド呼び出しの瞬間です。初回だけ「世界に同名が 2 つないか」の確認が入り、以降は場所がキャッシュされます。

SQLite ストレージ API と Alarms

sql.exec とカーソル

SQLite バックエンドのクラスでは、this.ctx.storage.sql.exec(query, ...bindings) で埋め込み SQLite に直接クエリを発行できます。同期 API(await 不要)で、ストレージが同じマシン上にあるためレイテンシは実質ゼロです。戻り値はカーソルで、toArray()(全行をオブジェクト配列で)、one()(ちょうど 1 行であることを検証して返す)、raw()(列名なしの値配列)、for...of での反復をサポートします。? プレースホルダによるパラメータバインドも使えます。

// SQL API(同期実行)
const rows = this.ctx.storage.sql
  .exec("SELECT * FROM users WHERE team = ?", teamId)
  .toArray();

const count = this.ctx.storage.sql
  .exec("SELECT count(*) AS c FROM users")
  .one().c; // 結果がちょうど 1 行でなければ例外

// 同期 KV API(SQLite バックエンドのみ)
this.ctx.storage.kv.put("config", { theme: "dark" });
const config = this.ctx.storage.kv.get("config");

SQL を使うまでもない単純な値には、同期 KV API(ctx.storage.kv の get / put / delete / list)や従来の非同期 KV API(ctx.storage.get() / put())も使えます。どのメソッドも暗黙にトランザクションで包まれ、await を挟まない一連の書き込みは自動的にまとめてアトミックにコミットされます。明示的なトランザクションが必要な場合は ctx.storage.transactionSync() を使います(sql.exec に BEGIN TRANSACTION を直接書くことはできません)。

Alarms — 自分を未来に起こす

Alarms は「この時刻に自分を起こして alarm() ハンドラを実行して」と予約する仕組みです。cron トリガーが Worker あたり 3 本までの静的なスケジュールなのに対し、アラームはオブジェクトごとに 1 本をコードから動的に設定でき、バッチ処理・TTL・キューなどの部品になります。

export class Batcher extends DurableObject<Env> {
  async enqueue(item: string) {
    this.ctx.storage.kv.put(`item:${crypto.randomUUID()}`, item);
    // アラーム未設定のときだけ 10 秒後に予約する
    const current = await this.ctx.storage.getAlarm();
    if (current === null) {
      await this.ctx.storage.setAlarm(Date.now() + 10_000);
    }
  }

  async alarm() {
    // 溜まった item をまとめて処理する(kv.list で列挙 → 処理 → 削除)
  }
}

アラームは at-least-once 実行が保証され、ハンドラが例外で失敗すると 2 秒から始まる指数バックオフで最大 6 回まで自動リトライされます。1 オブジェクトに設定できるアラームは同時に 1 つで、setAlarm() を重ねて呼ぶと上書きです。複数のイベントを扱いたい場合は、予定表をストレージに保存して alarm() の最後に次のアラームを張り直すパターンを使います。アラームハンドラのウォールタイム上限は 15 分です(2026-08 時点)。

WebSocket と Hibernation API

WebSocket は長寿命の双方向接続です。「接続をどこで受けるか」が問題になりますが、DO は単一インスタンスに複数クライアントの接続を集約できるため、チャットルームやマルチプレイヤーゲームの「部屋」の実装に最適です。

DO で WebSocket サーバーを作る API は 2 系統あります。

標準 WebSocket API クライアント WS 接続 DO(常駐) server.accept() で 自分で接続を握る アイドルでも メモリに残り続ける constructor() 再実行なし → 常に即応答 アイドル中も Duration 課金 Hibernation API(推奨) クライアント WS 接続 Cloudflare ランタイム acceptWebSocket() で 接続を預ける アイドルなら メモリから退避 次の受信で constructor() → webSocketMessage() 休止中は Duration 課金なし
図 8-4 違いは「WebSocket を誰が握っているか」です。標準 API では DO 自身がソケットとイベントリスナーを抱えるのでメモリから出られず、Hibernation API では接続を Cloudflare ランタイムに預けるため DO だけ退避できます。クライアントの接続はどちらでも切れません。代償は右下の再構築コストなので、接続ごとの状態は serializeAttachment() に預けておきます。

Hibernation API では、休止からの復帰時に constructor() が再実行され、インメモリ状態は消えています。接続ごとの状態は ws.serializeAttachment(value)(最大 16,384 バイト)で接続自体に添付しておき、ws.deserializeAttachment() で復元します。また ctx.setWebSocketAutoResponse() を使うと、ping/pong のような定型応答をDO を起こさずに返せます。1 つの DO が受けられる Hibernation WebSocket は最大 32,768 接続です(実際には CPU・メモリが先に上限になります)。

ハンズオン 8-1|グローバルカウンタを作る

名前ごとに独立したカウンタを持つ DO を作り、RPC メソッドと SQLite ストレージを体験します。所要 20 分程度です。

Durable Objects(SQLite バックエンド)は Workers Free プランで利用できます。この章のハンズオンに有料プランは不要です(2026-08 時点)。

  1. DO 用テンプレートでプロジェクトを作ります。プロンプトでは Hello World example → Worker + Durable Objects → TypeScript を選択し、デプロイは「No」にします。
    npm create cloudflare@latest -- do-counter
    cd do-counter
  2. src/index.ts の DO クラスを次の Counter に置き換えます。コンストラクタでテーブルを用意し、public メソッドを RPC として公開します。
    import { DurableObject } from "cloudflare:workers";
    
    export class Counter extends DurableObject<Env> {
      constructor(ctx: DurableObjectState, env: Env) {
        super(ctx, env);
        // このインスタンス専用の SQLite にテーブルを用意する
        this.ctx.storage.sql.exec(
          "CREATE TABLE IF NOT EXISTS counter (name TEXT PRIMARY KEY, value INTEGER NOT NULL)",
        );
      }
    
      async increment(amount = 1): Promise<number> {
        const rows = this.ctx.storage.sql
          .exec("SELECT value FROM counter WHERE name = 'total'")
          .toArray();
        const next = (rows.length ? Number(rows[0].value) : 0) + amount;
        // sql.exec は同期なので、この読み取り→書き込みに割り込みは入らない
        this.ctx.storage.sql.exec(
          "INSERT OR REPLACE INTO counter (name, value) VALUES ('total', ?)",
          next,
        );
        return next;
      }
    
      async getValue(): Promise<number> {
        const rows = this.ctx.storage.sql
          .exec("SELECT value FROM counter WHERE name = 'total'")
          .toArray();
        return rows.length ? Number(rows[0].value) : 0;
      }
    }
  3. 同じファイルの Worker 側ハンドラを置き換えます。クエリパラメータ name ごとに別インスタンスへルーティングします。
    export default {
      async fetch(request, env): Promise<Response> {
        const url = new URL(request.url);
        // name ごとに世界で 1 つのインスタンスが対応する
        const name = url.searchParams.get("name") ?? "global";
        const stub = env.COUNTERS.getByName(name);
    
        let value: number;
        if (url.pathname === "/increment") {
          value = await stub.increment();
        } else {
          value = await stub.getValue();
        }
        return new Response(`counter '${name}' = ${value}`);
      },
    } satisfies ExportedHandler<Env>;
  4. wrangler.jsonc のバインディング名とクラス名を合わせ、exports でクラスを宣言します(テンプレートが生成した宣言を書き換えます)。Env 型はテンプレートが worker-configuration.d.ts に自動生成しています。
    {
      "name": "do-counter",
      "main": "src/index.ts",
      "compatibility_date": "2026-08-01",
      "durable_objects": {
        "bindings": [
          { "name": "COUNTERS", "class_name": "Counter" }
        ]
      },
      "exports": {
        "Counter": { "type": "durable-object", "storage": "sqlite" }
      }
    }
  5. npx wrangler dev で起動し、別ターミナルから curl で叩きます。
$ curl "http://localhost:8787/increment"
counter 'global' = 1
$ curl "http://localhost:8787/increment"
counter 'global' = 2
$ curl "http://localhost:8787/"
counter 'global' = 2
$ curl "http://localhost:8787/increment?name=team-a"
counter 'team-a' = 1

global と team-a が独立して数えられていれば成功です。npx wrangler deploy で本番へデプロイすると、世界のどこからアクセスしても同じ名前は同じインスタンスに集約されます。プロセスを再起動しても値が残ること(ストレージの永続性)も確認してみてください。

ハンズオン 8-2|WebSocket チャットルームを作る

8-1 のプロジェクトに Hibernation API を使ったチャットルーム DO を追加し、部屋単位のブロードキャストを体験します。所要 20 分程度です。

  1. src/index.ts に ChatRoom クラスを追記します。WebSocket の接続受け入れは HTTP Upgrade の Request/Response を扱うため、RPC ではなく fetch() ハンドラを使います。
    export class ChatRoom extends DurableObject<Env> {
      async fetch(request: Request): Promise<Response> {
        // WebSocket ペアを作り、server 側をこの DO にアタッチする
        const pair = new WebSocketPair();
        const [client, server] = Object.values(pair);
    
        // 標準 API の server.accept() ではなく Hibernation API を使う
        this.ctx.acceptWebSocket(server);
    
        return new Response(null, { status: 101, webSocket: client });
      }
    
      async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
        // この部屋に接続中の全クライアントへブロードキャスト
        for (const peer of this.ctx.getWebSockets()) {
          peer.send(`[${this.ctx.id.name}] ${message}`);
        }
      }
    
      async webSocketClose(ws: WebSocket, code: number, reason: string) {
        // compatibility date 2026-04-07 以降は Close 応答が自動化されており、
        // この呼び出しは省略可能(呼んでも安全)
        ws.close(code, reason);
      }
    }
  2. Worker の fetch ハンドラの先頭に、部屋へのルーティングを追加します(カウンタのルーティングはその後ろに残します)。
    const match = url.pathname.match(/^\/rooms\/([\w-]+)\/websocket$/);
    if (match) {
      if (request.headers.get("Upgrade") !== "websocket") {
        return new Response("Expected Upgrade: websocket", { status: 426 });
      }
      // 部屋名ごとに 1 つの ChatRoom インスタンスに集約される
      const stub = env.CHAT_ROOMS.getByName(match[1]);
      return stub.fetch(request);
    }
  3. wrangler.jsonc にバインディングと exports エントリを追加します。
    {
      "durable_objects": {
        "bindings": [
          { "name": "COUNTERS", "class_name": "Counter" },
          { "name": "CHAT_ROOMS", "class_name": "ChatRoom" }
        ]
      },
      "exports": {
        "Counter": { "type": "durable-object", "storage": "sqlite" },
        "ChatRoom": { "type": "durable-object", "storage": "sqlite" }
      }
    }
  4. npx wrangler dev を起動し、ブラウザのタブを 2 つ開いて、それぞれの開発者ツールのコンソールで接続します。
    const ws = new WebSocket("ws://localhost:8787/rooms/lobby/websocket");
    ws.onmessage = (e) => console.log(e.data);
    ws.send("こんにちは");

片方のタブで ws.send("こんにちは") を実行すると、両方のタブのコンソールに [lobby] こんにちは が届けば成功です。/rooms/other/websocket に接続したタブにはメッセージが届かないこと(部屋の分離=インスタンスの分離)も確認しましょう。本格化する際は、送信者名を serializeAttachment() に載せて休止をまたいで復元する、入退室を通知する、履歴を sql.exec で保存するなどの拡張が定石です。

つまずきポイント

DO を URL で直接呼ぼうとする — Durable Objects はインターネットから直接リクエストを受けられません。必ず Worker がバインディング経由でスタブを作って呼び出します。「DO のエンドポイント URL」は存在せず、ルーティング・認証・検証は手前の Worker の仕事です。不正リクエストの遮断を Worker 側で行うと、DO への課金リクエストも減らせます。

クラスを export しただけでデプロイした / exports と migrations を併用した — コードにクラスがあるだけでは、ネームスペースはプロビジョニングされません。wrangler.jsonc の exports(または旧 migrations)での宣言が必要です。また exports と migrations は排他で、両方書くと検証エラーになります。一度 exports でデプロイすると旧方式へは戻れない点にも注意してください。デプロイ時の Durable Object exports reconciliation 出力にエラーの原因と修正候補が表示されます。

休止でインメモリ状態が消えて Map が空になる — Hibernation API では、アイドル時に DO がメモリから退避され、次のイベントで constructor() から再構築されます。接続一覧を Map に入れているだけだと休止後に空になります。接続ごとの状態は serializeAttachment() に載せ、コンストラクタで ctx.getWebSockets() から復元してください。逆に server.accept()(標準 API)や setTimeout / setInterval、送信側 WebSocket があると休止できず、Duration 課金が続きます。

アラームが 6 回失敗すると止まる — alarm() のリトライは指数バックオフで最大 6 回までです。依存先の長時間障害でリトライを使い切ると、次に setAlarm() を呼ぶまでアラームは再実行されません。恒久的に再試行したい処理は、ハンドラ内で例外を catch して自前で次のアラームを張り直します。また休止復帰時は alarm() の前に constructor() が走るため、コンストラクタで setAlarm() する場合は getAlarm() で既存の予約を確認してからにします。

理解度チェック

Q1. 世界中の複数リージョンから getByName("room-1") でアクセスすると、何が起きますか?

すべてのリクエストが、世界でただ 1 つの「room-1」インスタンスにルーティングされます。名前(から作られる ID)ごとにインスタンスはグローバルに一意で、これが複数クライアント間の協調ポイントになります。初回アクセス時のみ一意性確認のため数百 ms 程度の遅延が乗ることがあります。

Q2. ctx.storage.sql.exec() が使える条件と、使えない場合の代替は?

SQL API は SQLite ストレージバックエンドのクラス(exports で "storage": "sqlite"、旧方式なら new_sqlite_classes で作成)でのみ使えます。旧来の KV バックエンドのクラスで呼ぶとエラーになり、非同期 KV API(ctx.storage.get() / put() など)だけが使えます。新規ネームスペースは SQLite バックエンドのみ作成できます。

Q3. WebSocket Hibernation API が標準 WebSocket API より推奨される主な理由は?

クライアントの接続を維持したまま、アイドル時に DO 本体をメモリから休止できるからです。休止中は Duration(GB-秒)課金が発生しません。標準 API(server.accept())では接続中ずっと DO が常駐し、課金が続きます。休止するとインメモリ状態は失われるため、接続ごとの状態は serializeAttachment() で保全します。

Q4. alarm() ハンドラの実行保証とリトライ仕様は?

at-least-once(少なくとも 1 回)の実行が保証されます。ハンドラが未捕捉の例外で失敗すると、2 秒から始まる指数バックオフで最大 6 回まで自動リトライされます。設定できるアラームは 1 オブジェクトにつき同時に 1 つで、setAlarm() の重ね掛けは上書きになります。

Q5. カウンタの「読み取り→加算→書き込み」を素朴に書いても壊れないのはなぜですか?

各インスタンスはシングルスレッドで、ストレージ操作中は入力ゲートが他のイベントの割り込みを防ぐためです。さらに sql.exec() は同期 API なのでイベントループを譲る隙がありません。同一インスタンスへのリクエストは実質直列に処理されるため、アプリ側でロックを実装する必要がありません。

まとめと次章へ

参考リンク:

次章は Queues です。DO が「今この瞬間の協調」を担うのに対し、Queues は「あとで確実にやる」を担います。受け付けたら即応答し、重い処理は非同期のキューに逃がす — producer と consumer を両方書いて、Cloudflare 流の非同期メッセージングを体験します。