zudo-cloudflare-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

R2(オブジェクトストレージ)

ファイルとブロブストレージ用の Cloudflare R2

概要

R2 はエグレス料金ゼロの S3 互換オブジェクトストレージ。ファイル、画像、バックアップ、大容量データに使用する。

セットアップ

バケットの作成

npx wrangler r2 bucket create my-files

wrangler.toml に追加:

[[r2_buckets]]
binding = "FILES"
bucket_name = "my-files"

関数での使用

interface Env {
  FILES: R2Bucket;
}

// アップロード
await env.FILES.put("uploads/photo.jpg", imageData, {
  httpMetadata: { contentType: "image/jpeg" },
});

// ダウンロード
const object = await env.FILES.get("uploads/photo.jpg");
if (object) {
  return new Response(object.body, {
    headers: {
      "Content-Type": object.httpMetadata?.contentType || "application/octet-stream",
    },
  });
}

// 削除
await env.FILES.delete("uploads/photo.jpg");

// オブジェクトの一覧
const list = await env.FILES.list({ prefix: "uploads/" });
for (const object of list.objects) {
  console.log(object.key, object.size);
}

ブラウザからの直接アップロード(署名付き URL)

ネイティブの R2Bucket バインディングは、すべてのアップロードを Worker 経由でルーティングするため、ファイル本体が Worker のリクエストボディサイズ制限にカウントされ、Worker の CPU 時間を消費する。写真や動画など大きなユーザーアップロードには、署名付き PUT URL を発行し、ブラウザから R2 へ直接 アップロードさせて、バイト列については Worker を完全にバイパスする。

R2 は https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com で S3 互換 API を公開しているため、任意の S3 署名付き URL 生成ツールが使える。Worker 上での落とし穴はバンドルサイズだ。

AWS SDK ではなく aws4fetch を使う

aws4fetch(minify 後 ~5 KB)を使い、@aws-sdk/client-s3 + @aws-sdk/s3-request-presigner使わない。AWS SDK v3 は重い smithy / AbortSignal の仕組みを同梱しており、ここでは単一の署名呼び出しのためだけに Worker の 1 MB コードサイズ制限 を突破してしまう。

npm install aws4fetch

署名付き PUT URL の発行

import { AwsClient } from "aws4fetch";

interface Env {
  R2_ACCOUNT_ID: string;
  R2_ACCESS_KEY_ID: string;
  R2_SECRET_ACCESS_KEY: string;
  R2_BUCKET_NAME: string;
}

interface SignOpts {
  objectKey: string;
  contentType: string;
  expiresIn?: number; // seconds; defaults to 300 (5 minutes)
}

async function signPutUrl(env: Env, opts: SignOpts): Promise<string> {
  const client = new AwsClient({
    accessKeyId: env.R2_ACCESS_KEY_ID,
    secretAccessKey: env.R2_SECRET_ACCESS_KEY,
    service: "s3",
    region: "auto",
  });

  const expiresIn = opts.expiresIn ?? 300;
  const url = new URL(
    `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${env.R2_BUCKET_NAME}/${opts.objectKey}`,
  );
  // aws4fetch reads X-Amz-Expires from the URL when signQuery is true.
  // Without it, the s3 default is 86400 (24h) — far too long for an upload window.
  url.searchParams.set("X-Amz-Expires", String(expiresIn));

  const signed = await client.sign(
    new Request(url.toString(), {
      method: "PUT",
      headers: { "content-type": opts.contentType },
    }),
    {
      aws: {
        signQuery: true,
        // content-type is in aws4fetch's UNSIGNABLE_HEADERS by default.
        // allHeaders forces it into the signed-headers list.
        allHeaders: true,
      },
    },
  );
  return signed.url;
}

content-type の落とし穴

aws4fetch はデフォルトで content-type署名不可(unsignable) として扱い、署名から除外する。allHeaders: true を渡すと署名対象ヘッダーに含められる——宣言した MIME タイプにアップロードを固定できるため、これがまさに狙いだ。

落とし穴は、content-type が署名されたら、ブラウザは署名されたものと完全に同一の content-type ヘッダーで PUT しなければならない点だ。少しでも不一致があると、R2 は SignatureDoesNotMatch でアップロードを拒否する。

// Browser side — the content-type MUST match what was signed.
await fetch(presignedUrl, {
  method: "PUT",
  headers: { "Content-Type": "image/jpeg" }, // exactly what signPutUrl signed
  body: fileBlob,
});

署名付き URL は使い捨てではない

