🎇

Cloudflare Workersと各種ストレージについての実用基本メモ

に公開

Cloudflareを使用して何かをしようと思った時に、基本的な部分でどうするんだっけが多発するためメモとして残しておきます。各サービスがどのようなものか、というさわりの部分は省いているのでその部分については他の記事を参照ください。記事にするために色々調べたりしたところで新たな気づきがあったりしたので基本的な知識について補強できたように思います。
少し話が変わりますが、TTL付の強整合KVSがCloudflareに欲しくなりDurable Objectsを使って作成したので、そのうち公開するかもしれません。

前提

  • 記事作成時点の仕様について記載
  • GitHub Actions等の別環境からCloudflare Workersにデプロイする

Cloudflare Workers基本

サービス概要は省略。簡易的な動作確認はPlaygroundで可能。ここの記載以外については公式ドキュメントを参照。

前提知識

対応言語

現時点で以下が対応言語として挙げられているが、正式リリースとしては実質的にJS/TSのみ。RustはWasmとしてコンパイル・実行される。そのため、成果物としてWasmバイナリとしてコンパイル可能な言語はWorkersで使用可能とも言える。

  • JS/TS
  • Rust
  • Python (Beta)

wranglerツール

以下前提であればwranglerツールをローカル開発環境にインストールする必要性はあまりない。

  • GitHub Actions等の別環境からデプロイする
  • KVやD1等の事前準備をダッシュボードで実施する
  • wrangler.jsonc向けのJSONスキーマを外部参照する
  • 小規模な開発

以下のような必要になった時にスポットでnpxする程度で対応可能。(Denoであればdeno run -A npm:wrangler@latest types等)

  • プロジェクト作成時: npx wrangler init
  • 型定義ファイル更新: npx wrangler types

各種設定について

wrangler.jsonc.dev.vars,.env.local等の環境変数ファイルを変更した場合、変更を反映するためにnpx wrangler typesを都度実行して型定義ファイルworker-configuration.d.tsを再生成する必要がある。

Workersの設定

Workersの動作設定としてwrangler.jsoncファイルを作成する必要がある。以前はTOML形式のファイルが用いられていたがJSONC形式もサポートされた。現時点でCloudflareはJSONC形式を推奨しているため、将来的にTOML形式がどうなるかわからない。

npx wrangler initを実行するとプロジェクト作成とともに自動作成される。現時点では大した内容は出力されないので、既存ディレクトリに作成したい場合はnpx wrangler initを使用せず手動でwrangler.jsoncを作成する方が明確で早い。

サンプル設定

ローカル開発環境にwranglerをインストールしていない場合、ローカルにJSONスキーマが存在しないため外部を参照する必要がある。公式のサンプルや各項目説明はこちら

