​【Cloudflare R2入門】D1やAmazon S3との違いからHono+Workersでの実践コードまで徹底解説

cloudflare-r2 インフラ・サーバー

Webアプリケーションで画像や動画などのファイルを扱う際、候補として急速に存在感を増しているのがCloudflare R2です。

本記事では、Cloudflare R2の概要から、よく比較されるD1やAmazon S3との違い、料金体系、そして軽量Webフレームワーク「Hono」を使った具体的な開発例までを分かりやすく解説します。

1. Cloudflare R2とは?

Cloudflare R2は、Cloudflareが提供するS3互換のオブジェクトストレージです。

簡単にいうとGoogle Driveや MicrosoftOneDriveのようなオンラインストレージです。
ただ、WEBアプリなどのプログラムからのみアクセスできるというのが特徴です。

画像、動画、PDF、システムバックアップといった非構造化データ(ファイル)を保存するために使用します。最大の特徴は、一般的なクラウドサービスで発生するデータ転送手数料(エグレス料金)が完全無料($0)である点です。

2. Cloudflare D1 との違い

同じCloudflareのストレージ機能である「D1」との違いに迷う方も多いかもしれません。役割は明確に分かれています。

項目Cloudflare R2Cloudflare D1
データの種類非構造化データ(ファイル本体)構造化データ(テーブル・行)
仕組みS3互換 オブジェクトストレージSQLiteベース 分散リレーショナルDB
操作方法パス(Key)によるファイルの出力・取得SQLクエリ(SELECT, INSERT等)
主な用途画像、動画、PDF、ログファイルの保管ユーザー情報、投稿文、設定データの管理

基本の組み合わせ:

実務では「画像本体はR2に保存し、そのURLやファイル名・投稿者IDなどのメタデータをD1(SQL)に記録する」という連携パターンが一般的です。

自分はR2だけで全て済むのかと最初思いました。しかしR2はSQLではないため「○月○日以降に作成された画像だけを検索して取り出す」といった複雑な処理(SQLでやるようなこと)ができません。
そのため、「ファイルはR2に保存し、ファイル名や検索用の情報をD1にSQLで記録しておく」という組み合わせでよく使います。

3. 他のクラウドストレージ(Amazon S3など)との比較

オブジェクトストレージの代表格である「Amazon S3」や「Supabase Storage」と比較してみます。

項目Cloudflare R2Amazon S3Supabase Storage
データ転送量 (Egress)完全無料 ($0)有料(従量課金)無料枠超過後は有料
無料ストレージ容量10 GB / 月5 GB / 月 (12ヶ月限定)1 GB / 月
S3互換性あり本家なし(独自API/SDK)
エッジ連携Cloudflare Workersと超高速連携AWS Lambda等と連携Supabaseエコシステム

Webサイトで頻繁に画像や動画を配信する場合、S3では転送量に応じた課金が膨らむ可能性がありますが、R2であれば通信コストを極限まで抑えることができます。

4. R2の料金体系と無料枠

ここが一番自分が利用している理由になります!
なんといっても無料枠がとても大きいです!

R2の課金要素は「保存量」と「操作回数」の2つのみで、転送通信料はかかりません。

無料枠(毎月更新)

個人開発や小規模なアプリであれば、無料枠内で収まるケースがほとんどです。

  • ストレージ容量: 10 GB / 月
  • Class A 操作 (書き込み/削除/リスト取得): 100万回 / 月
  • Class B 操作 (読み込み/ダウンロード): 1,000万回 / 月
  • データ転送量 (Egress): 無制限・無料

無料枠を超えた場合の従量課金

  • 保管料: $0.015 / GB / 月
  • Class A 操作: $4.50 / 100万回
  • Class B 操作: $0.36 / 100万回

これだけの無料枠があれば、個人利用や中小企業の業務アプリならほぼ無料で収まると思います。

5. Cloudflare Workers + Hono + D1 + R2 の構成でプロジェクトをセットアップする手順

ここからは、自分がいつも実務でよく使う軽量フレームワーク HonoD1+R2 を組み合わせたプロジェクトの立ち上げ手順を解説します。

1つの npm create コマンドだけで全リソースを自動生成することはできないため、① Honoプロジェクトの生成② D1 / R2 バケットの作成③ 接続設定(wrangler.jsonc / wrangler.toml) の順でコマンドを実行します。

1. Honoプロジェクトの生成

npm create hono@latest my-d1+r2-app
✔ Which template do you want to use? cloudflare-workers
✔ Do you want to install project dependencies? Yes
✔ Which package manager do you want to use? npm

cd my-d1+r2-app

2. Cloudflare リソース(D1 / R2)の作成

CLIツールの wrangler を使って D1 データベースと R2 バケットを作成します。

① D1 データベースの作成

