12k
All articles

Cloudflare Durable Objects 初心者ガイド

Cloudflare Durable Objectsの仕組みを解説。ルーティング、単一インスタンスの状態、SQLiteストレージ、Workers向けTypeScriptのレート制限例まで紹介。

OpenReplay Team
OpenReplay Team
Cloudflare Durable Objects 初心者ガイド

Durable Object とは、Cloudflare が常に世界中のいずれか 1 か所だけで実行する JavaScript クラスの単一インスタンスです。そのインスタンスを名前で指定したリクエストは、世界中のどこから発生したものであっても、すべてそのインスタンスにルーティングされます。さらに、インスタンスは自身専用のプライベートなストレージを持ちます。

素の Workers 上でカウンター、ロック、あるいは「この部屋に誰がいるか」というリストを構築すると、2 つのリクエストの見解が食い違ってしまうことがあります。1 つ目のリクエストを処理した Worker と 2 つ目を処理した Worker は、異なる都市にある別々の isolate である可能性があり、両者の間で共有されるメモリは存在しません。

本ガイドでは、Durable Objects を成り立たせているルーティングモデルを説明し、続いてそれを示す最小限の TypeScript コードを、API キー単位のレートリミッターという 1 つの題材を通して紹介します。なお、バインディング、wranglerfetch ハンドラーについては既知であることを前提としています。これらの前提知識が必要な場合は、まず OpenReplay の Cloudflare Workers 初心者ガイド から読み始めてください。

重要なポイント

  • Durable Objects は、Worker から読み取るストレージ製品ではなく、自身専用のプライベートストレージを備えたコンピュートプリミティブです。そのストレージには、オブジェクト内部で実行されるコードからのみアクセスできます。
  • env.BINDING.getByName(name) に渡す文字列がオブジェクトのアイデンティティになります。Cloudflare ネットワーク上のどこからのリクエストであっても、同じ文字列を渡せば同じ実行中インスタンスに到達します。
  • 各 Durable Object は、自身のコードと同じスレッド上に組み込みの SQLite データベースを保有します。そのため this.ctx.storage.sql.exec() は同期的にカーソルを返し、await を必要としません。
  • クラスフィールドは連続するリクエスト間では保持されますが、約 10 秒間アクティビティがないとオブジェクトがハイバネート(休止)し、その際に破棄されます。永続化が必要なものは ctx.storage に置いてください。
  • 多数のロケーションが同じデータを読み取り、書き込みが全ロケーションに反映されるまで時間がかかっても問題ない場合は Workers KV を使用します。複数のクライアントが同一時点で現在の値について合意しなければならない場合は Durable Object を使用します。

ステートレスな Workers が協調処理で失敗する理由

Worker はリクエスト間で何も保持しません。2 つの呼び出しは異なる場所の異なる isolate に着地する可能性があり、どちらも相手が何をしたかを知ることができません。そのため、連続するリクエストが値について合意する必要がある機能はすべて破綻します。Cloudflare 自身の Durable Objects の設計ガイダンス も、ステートレスな Workers とステートフルな協調処理の間に、まさにこの線を引いています。

以下は、一見正しそうに見えるが実際には正しくない、素朴なレートリミッターです。

// Broken: this Map exists per isolate. Another isolate has its own copy.
const hits = new Map<string, number>();

export default {
  async fetch(request): Promise<Response> {
    const key = request.headers.get("x-api-key") ?? "anonymous";
    const count = (hits.get(key) ?? 0) + 1;
    hits.set(key, count);
    return new Response(count > 10 ? "slow down" : "ok", {
      status: count > 10 ? 429 : 200,
    });
  },
} satisfies ExportedHandler;

モジュールレベルの Map は 1 つの isolate 内にのみ存在します。30 件のリクエストを送信するクライアントが 3 つの isolate に振り分けられた場合、それぞれが 10 で止まる独立した 3 つのカウンターが存在することになり、上限は決して強制されません。カウンターを外部データベースに移せば共有の問題は解決しますが、同時リクエスト間で read-then-write の競合が発生します。問題はデータがどこに置かれているかではなく、チェックと更新が一緒に行われる単一の場所を何も保証していないことなのです。