config example
wrangler.jsonc
{
  // "$schema": "node_modules/wrangler/config-schema.json", // if installed wrangler locally
  "$schema": "https://www.unpkg.com/wrangler@latest/config-schema.json",
  "name": "my-workers-project", // required
  "main": "dist/index.js",      // required
  "compatibility_date": "2025-10-17", // required
  "compatibility_flags": [
    "nodejs_compat" // required if you use Hyperdrive
  ],
  "workers_dev": false,
  "route": {
    "pattern": "mydomain.example.com",
    "custom_domain": true
  },
  "durable_objects": {
    "bindings": [
      { // `my-workers-project_MyObjectClass` will be appeared on your dashboard as namespace
        "name": "MY_DURABLE_OBJECT",  // just access name  e.g., env.MY_DURABLE_OBJECT
        "class_name": "MyObjectClass" // name of actual class definition extends `DurableObject`
      }
    ]
  },
  "kv_namespaces": [
    {
      "binding": "MY_KV_NAMESPACE", // access name, and the name on dashboard  e.g., env.MY_KV_NAMESPACE
      "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    }
  ],
  "d1_databases": [
    {
      "binding": "MY_D1_DATABASE",         // just access name  e.g., env.MY_D1_DATABASE
      "database_name": "my_database_name", // name of the database that is appeared on dashboard
      "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
    }
  ],
  "r2_buckets": [
    {
      "binding": "MY_R2_BUCKET",      // just access name  e.g., env.MY_R2_BUCKET
      "bucket_name": "my-bucket-name" // name of the bucket that is appeared on dashboard
    }
  ],
  "hyperdrive": [
    {
      "binding": "MY_HYPERDRIVE_CONN", // access name, and the name on dashboard  e.g., env.MY_HYPERDRIVE_CONN
      "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    }
  ],
  "observability": {
    "enabled": true
  },
  "migrations": [
    {
      "tag": "v1",
      "new_sqlite_classes": [ // required for durable objects as SQLite-backed
        "MyObjectClass"
      ]
    }
  ]
}

環境変数の設定

ローカル開発環境

.dev.vars.env.localなどに以下のように環境変数を定義して使用する。(詳細)

.env.local
FOO="my-secret-value"
BAR="DO NOT PUSH THIS FILE TO GITHUB"

本番環境

GitHub Actionsから以下のようにGitHubのシークレットを使用してデプロイする。
cloudflare/wrangler-action@v3を用いてデプロイすることも可能だが、現時点では内部的にwrangler v3系を使用しているようでログにバージョン警告が出る。依存関係を減らすという意味でも、以下例ではシンプルにNode.js環境からnpx wranglerを用いてデプロイする方法を使用している。

sample
deploy.yaml
name: Deploy Worker
on:
  push:
    branches:
      - main
jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 3
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Install Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "lts/*"
          registry-url: "https://registry.npmjs.org"

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Set Cloudflare secrets
        run: |
          echo "${{ secrets.EXTERNAL_SERVICE_API_KEY }}" | npx wrangler secret put EXTERNAL_SERVICE_API_KEY
        env:
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

      - name: Deploy to Cloudflare Workers
        run: npx wrangler deploy
        env:
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

コーディングについて

ハンドラの種類

Workersでは現在のところ以下のハンドラが使用可能で、それぞれをWorkersのエントリーポイントとして認識して問題ない。

  1. fetch: HTTPリクエストに対するハンドラ
    index.ts
    export default {
      async fetch(request: Request, env: Env, ctx: ExecutionContext) {
        return new Response("Hello World!");
      },
    };
    
  2. queue: Queue Consumerとしてのハンドラ (Cloudflare Queuesサービス用)
    index.ts
    export default {
      async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext) {
        for (const message of batch.messages) {
          console.log("Received", message);
        }
      },
    };
    
  3. scheduled: Cron Triggersに対するハンドラ
    index.ts
    export default {
      async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
        console.log("cron processed");
      },
    };
    
  4. email: Email Routingに対するハンドラ
    index.ts
    export default {
      async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext) {
        await message.forward("foo@mail.example.com");
      },
    };
    
  5. tail: Tail Workerとして動作する場合のハンドラ
    index.ts
    export default {
      async tail(events: TailItems[], env: Env, ctx: ExecutionContext) {
        console.log(events);
        await fetch("https://example.com/api/observer/receive", {
          method: "POST",
          body: JSON.stringify(events),
    	  })
      },
    };
    

各ハンドラの共通引数

env: Env

環境変数やこのWorkerで使用するCloudflareサービスへのアクセスを提供する。Env型はwrangler.jsoncによって変化し、以下のような型定義がnpx wrangler typesによって生成される。

Workersのコード内でbinding名として定義したプロパティを通じて、env.MY_D1_DATABASE.prepare("")のような形でWorkersから各サービスが提供するメソッド等へアクセスする。

interface Env {
  FOO: string;  // env var
  MY_KV_NAMESPACE: KVNamespace;
  MY_DURABLE_OBJECT: DurableObjectNamespace /* MyObjectClass */;
  MY_R2_BUCKET: R2Bucket;
  MY_D1_DATABASE: D1Database;
  MY_HYPERDRIVE_CONN: Hyperdrive;
}

ctx: ExecutionContext

あるリクエスト等によってWorkersが起動してから停止するまでの処理全体を表現するような引数。例えばレスポンスはすぐに返したいが、バックグラウンドで外部との通信等の長め処理を継続したい場合、以下のようにctx.waitUntilを用いて処理を追加することでfetch関数が完了した後もWorkersを動作させ続けることができる。

入出力待ちやsetTimeoutによる待ち時間はCPU時間制限や請求額にカウントされない。(はず)

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    ctx.waitUntil(processLongTime());
    return new Response("Hello World!");
  },
};
async function processLongTime() {
  // some long process
}

ストレージ系サービス

以下からDurable Objectsは"DO"、Hyperdriveは"HD"等と省略表記する場合がある。また、各個別サービスについては公式ドキュメントで簡単に見つけられるような内容は省略している。

簡単な俯瞰と概要

各種サービス

主なサービス

サービス 概要
Durable Objects 強整合のKVS,RDB両対応
Workers KV 結果整合のKVS
D1 SQL Database 結果整合のRDB
R2 object storage ファイル等用のS3互換ストレージ
Hyperdrive 外部RDBへのコネクションプール

KVとD1はエッジストレージのため読み出しが早い。

その他