CLIツールの wrangler を使って D1 データベースを作成します。

npx wrangler d1 create my-db-d1

√ Would you like Wrangler to add it on your behalf? ... yes
√ What binding name would you like to use? ... my_db_d1
√ For local dev, do you want to connect to the remote resource
  instead of a local resource? ... no

database_id :自動的に wrangler.jsonc に書き込まれるはずです。

② R2 バケットの作成

Wrangler CLIを使って、Cloudflare上にバケットと呼ばれるデータの器を作成します。

npx wrangler r2 bucket create my-bucket

√ Would you like Wrangler to add it on your behalf? ... yes
√ What binding name would you like to use? ... my_bucket_r2
√ For local dev, do you want to connect to the remote resource 
 instead of a local resource? ... no

3. 設定ファイル(wrangler.jsonc または wrangler.toml)の編集

作成した D1 と R2 を Workers から参照できるよう、プロジェクトルートにある設定ファイル(最近のテンプレートでは wrangler.jsonc)にバインディング情報を追記します。

先ほどの npx wrangler コマンドの時、Would you like Wrangler to add it on your behalf? で yes を選択していればここの設定ファイルは自動で書き込まれると思います。
自分はいつも書いていないですが確認だけします。

wrangler.jsonc の場合:

{
  "name": "my-app",
  "main": "src/index.ts",
  "compatibility_date": "2026-08-04",
  // D1のバインディング設定
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "my-db",
      "database_id": "YOUR_DATABASE_ID_HERE" // 先ほど作成したdatabase_idを貼り付け
    }
  ],
  // R2のバインディング設定
  "r2_buckets": [
    {
      "binding": "BUCKET",
      "bucket_name": "my-bucket"
    }
  ]
}

4. データベースのスキーマ作成

データベースのスキーマ shema.sql を作成します。

CREATE TABLE IF NOT EXISTS files (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    r2_id TEXT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

データベースにスキーマを適用させます。

npx wrangler d1 execute my-db --local --file=./schema.sql

5. TypeScript型定義の作成と src/index.ts の記述

TypeScript環境で補完を効かせるため、型定義を指定して Hono アプリを記述します。
今回は簡単なファイル管理のCRUD操作をできるコードを以下に用意しました。

import { Hono } from 'hono'

type Bindings = {
  my_db_d1: D1Database
  my_bucket_r2: R2Bucket
}

const app = new Hono<{ Bindings: Bindings }>()

// 1. 作成 (CREATE): 画像などのバイナリとタイトルを送信
app.post('/api/files', async (c) => {
  const formData = await c.req.formData()
  const file = formData.get('file') as File | null
  const title = formData.get('title') as string | null

  if (!file || !title) {
    return c.json({ error: 'ファイルとタイトルは必須です' }, 400)
  }

  const id = crypto.randomUUID()
  const r2Id = `${id}-${file.name}`

  // R2にバイナリ保存
  await c.env.my_bucket_r2.put(r2Id, await file.arrayBuffer(), {
    httpMetadata: { contentType: file.type },
  })

  // D1にメタデータ保存
  await c.env.my_db_d1.prepare(
    'INSERT INTO files (id, title, r2_id) VALUES (?, ?, ?)'
  ).bind(id, title, r2Id).run()

  return c.json({ message: '作成完了', id, r2_id: r2Id }, 201)
})

// 2. 読み込み (READ): メタデータ取得
app.get('/api/files/:id', async (c) => {
  const id = c.req.param('id')
  const record = await c.env.my_db_d1.prepare('SELECT * FROM files WHERE id = ?').bind(id).first()

  if (!record) {
    return c.json({ error: 'データが見つかりません' }, 404)
  }

  return c.json(record)
})

// 2-1. バイナリファイルの取得
app.get('/api/files/:id/content', async (c) => {
  const id = c.req.param('id')
  const record = await c.env.my_db_d1.prepare('SELECT r2_id FROM files WHERE id = ?').bind(id).first<{ r2_id: string }>()

  if (!record) {
    return c.json({ error: 'データが見つかりません' }, 404)
  }

  const object = await c.env.my_bucket_r2.get(record.r2_id)
  if (!object) {
    return c.json({ error: 'R2ファイルが見つかりません' }, 404)
  }

  const headers = new Headers()
  object.writeHttpMetadata(headers)
  headers.set('etag', object.httpEtag)

  return new Response(object.body, { headers })
})

// 3. 更新 (UPDATE): タイトル変更および/またはファイルの差し替え
app.put('/api/files/:id', async (c) => {
  const id = c.req.param('id')
  const formData = await c.req.formData()
  const newFile = formData.get('file') as File | null
  const newTitle = formData.get('title') as string | null

  const record = await c.env.my_db_d1.prepare('SELECT * FROM files WHERE id = ?').bind(id).first<{ r2_id: string, title: string }>()
  if (!record) {
    return c.json({ error: 'データが見つかりません' }, 404)
  }

  let r2Id = record.r2_id
  let title = newTitle || record.title

  // 新しいファイルがあれば旧ファイルを消して新ファイルを保存
  if (newFile && newFile.size > 0) {
    await c.env.my_bucket_r2.delete(record.r2_id)
    r2Id = `${id}-${newFile.name}`
    await c.env.my_bucket_r2.put(r2Id, await newFile.arrayBuffer(), {
      httpMetadata: { contentType: newFile.type },
    })
  }

  // D1のメタデータ更新
  await c.env.my_db_d1.prepare(
    'UPDATE files SET title = ?, r2_id = ? WHERE id = ?'
  ).bind(title, r2Id, id).run()

  return c.json({ message: '更新完了', id, title, r2_id: r2Id })
})

// 4. 消去 (DELETE): D1とR2の両方から削除
app.delete('/api/files/:id', async (c) => {
  const id = c.req.param('id')
  const record = await c.env.my_db_d1.prepare('SELECT r2_id FROM files WHERE id = ?').bind(id).first<{ r2_id: string }>()

  if (!record) {
    return c.json({ error: 'データが見つかりません' }, 404)
  }

  // R2からバイナリ削除
  await c.env.my_bucket_r2.delete(record.r2_id)

  // D1からレコード削除
  await c.env.my_db_d1.prepare('DELETE FROM files WHERE id = ?').bind(id).run()

  return c.json({ message: '削除完了', id })
})

export default app

※TypeScriptの型定義不足エラーが出た場合

自分がハマったポイントとして、VScode等のエディターで 名前 ‘File’ が見つかりません。 などのエラーが多数出た場合があります。
これはTypeScriptの型定義(tsconfig.json)に Cloudflare Workers や Web 標準の型が含まれていないことが原因です。

解決手順

1. パッケージのインストール ターミナルで型定義パッケージをインストールします。

npm install -D @cloudflare/workers-types

2. tsconfig.json の編集 tsconfig.json の compilerOptions 内にある types に @cloudflare/workers-types を追加します(DOM などの Web 標準型を含める場合は lib も設定します)。

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "skipLibCheck": true,
    "lib": [
      "ESNext",
      "DOM"
    ],
    "types": [
      "@cloudflare/workers-types"
    ],
    "jsx": "react-jsx",
    "jsxImportSource": "hono/jsx"
  }
}