R2 は最初の PUT のあとに署名付き URL を 無効化しない。唯一の制約は有効期限(X-Amz-Expires)だけだ。TTL は短く保ち(300 秒が妥当なデフォルト)、URL を保持している者なら誰でも期限切れまで再利用できるケイパビリティとして扱うこと。

バインディングではなく S3 API トークン

署名付き URL の生成には R2 の S3 API トークン——R2_ACCESS_KEY_IDR2_SECRET_ACCESS_KEY——が必要で、ダッシュボードの R2 → Manage R2 API Tokens から作成する。これらはネイティブの [[r2_buckets]] バインディングとは 別の認証情報 だ。Worker は両方を併用できる:サーバーサイドの読み書きにはバインディング、署名付き URL の発行には S3 トークンを使う。

R2 と D1 のペアリング

削除(後述)の形を決めているのと同じ「分散トランザクションがない」という制約は、作成の形も決めている——ただし向きが逆になる。

ブロブを先に、行を最後に

ブロブを先に、D1 の行を最後に 書き込む:

// 1. Write the blob first. If this fails, nothing else has happened yet.
const key = `uploads/${crypto.randomUUID()}`;
await env.FILES.put(key, fileData, {
  httpMetadata: { contentType },
});

// 2. Row last. If this fails, the blob is orphaned but harmless —
//    nothing in D1 claims it exists.
await env.DB.prepare(
  "INSERT INTO photos (id, r2_key, content_type) VALUES (?, ?, ?)",
).bind(crypto.randomUUID(), key, contentType).run();

これは後述の削除順序と同じ不変条件を、作成側に適用したものだ:D1 の行は、存在しないブロブを決して参照してはならない。 ブロブを先に書き込んでおけば、2つのステップの間で失敗しても残るのは孤立したオブジェクト——回収については後述するとおり復旧可能——であり、ユーザーの目の前で 404 になる宙ぶらりんのポインタにはならない。

不変な UUID キー

キーは crypto.randomUUID()、またはコンテンツハッシュ(キーの安全性 参照)から生成する。ユーザーが指定した名前や表示タイトルなど、後で変わりうるものからは絶対に生成しない。

  • リネーム問題が起きない。 可変なフィールドから生成されたキーは、リネーム時に選択を迫る:キーを古いまま誤解を招く状態にしておくか、オブジェクトを移動するかだ。R2 にはアトミックなリネームがないため、「移動」はコピーしてから旧キーを削除することになり、この節が丸ごと回避しようとしている2ステップの失敗ウィンドウをまた開いてしまう。

  • 衝突問題が起きない。 UUID キーは書き込み前に存在チェックを必要としない。photo.jpg という名前のファイルが2つあると同じキーに衝突しうるが、UUID キーではそれが起こらない。

バッチ前のコピー

キーが不変であることには、バッチ処理に対する帰結がある:ジョブが一群のオブジェクトを置き換えたり再生成したりする場合(リサイズ処理、再エンコード、マイグレーションなど)、新しいブロブはすべて新しいキーの下に 先に、完全に書き込んでから、それが参照しようとしているオブジェクトがすべて実在する状態になって初めて D1 のバッチ更新を実行する。アップロードループがまだ実行中のうちにバッチ UPDATE を送り出すと、単一レコードの場合と同じ宙ぶらりんポインタの失敗が、規模を増して起こりうる——バッチの途中でクラッシュすると、一度も書き込まれていないブロブを指す行が残ってしまう。

孤立したブロブの回収

ブロブ先行の書き込みでは、孤立したオブジェクトは例外ではなく想定内の副産物になる——上記のステップ1とステップ2の間で起きるすべての失敗が、1つずつそれを残していく。定期的な回収ジョブは任意ではなく必須であり、次の3つの性質を備える必要がある:

まず猶予期間を設けないと、書き込み中のブロブを削除してしまう