中心となる考え方: 名前ごとに 1 つのオブジェクト、常に同じインスタンス

Durable Objects は、各名前に対して実行中のインスタンスをちょうど 1 つだけ割り当て、その名前へのすべてのリクエストをそこへルーティングすることで、協調処理の問題を解決します。コンセプトのページ は、その背後にある 3 つの性質を示しています。各オブジェクトは世界中で一意な名前に応答する、ストレージはネットワーク越しではなくオブジェクトと同じ場所にある、そしてブラウザータブ内の JavaScript と同じように一度に 1 つのことだけを実行する、という点です。

このモデルから 3 つの性質が導かれます。

  1. アイデンティティは名前である。 Worker が文字列(API キー、ルーム ID、ドキュメント ID など)を選び、プラットフォームがそれを 1 つのインスタンスにマッピングします。異なる大陸にある 2 つの Worker が同じ文字列を渡せば、同じオブジェクトと通信します。
  2. 生成は暗黙的である。 create 呼び出しは存在しません。ネームスペース API リファレンス では、ID 自体は何も生成せず、実際に何かがオブジェクトに到達するまでオブジェクトは構築されないと説明されています。実際には、スタブに対する最初のメソッド呼び出しが届いた時点でコンストラクターが実行されます。
  3. 実行はシングルスレッドである。 メソッド内の同期コードは、他のリクエストによって割り込まれることはありません。他のリクエストが実行できるのは、fetch() のようなストレージ以外の I/O を await している間だけです。

各オブジェクトは 1 台のマシン上の 1 スレッドであるため、スループットはスケールアップではなくスケールアウトで拡張します。レートリミッターは API キーごとに 1 オブジェクトとすべきで、全トラフィックに対して 1 つのグローバルオブジェクトを使ってはいけません。

Durable Object クラスの定義と登録の方法

Durable Object は、cloudflare:workersDurableObject を継承し、コンストラクターで ctxenv を受け取り、public メソッドを RPC 経由で Workers に公開する、エクスポートされたクラスです。入門ガイド では、コンストラクターのシグネチャを (ctx: DurableObjectState, env: Env) とし、super(ctx, env) の呼び出しを必須としています。

import { DurableObject } from "cloudflare:workers";

export class RateLimiter extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.ctx.storage.sql.exec(
      "CREATE TABLE IF NOT EXISTS hits (bucket INTEGER PRIMARY KEY, count INTEGER NOT NULL)"
    );
  }

  async increment(limit: number, windowMs: number): Promise<{ allowed: boolean; remaining: number }> {
    const bucket = Math.floor(Date.now() / windowMs);

    // No await between the read and the write: nothing else can run in between.
    const row = this.ctx.storage.sql
      .exec<{ count: number }>("SELECT count FROM hits WHERE bucket = ?", bucket)
      .toArray()[0];
    const count = row ? row.count + 1 : 1;

    this.ctx.storage.sql.exec(
      "INSERT INTO hits (bucket, count) VALUES (?, ?) ON CONFLICT(bucket) DO UPDATE SET count = ?",
      bucket, count, count
    );

    return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
  }
}

SELECT とアップサートの間に await が存在しないため、この API キーに対する他のリクエストが間に割り込むことはできません。この 1 点こそ、ステートレス版では提供できなかったものです。

クラスの登録には、wrangler.jsonc に 2 つのエントリーが必要です。exports エントリー はクラスを sqlite ストレージを持つ durable-object として指定し、初回デプロイ時にネームスペースをプロビジョニングします。durable_objects.bindings エントリー は、env を通じて Worker にハンドルを提供します。

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

古い例では、new_sqlite_classes を含む migrations 配列でクラスを登録しています。この形式は既存の Workers では引き続きサポートされますが、現在の方法は exports であり、両者は 1 つの設定ファイル内に共存できません。