tsconfig.json とは?(このファイルの意味)

tsconfig.json は TypeScriptコンパイラ(tsc)の動作設定ファイル です。

「どのJavaScriptのバージョンに向けて変換するか」「どれくらい厳密に型をチェックするか」「どんなグローバル型(D1Database や File など)を認識させるか」という 開発環境のルールブック の役割を果たします。

※ エディタ(VS Codeなど)で赤波線が消えない場合は、エディタを再起動するか、コマンドパレットから 「TypeScript: TS サーバーを再起動」 を実行してください。

6. 開発サーバーの起動

npm run dev

ローカル開発環境(Wrangler)では、D1 や R2 のローカルエミュレータが自動的に起動するため、クラウドに影響を与えることなくテストが可能です。

  • アップロードのテスト
# ファイルのパスは適宜変更してください
curl.exe -X POST http://localhost:8787/api/files `
  -F "title=テスト画像" `
  -F "file=@C:\Users\user\Pictures\img_1.png"

# 以下のようなメッセージが表示されれば完了
{"message":"作成完了","id":"73eccc43-149c-4fee-9975-1cd7f065a2d9",
"r2_id":"73eccc43-149c-4fee-9975-1cd7f065a2d9-img_1.png"} 
  • メタデータ取得 (READ)
# 取得するファイルのidを入力してください
curl.exe http://localhost:8787/api/files/ここにid
  • 画像ファイルダウンロード (READ Content)
# ダウンロードするファイルのidを入力してください
curl.exe http://localhost:8787/api/files/ここにid/content --output downloaded_img.png
  • 更新 (UPDATE)
# タイトルとファイルを同時に更新する場合
curl.exe -X PUT http://localhost:8787/api/files/ここにid `
  -F "title=更新後のタイトル" `
  -F "file=@C:\Users\user\Pictures\img_2.png"
  • 消去 (DELETE)
curl.exe -X DELETE http://localhost:8787/api/files/ここにid

まとめ

Cloudflare R2は、転送量無料・10GBの太っ腹な無料枠・S3互換という強力な特徴を備えたストレージサービスです。

HonoやWorkersと組み合わせることで、爆速かつ低コストなWeb APIやメディア配信サーバーを数分で構築できます。個人開発から商用サービスまで、ストレージの第一選択肢としてぜひ試してみてください!

コメント