書き込みフローにおける「ブロブのアップロード完了」から「D1 の行がコミットされる」までの、起こりうる最大のギャップより若いオブジェクトは絶対に削除しないこと——リトライ、キューイング、コールドスタートはいずれもそのギャップを引き伸ばす。アップロードから2秒後に孤立して見えるオブジェクトも、単にステップ2にまだ到達していない通常のリクエストかもしれない。ワーストケースの書き込みレイテンシより十分に長い猶予期間(秒単位ではなく時間単位で)を選び、それより新しいものはすべてスキップする。

  • 境界付きでページネーションされたスキャン。 list() は KV と同様、1回の呼び出しにつき最大1000キーが上限だ——大きなバケットをスキャンするには cursor を使って境界付きのバッチでページングする必要があり、バケット全体を一度に列挙しようとしてはならない。

  • 参照を意識した削除は、使い回されないキーに限定する。 すべてのキーが 1 オブジェクトにつき 1 度だけ発行される UUID なら(不変な UUID キー 参照)、「これを作成した行が消えた」ではなく「このキーを参照する行が0件になった」——D1 に対する COUNT(*) チェック——を基準に削除してよい。これが安全なのは、参照が0件になった UUID キーがあとから新しい参照を得ることは決してないからだ——その正確な UUID を指すものが新たに作られることは二度とない。コンテンツアドレス化されたキーではこれは安全ではない。 新しいアップロードがいつでも古いキーを参照しうるからだ——生きた COUNT(*) がなぜ並行アップロードとレースするのか、そして代わりに何を協調させるべきかは、後述の コンテンツアドレス化されたキーを安全に回収する を参照。

トランザクションなしでの R2 + D1 の整合

レコードが R2(ブロブ)と D1(メタデータの行)の両方にまたがる場合、両者をまたぐ 分散トランザクションは存在しない。削除や更新は途中で失敗しうるため、どちらの不整合を許容できるかを選ぶ必要がある。

R2 オブジェクトを先に、D1 の行を最後に削除する

削除の安全な順序は、R2 オブジェクトを最初(FIRST)に、D1 の行を最後(LAST)に削除する ことだ。

  • 孤立した R2 オブジェクト(D1 から見えなくなったがバケットには残っているブロブ)は復旧可能——バケットを一覧して突き合わせればよい。

  • 宙ぶらりんの D1 ポインタ(R2 が既に削除したブロブをまだ参照している行)は データ損失 だ——UI にはアクセスすると 404 になるレコードが表示される。

操作全体を リトライに対して冪等 にすること:既に削除済みの R2 キーを削除しても何も起きず(no-op)、存在しない行に対して D1 削除を再実行しても成功する。したがって R2 成功後に D1 が失敗した場合は HTTP 503(「一時的に利用不可、リトライしてください」)を返す——オペレーターがリトライすると、R2 の一覧は空になっており、D1 削除が完了する。このトレードオフはストレージの整然さよりもユーザーから見た正しさを優先する。

// 1. R2 first (best-effort — swallow per-object failures, surface a count)
const r2 = await deletePhotoR2Objects(slug, env);

// 2. D1 row last. On D1 failure, return 503 so the caller can safely retry.
try {
  await deletePhotoRow(slug, env.DB);
} catch (err) {
  return Response.json(
    { success: false, error: "Photo store temporarily unavailable, please retry" },
    { status: 503 },
  );
}

return Response.json({ success: true, r2 }, { status: 200 });

キーの安全性

クライアントの影響を受ける入力から組み立てられる R2 キーには、ストレージに到達する他の信頼できない文字列と同じ検証の規律が必要だ。

文字種と長さ

キーのうちクライアントの影響を受ける部分は、R2 キーの一部になる前に安全な許可リスト([a-zA-Z0-9_-].. 禁止、先頭の / 禁止、制御文字禁止)に制約する。長さは文字数ではなく UTF-8 のバイト数 で検証すること——R2 キーは最大1024バイトまでで、マルチバイト文字(CJK、絵文字)は1024 文字 に達するかなり前にこの上限を超えうる。キーはサーバー側で生成する(不変な UUID キー 参照)ことを優先し、クライアントが指定した名前はキーパスに信頼して使うのではなく customMetadata として保存する。

コンテンツアドレッシングと暗号化における注意点

オブジェクトをコンテンツのハッシュ(sha256:${hash})でキー付けすると、重複排除がただで手に入る:同じコンテンツは常に同じキーになるため、同じファイルを2回アップロードしてもオブジェクトは1つで済み、それを参照する D1 行が2つあるだけになる。

注意点は暗号化だ。コンテンツが R2 に届く前に暗号化される場合は、暗号文ではなく 平文 をハッシュすること——ランダムな IV / nonce により、同じ平文でも暗号文は異なるものになるため、暗号文をハッシュすると重複排除は完全に無効になる。平文をハッシュすればそれは復元されるが、その一方でハッシュ自体が、どちらも復号できない者に対してさえ、2つのアップロードが同一のコンテンツを共有していることを明かしてしまう。暗号化下でコンテンツアドレッシングを選ぶ前に、そのリークがそのデータにとって許容できるかどうかを判断すること——許容できないなら、重複排除なしのストレージコストを受け入れる。