画像ホスティングに特化したImagesというサービスもあり、サイズ変更や形式変換などの処理を伴う画像ホスティングが必要な場合に便利。その他にもAnalytics EngineやVectorize、Streamなど多くのサービスがある。

選び方

あくまでも主観のため注意。

  1. ファイル等の大容量データ -> R2
  2. 格納したいデータの形式
    1. キーバリュー型 (KVS)
      • 強整合が必要 -> DO
      • 結果整合でいい -> KV
    2. レコード型 (RDB)
      • 強整合が必要 -> HD(外部RDB) or SQLite in DO
      • 結果整合でいい -> D1

比較

KVS

整合性 値の型制限 TTL設定 事前準備
DO 強整合 無し 不可 不要
KV 結果整合 有り 可能 必要
  • DOのTTL設定について、自分で追加実装することは可能
  • KVの型制限: string | ArrayBuffer | ArrayBufferView | ReadableStream

RDB

整合性 値の型制限 事前準備
D1 結果整合 SQLiteの型 必要
DO 強整合 SQLiteの型 不要(不可)
HD 強整合 PostgreSQL又はMySQLの型 必要
  • DOはテーブル作成等をWorkersのコード中で実行する必要がある
    • 特殊用途以外ではRDBサービスとして使いにくい

Durable Objects

種類

以下の2種類がある。

  • KV-backed Durable Objects (従来型)
  • SQLite-backed Durable Objects (新型)

CloudflareはSQLite-backed型を推奨しており、従来型は下位互換性のために残されている。(参考)
この経緯により、DOはKVSとRDB両方の性質を持つようになったと思われる。

特徴

WebSocket用のメソッドがいくつも用意されており、WebSocket用途ではDOを使用するのがベストプラクティスと思われる。また、setAlarmを用いることでcron jobのように定期的に値のクリーンアップなどの処理を実行させることも可能。

基本的な実装

以下例のように一つのDO実装に対して一つのクラスを定義する必要がある。同じクラスを使用した別のインスタンスを作成する場合、idFromNameに指定する文字列を変更する。以下例のようにdo1.put()の後に同じキーでdo2.put()を呼び出してもインスタンスが異なるためdo1は上書きされない。
公式ドキュメントではコンストラクタにWorkersのハンドラと同じようなctxという名称の引数が使用されているが型が異なるため注意。

worker-main.ts
const do1: DurableObjectStub<MyObjectClass> = env.MY_DURABLE_OBJECT.get(env.MY_DURABLE_OBJECT.idFromName("identify-string1"));
const do2: DurableObjectStub<MyObjectClass> = env.MY_DURABLE_OBJECT.get(env.MY_DURABLE_OBJECT.idFromName("identify-string2"));
do1.put("key", "value1");
do2.put("key", "value2");
class-def.ts
import { DurableObject } from "cloudflare:workers";

class MyObjectClass extends DurableObject<Env> {
  #sql: SqlStorage;
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.#sql = ctx.storage.sql;
  }

  put(key: string, value: string) {
    this.ctx.storage.put(key, value);
  }
  replace(key: string, value: string): string {
    let current = "";
    this.ctx.blockConcurrencyWhile(async () => {
      current = await this.ctx.storage.get(key);
      await this.ctx.storage.put(key, value);
    });
    return current;
  }
  // ...
}

以下のようにRequestをそのままクラスインスタンスに送信するように書くことも可能。

worker-main.ts
const do: DurableObjectStub<MyObjectClass> = env.MY_DURABLE_OBJECT.get(env.MY_DURABLE_OBJECT.idFromName("identify-string1"));
const response = await do.fetch(request);
class-def.ts
import { DurableObject } from "cloudflare:workers";

class MyObjectClass extends DurableObject<Env> {
  async fetch(request: Request) {
    return new Response("Hello, World!");
  }
  // ...
}

RDBとして使用する場合は以下例のように書く。execの結果はカーソル型SqlStorageCursorが返る。

interface SelectRow {
  value: string;
  [key: string]: SqlStorageValue;
}

class MyObjectClass extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    MyObjectClass.#initialize(ctx);
  }
  static #initialize(ctx: DurableObjectState) {
    ctx.blockConcurrencyWhile(async () => {
      if (await MyObjectClass.#isTable(ctx)) return;
      await MyObjectClass.#createTable(ctx);
    });
  }
  static async #isTable(ctx: DurableObjectState): Promise<boolean> {
    return await ctx.storage.get("init") ?? false;
  }
  static async #createTable(ctx: DurableObjectState): Promise<void> {
    await ctx.storage.put("init", true);
    ctx.storage.sql.exec("create table if not exists my_table (key string primary key not null, value text not null);");
  }

  get(key: string): string | undefined {
    const result = this.ctx.storage.sql.exec<SelectRow>("select value from my_table where key = ?;", key).next();
    return result.done ? undefined : result.value.value;
  }
  // ...
}