Worker から Durable Object を呼び出す方法

Worker は、バインディングに対して getByName(name) でスタブを取得し、そのスタブ上でクラスの public メソッドを通常の async 関数として呼び出すことで Durable Object にアクセスします。スタブはローカルのハンドルにすぎず、そこで行われた呼び出しは、その名前を所有する唯一のインスタンスへ転送されます。

export default {
  async fetch(request, env): Promise<Response> {
    const key = request.headers.get("x-api-key") ?? "anonymous";

    // `key` is the object's identity. Same key, same instance, everywhere.
    const stub = env.RATE_LIMITER.getByName(key);
    const { allowed, remaining } = await stub.increment(10, 60_000);

    return new Response(allowed ? "ok" : "slow down", {
      status: allowed ? 200 : 429,
      headers: { "x-ratelimit-remaining": String(remaining) },
    });
  },
} satisfies ExportedHandler<Env>;

getByName() に渡す文字列がルーティングキーになります。getByName(name) は、多くの例で今も見かける、従来の 2 段階の idFromName(name)get(id) の短縮形であり、いずれも同じオブジェクトを指します。スタブ上でメソッドを直接 RPC として呼び出すには、互換性日付(compatibility date)が 2024-04-03 以降 である必要がありますが、新しいテンプレートであればいずれも条件を満たしています。

Durable Object は状態をどこに保存するのか

各 Durable Object には 2 種類の状態があります。再起動をまたいで永続する、プライベートな組み込み SQLite データベースと、オブジェクトがメモリ上にある間だけ存在する通常のクラスフィールドです。SQLite ストレージ API はコードと同じスレッド上で動作するため、exec()await なしで即座に SqlStorageCursor を返します。次の await の前に、.toArray().one()、あるいはループを使ってカーソルを消費しきってください。await をまたいで開いたままのカーソルは、その間に書き込まれた行(後にロールバックされる書き込みを含む)を拾ってしまう可能性があります。

クラスフィールドは高速な経路です。レートリミッターに 1 つ追加すると、その違いがわかります。

export class RateLimiter extends DurableObject<Env> {
  // In-memory: fast, private to this instance, gone after hibernation.
  private lastSeen = 0;

  async increment(limit: number, windowMs: number) {
    this.lastSeen = Date.now();
    // ... SQLite read and write as before (durable)
  }
}

lastSeen は連続するリクエスト間では保持されますが、ライフサイクルのドキュメント によれば、10 秒間受信イベントがない場合(かつ保留中のタイマー、標準 API の WebSocket、実行中の fetch() がない場合)、オブジェクトはハイバネートし、そのメモリは破棄されます。デプロイやランタイムのメンテナンスによっても、いつでも再起動される可能性があります。hits テーブルはそのすべてを乗り越えて存続します。制限のページ によると、SQLite バックエンドの各オブジェクトは Workers Paid プランで最大 10 GB を保持できます。

Workers KV と Durable Objects のどちらを使うべきか

多数のロケーションが同じデータを読み取る必要があり、書き込みが全ロケーションに反映されるまで時間がかかっても許容できる場合は Workers KV を使用します。複数のクライアントが同一時点で現在の値について合意しなければならない場合は Durable Object を使用します。KV の整合性に関するドキュメント はこのトレードオフについて率直です。KV は速度のために整合性を犠牲にしており、変更が他のロケーションに現れるまで 1 分以上かかることもあるため、アトミックな読み取りと書き込みをまとめて必要とするものは代わりに Durable Objects を使うべきだと述べています。

Workers KVDurable Objects
整合性結果整合性。キャッシュされたコピーは TTL で失効強整合性。1 つのインスタンスがデータを所有
読み取りが発生する場所任意のロケーション、キャッシュから所有する単一インスタンスの内部
read-then-write の安全性リクエストをまたぐと保証なしストレージ以外の await を挟まなければ保証される
書き込みパターンキーごとに低頻度の書き込みオブジェクト単位の書き込みをランタイムが直列化
典型的な用途設定、フィーチャーフラグ、許可リストカウンター、ロック、ルーム、エンティティ単位の状態