コンテンツアドレス化されたキーを安全に回収する

コンテンツアドレス化されたキーは、いつ何時でも新しい D1 行から参照されうる——回収者の COUNT(*) チェックが参照0件を見つけた、その直後の瞬間も含めて。回収者がそのスナップショットを信頼してそのあとオブジェクトを削除すると、その間に参照を挿入した並行の create は何も指さないものを参照することになる。孤立したブロブの回収 の参照を意識した削除が成り立つのは、決して使い回されないキーに限られる。使い回されるキーでは、参照数と削除の判断を、あとで別々に読んで行動するのではなく、D1 自身を通じて協調させる必要がある。

コンテンツアドレス化された各キーの参照数とトゥームストーンのタイムスタンプを、専用の行として追跡する:

CREATE TABLE blob_refs (
  r2_key         TEXT PRIMARY KEY,
  ref_count      INTEGER NOT NULL DEFAULT 0,
  tombstoned_at  INTEGER  -- set when ref_count drops to 0; cleared by the next reference
);

コンテンツアドレス化されたキーを参照するすべての書き込みは、同じステートメントの中で参照数をインクリメントし、既存のトゥームストーンをクリアする。そうすればトゥームストーンが設定されたあとに届いた参照が、キーのトゥームストーンをアトミックに解除する:

await env.DB.prepare(
  `INSERT INTO blob_refs (r2_key, ref_count, tombstoned_at)
   VALUES (?, 1, NULL)
   ON CONFLICT (r2_key) DO UPDATE SET
     ref_count = blob_refs.ref_count + 1,
     tombstoned_at = NULL`,
).bind(r2Key).run();

参照している D1 行の削除は参照数をデクリメントし、0 に達したときだけトゥームストーンを設定する:

await env.DB.prepare(
  `UPDATE blob_refs
   SET ref_count = ref_count - 1,
       tombstoned_at = CASE WHEN ref_count - 1 <= 0 THEN ? ELSE tombstoned_at END
   WHERE r2_key = ?`,
).bind(Date.now(), r2Key).run();

回収者は、猶予期間を過ぎたトゥームストーンを、R2 に触れる前にアトミックに claim する——このサイト全体で使われているのと同じ claim-before-mutate の形だ:

const claim = await env.DB.prepare(
  `UPDATE blob_refs
   SET tombstoned_at = NULL
   WHERE r2_key = ? AND ref_count <= 0 AND tombstoned_at < ?
   RETURNING r2_key`,
)
  .bind(r2Key, Date.now() - GRACE_PERIOD_MS)
  .first();

if (claim) {
  // Won the claim: no reference has arrived since the tombstone was set,
  // and none can silently reappear without going through the INSERT
  // above, which would need this same row to still show ref_count > 0.
  await env.FILES.delete(r2Key);
} else {
  // Not eligible: still referenced, tombstone too fresh, or another
  // reclaimer already claimed it -- leave the object alone.
}

create 側は、それでもブロブを書き込まなければならない。重複排除のヒットでアップロードを省略してはいけない

上記の claim は、参照のチェックと削除の判断のあいだのギャップを閉じるが、物理的な env.FILES.delete() と並行する creator の D1 挿入は、依然として 2 つの別々のシステムに対する 2 つの別々の操作だ——両者を互いにアトミックにするものは何もない。この残ったわずかな隙間が閉じるのは、重複排除のルックアップでキーがすでに D1 にあると分かったからといって、creator が env.FILES.put() を省略することが決してない場合だけだ。コンテンツアドレス化された put() は冪等だ——同じキーに同一のバイト列を 2 回書き込んでも、実質的には no-op になる——のだから、D1 の参照を書き込む前に常にそれを呼び出すこと(UUID キーで使ったのと同じ ブロブを先に、行を最後に の順序だ)。そうすれば、たとえ少し前に回収者がそのオブジェクトを削除していたとしても、何らかの行がそれを参照する瞬間には、オブジェクトが存在することが保証される。

これは create / delete のたびにテーブル 1 つと書き込み 1 回のコストがかかるが、その代わり回収ジョブが正当な参照を宙ぶらりんのポインタへと追い込むレースを二度と起こさなくなる。

注意点

  • 自動パブリック URL はない: S3 のパブリックバケットとは異なり、R2 はファイルを公開配信するには Worker またはカスタムドメインが必要

  • オブジェクトキー制限: キーは最大1024バイト

  • メタデータ: 独自のキーバリューペアには customMetadata、HTTP ヘッダーには httpMetadata を使用

Revision History

作成更新