参考データ型

interface SqlStorage {
  exec<T extends Record<string, SqlStorageValue>>(query: string, ...bindings: any[]): SqlStorageCursor<T>;
  get databaseSize(): number;
  Cursor: typeof SqlStorageCursor;
  Statement: typeof SqlStorageStatement;
}
type SqlStorageValue = ArrayBuffer | string | number | null;
declare abstract class SqlStorageCursor<T extends Record<string, SqlStorageValue>> {
  next(): {
    done?: false;
    value: T;
  } | {
    done: true;
    value?: never;
  };
  toArray(): T[];
  one(): T;
  raw<U extends SqlStorageValue[]>(): IterableIterator<U>;
  columnNames: string[];
  get rowsRead(): number;
  get rowsWritten(): number;
  [Symbol.iterator](): IterableIterator<T>;
}

イメージ

[Workers] <---> [DOのクラスインスタンス] <---> [永続化ストレージ]

  • WorkersはDOクラスインスタンスのメソッドを呼び出す
  • DOクラスインスタンスはthis.ctx.storage.put等で永続化ストレージにアクセスする

主な制限

DOのクラスインスタンスはアイドル状態が続くと削除される。当たり前だが、その際にインスタンスプロパティに保存していた値も削除される。目安として以下タイミングで削除されるらしい。(情報源Claude)

条件 頻度 メモ
アイドル状態 数分〜数十分 リクエストがない場合、通常10-30分程度でevict
新しいデプロイ デプロイ毎 コード更新時は即座に再作成
高負荷時の移行 Cloudflareが別ロケーションに移動(数時間〜数日に1回程度)
システムメンテナンス インフラ更新時(週〜月単位)

データの削除

完全に削除するためにはthis.ctx.storage.deleteAll()を呼び出す。(参考)
setAlarmを使用した場合はdeleteAlarmが必要だと記載されているが、deleteAllの呼び出しで請求されないことを保証してもいる。

Calling deleteAll() ensures that a Durable Object will not be billed for storage.

deleteAlldeleteAlarmを含むのか、別個に必要なのか不明。また、一応ダッシュボードにも削除ボタンはあるが、それが何を実行するのかは不明。

Workers KV

特徴

Key-Valueの他に同時にTTLや他のメタデータを格納可能。putメソッドのオプションの型は以下となっている。

interface KVNamespacePutOptions {
  expiration?: number;    // absolute, expired at <X>
  expirationTtl?: number; // relative, expired at <now + X seconds> (minimum 60)
  metadata?: (any | null); // object that will be serialized to JSON
}

expiration, expirationTtlを指定しない場合は永続化される。(はず)
getメソッドのオプションとしてcacheTtlを設定するとgetの結果をキャッシュすることも可能。
ダッシュボードから値確認など管理することも可能。

基本的な実装

worker-main.ts
await env.MY_KV_NAMESPACE.put("key1", "value1");
await env.MY_KV_NAMESPACE.put("key2", "value2");
const values: Map<string, string | null> = await env.MY_KV_NAMESPACE.get(["key1", "key2"]);
const result = await env.MY_KV_NAMESPACE.getWithMetadata("key1");
// result.value === "value1"
// result.metadata === null
await env.MY_KV_NAMESPACE.delete("key2");

主な制限

  • keyには以下の文字列を指定できない
    • ""
    • "."
    • ".."
  • 各値のサイズ制限
    • key: 512 bytes
    • value: 25 MiB
    • metadata: 1024 bytes

D1 SQL Database

特徴

あらかじめ何らかの手段でデータベースを作成しておき、そのデータベースに対して操作を行う。ダッシュボードから事前にDDLを発行してテーブルを作成することや、DMLで直接値を確認する事等も可能。
env.MY_D1_DATABASEはDOのctx.storage.sqlとは型が違い、使い方が大きく異なる。基本的にSQLの実行結果はカーソルではなく配列として取得される。

クエリの実行が遅い場合はSmart Placement設定を有効にすると改善する可能性がある。

基本的な実装

types.ts
interface TableRow {
  userid: string;
  username: string;
}
interface RowCount {
  count: number;
}
worker-main.ts
const username = "my-user";
const row = await env.MY_D1_DATABASE
  .prepare("select count(userid) as count from my_table where username = ?;")
  .bind(username)
  .first<RowCount>();