ストレージ選択肢の比較 も両者を同じように整理しています。KV は、変更頻度よりも読み取り頻度がはるかに高い設定値などをカバーし、Durable Objects はクライアント間の協調処理と、オブジェクト単位で整合性を保つストレージをカバーします。レートリミッターは read-then-write のカウンターなので、Durable Object に属します。リクエストごとに読み取られるテナント単位のフィーチャーフラグは KV に属します。

まとめ

Durable Objects は、状態がどこにあるかという問題を取り除くことで協調処理を解決します。getByName() に渡した名前がちょうど 1 つの実行中インスタンスを選択し、その SQLite データベースはコードと同じスレッド上に存在し、ストレージ以外の await を挟まない読み取りと書き込みの組み合わせは他の処理とインターリーブされません。次のステップは、npm create cloudflare@latestWorker + Durable Objects テンプレートを生成し、生成されたクラスを上記のレートリミッターに置き換え、npx wrangler dev を実行して、本来であれば決して合意しないはずのリクエスト間でカウントが積み上がっていく様子を確認することです。

FAQ

Durable Objects と D1 の違いは何ですか?

D1 はマネージドな SQLite データベースで、Worker はネットワーク越しにクエリを実行し、HTTP API とスキーママイグレーションが組み込まれています。一方、Durable Object の SQLite データベースはオブジェクトのコードと同じマシン上で動作し、Workers からはそのオブジェクト経由でのみアクセスできます。どちらも Workers Paid プランではデータベースあたり 10 GB が上限で、無料プランではさらに低い上限が設定されています。共有のリレーショナルデータベースが 1 つ必要な場合は D1 を、協調処理が必要なユーザー単位・エンティティ単位の状態には Durable Objects を使用してください。

単一の Durable Object は 1 秒あたり何リクエストを処理できますか?

単一の Durable Object は 1 台のマシン上の 1 スレッドで動作するため、1 秒あたり約 1,000 リクエストというソフトリミットがあります。それを超えると、ランタイムは可能な範囲でキューイングし、それ以上の呼び出しは overloaded エラーで失敗します。各呼び出しにはデフォルトで 30 秒の CPU 時間が割り当てられ、Wrangler 設定の limits.cpu_ms で最大 5 分まで設定できます。API キーごとに 1 オブジェクトのように、名前ごとに 1 オブジェクトとしてスケールアウトしてください。

Durable Objects は Workers の無料プランで利用できますか?

はい。SQLite ストレージバックエンドを使用する Durable Objects は Workers 無料プランで利用可能で、オブジェクトあたり 1 GB、アカウントあたりの Durable Objects ストレージ合計 5 GB、Durable Object クラス数 100 が上限です。Workers Paid ではこれらがオブジェクトあたり 10 GB、アカウントストレージ無制限、クラス数 500 に引き上げられます。オブジェクトが満杯になると書き込みは SQLITE_FULL エラーで失敗しますが、行の読み取りや削除による空き容量の確保は引き続き可能です。

Durable Object 内で sql.exec() を使う際、明示的なトランザクションは必要ですか?

通常は不要です。sql.exec() の各呼び出しはすでに独自のトランザクション内で実行され、間に await を挟まずに連続する読み取りと書き込みは単一のアトミックなバッチとしてコミットされます。したがって、レートリミッターの read-then-write は記述されたままで安全です。なお sql.exec() では BEGIN TRANSACTION 文や SAVEPOINT 文を実行できません。複数の文をグループ化し、いずれかが例外を投げた場合にすべてロールバックさせたい場合は、ctx.storage.transactionSync(callback) を使用してください。コールバックは完全に同期的でなければならず、async 宣言も Promise の返却も不可です。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.