// const rowCount = row?.count ?? 0;
const rows = await env.MY_D1_DATABASE.prepare("select userid, username from my_table;").run<TableRow>();
// console.log(rows[0]?.userid);
await env.MY_D1_DATABASE.exec("delete from my_table;");
await env.MY_D1_DATABASE.batch([ // transactional procedure
  this.#d1.prepare("insert into ...").bind(userid, username),
  this.#d1.prepare("update ...").bind(userid),
]);

主な制限

  • テーブルの最大列数: 100
  • 最大行サイズ: 2 MB
  • 発行SQLの長さ: 100 KB
  • 最大bind数: 100
  • 最大実行時間: 30 sec

R2 object strage

特徴

データ通信料金(データエグレス料金)が無料のストレージサービス。ダッシュボードから格納したファイルを確認することが可能。
オブジェクトとして以下データ型を指定可能。

  • ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob

基本的な実装

worker-main.ts
const file = (await request.formData()).get("file");
if (!file || !(file instanceof File)) return new Response("error", { status: 400 });

await env.MY_R2_BUCKET.put(key, file.arrayBuffer(), {
  httpMetadata: {
    contentType: file.type || "application/octet-stream",
  },
  customMetadata: {
    originalName: file.name,
  },
});

const metadata: R2Object | null = await env.MY_R2_BUCKET.head(key);
const result: R2ObjectBody | null = await env.MY_R2_BUCKET.get(key);
await await env.MY_R2_BUCKET.delete(key);

参考データ型

types.ts
declare abstract class R2Object {
  readonly key: string;
  readonly version: string;
  readonly size: number;
  readonly etag: string;
  readonly httpEtag: string;
  readonly checksums: R2Checksums;
  readonly uploaded: Date;
  readonly httpMetadata?: R2HTTPMetadata;
  readonly customMetadata?: Record<string, string>;
  readonly range?: R2Range;
  readonly storageClass: string;
  readonly ssecKeyMd5?: string;
  writeHttpMetadata(headers: Headers): void;
}
interface R2ObjectBody extends R2Object {
  get body(): ReadableStream;
  get bodyUsed(): boolean;
  arrayBuffer(): Promise<ArrayBuffer>;
  bytes(): Promise<Uint8Array>;
  text(): Promise<string>;
  json<T>(): Promise<T>;
  blob(): Promise<Blob>;
}
interface R2HTTPMetadata {
  contentType?: string;
  contentLanguage?: string;
  contentDisposition?: string;
  contentEncoding?: string;
  cacheControl?: string;
  cacheExpiry?: Date;
}

主な制限

  • 各値のサイズ制限
    • キー長: 1024 bytes
    • メタデータ: 8192 bytes
    • オブジェクトサイズ: 4.995 TiB
    • アップロードサイズ: 4.995 GiB

Hyperdrive

特徴

外部RDBとしてPostgreSQL又はMySQLに接続可能で、それらに準拠するサービス(CockroachDBやMariaDB等)でも使用可能。コネクションプーリングは無償プランで最大20、有償プランで最大100までプールされる。事前に接続文字列をCloudflareに登録しておき、それを用いて接続する。

また、select等の読み出しクエリに対するクエリキャッシュを持つことができ、デフォルトで有効になっている。キャッシュは標準で60秒間有効で最大1時間まで設定可能。クエリキャッシュ機能を無効化することもできる。

基本的な実装 (PostgreSQL)

wrangler.jsonc
{
  "compatibility_flags": [ // required
    "nodejs_compat"
  ],
}
worker-main.ts
import { Client } from "pg"; // need to install the `pg` driver, and it's type `@types/pg`

// ...

const client = new Client({
  connectionString: env.MY_HYPERDRIVE_CONN.connectionString,
})

try {
  await client.connect();
  const result = await client.query("select * from my_table;");
  // console.log(result.rows);
} catch { /* ... */ }

制限

  • 最大アイドル時間: 10 min
  • 最大実行時間: 60 sec
  • ユーザー名長: 63 bytes
  • データベース名長: 63 bytes

10分以上アイドル時間が続く場合は初回接続待ちが発生する。

雑記

Cloudflareは計算もデータもエッジで解決すればどこでもからでも高速になる、という理想的な思想がベースにあるように思います。その思想自体は良いと思うのですが、整合性とのトレードオフやリージョン指定がほぼ無いため計算とデータのロケーションが意図せず離れてしまいデータ取得が致命的に遅くなる場合があるなど、開発側にとって制御不能に近いような問題に向き合わないといけなくなる印象があります。何事も理想と現実の差は難しいものですね。

参考文献

Discussion