Cloudflare R2で画像アップロードを最安構成で実装する手順【個人開発・コスト比較付き】
結論:個人開発の画像ストレージはCloudflare R2一択
個人開発SaaSに画像アップロード機能を追加するとき、AWS S3はエグレス料金(データ転送費用)がネックで意外とコストがかさむ。一方、Cloudflare R2はエグレス料金が無料で、ストレージ料金もS3より安い。
本記事では、S3互換APIを持つR2をTypeScript(Node.js / Cloudflare Workers)から操作し、Presigned URLで安全に画像アップロードを実装する手順をコード付きで解説します。
AWSとCloudflare R2のコスト比較
まず数字で確認しましょう。2024年時点の料金体系をまとめました(為替は1USD=150円換算)。
| 項目 | AWS S3 (東京) | Cloudflare R2 |
|---|---|---|
| ストレージ単価 | $0.025/GB/月 | $0.015/GB/月 |
| 書き込みリクエスト | $0.0053/1,000件 | $4.50/1,000,000件 |
| 読み出しリクエスト | $0.00042/1,000件 | $0.36/1,000,000件 |
| エグレス(外部転送) | $0.114/GB | 無料 |
| 無料枠(ストレージ) | 5GB(12ヶ月のみ) | 10GB(永続) |
| 無料枠(書き込み) | — | 100万件/月 |
| 無料枠(読み出し) | — | 1,000万件/月 |
具体的なシミュレーション
月間画像50GB保存・合計200GBのダウンロード・書き込み100万件が発生するとします。
- S3: ストレージ$1.25 + エグレス$22.80 + リクエスト$5.30 ≒ 月$29.35(約4,400円)
- R2: ストレージ$0.60 + エグレス$0 + リクエスト無料枠内 ≒ 月$0.60(約90円)
エグレスが無料なだけで月4,000円以上の差が生まれます。個人開発では致命的な差です。
前提と構成
本記事では以下の構成を想定します。
- バックエンド: Node.js(またはCloudflare Workers)+ TypeScript
- フロントエンド: Next.js / React(任意)
- 実装方式: Presigned URL(クライアントが直接R2にアップロード)
Presigned URLを使うと、サーバーを画像データが通過しないため、サーバーの転送コストとCPU負荷を両方ゼロにできます。
クライアント → サーバー(Presigned URL発行)
↓
クライアント → R2(直接PUT)
セットアップ手順
1. R2バケットの作成
Cloudflareダッシュボードで R2 → バケットを作成 をクリックし、バケット名(例: my-app-images)を入力して作成します。
カスタムドメインの設定(推奨):バケット → 設定 → カスタムドメインから自分のドメインをR2に向けると、CDNとして機能します。
2. APIトークンの発行
R2 → APIトークン管理 から以下の権限で発行します。
- 権限: Object Read & Write
- バケット指定: 作成したバケット名
Account ID・Access Key ID・Secret Access Key をメモしてください。
3. 依存パッケージのインストール
R2はS3互換なので、AWS公式SDKがそのまま使えます。
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
4. R2クライアントの初期化
// lib/r2.ts
import { S3Client } from "@aws-sdk/client-s3";
const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID!;
export const r2 = new S3Client({
region: "auto",
endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
export const BUCKET_NAME = process.env.R2_BUCKET_NAME ?? "my-app-images";
環境変数(.env.local):
CLOUDFLARE_ACCOUNT_ID=xxxxxxxxxxxx
R2_ACCESS_KEY_ID=yyyyyyyyyyyy
R2_SECRET_ACCESS_KEY=zzzzzzzzzzzz
R2_BUCKET_NAME=my-app-images
Presigned URLの発行エンドポイント実装
サーバーサイド(Next.js Route Handler例)
// app/api/upload-url/route.ts
import { NextRequest, NextResponse } from "next/server";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { r2, BUCKET_NAME } from "@/lib/r2";
import { randomUUID } from "crypto";
export async function POST(req: NextRequest) {
const { fileName, contentType } = await req.json();
// 許可する拡張子を絞る(セキュリティ上重要)
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
if (!ALLOWED_TYPES.includes(contentType)) {
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
}
// ファイル名をランダム化してパス traversal を防ぐ
const ext = fileName.split(".").pop();
const key = `uploads/${randomUUID()}.${ext}`;
const command = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
ContentType: contentType,
// 最大10MBに制限
ContentLength: undefined, // Presigned URLではサイズ制限はポリシーで別途制御
});
const presignedUrl = await getSignedUrl(r2, command, {
expiresIn: 300, // 5分間有効
});
return NextResponse.json({
url: presignedUrl,
key,
// 公開URL(カスタムドメイン設定済みの場合)
publicUrl: `https://images.example.com/${key}`,
});
}
クライアントサイド(React)
// components/ImageUploader.tsx
async function uploadImage(file: File): Promise<string> {
// 1. Presigned URLを取得
const res = await fetch("/api/upload-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
}),
});
const { url, publicUrl } = await res.json();
// 2. 直接R2にアップロード(サーバーを通過しない)
await fetch(url, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
return publicUrl; // DBに保存するURL
}
export function ImageUploader() {
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const publicUrl = await uploadImage(file);
console.log("アップロード完了:", publicUrl);
};
return <input type="file" accept="image/*" onChange={handleChange} />;
}
セキュリティ上の注意点
Presigned URLを使う際に必ず対処すべき点です。
| リスク | 対策 |
|---|---|
| 不正なファイル形式のアップロード | サーバー側でMIMEタイプをホワイトリスト検証 |
| 巨大ファイルによるコスト爆発 | Content-Length ヘッダーチェック or バケットのライフサイクルルール |
| URLの使い回し | expiresIn を短く設定(300秒程度) |
| 認証なしアップロード | Presigned URL発行エンドポイントをログイン済みユーザーのみに制限 |
| 不要ファイルの蓄積 | R2のライフサイクルルールで一定期間後に自動削除 |
バケットをパブリックアクセス禁止にし、カスタムドメインからのみ配信する構成が最も安全です。
Cloudflare Workersで使う場合
Cloudflare Workers環境ではAWS SDKは不要で、バインディングで直接R2を操作できます。
// worker.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method === "PUT") {
const key = new URL(request.url).pathname.slice(1);
await env.MY_BUCKET.put(key, request.body, {
httpMetadata: { contentType: request.headers.get("Content-Type") ?? "" },
});
return new Response(`Uploaded: ${key}`, { status: 200 });
}
return new Response("Method Not Allowed", { status: 405 });
},
};
interface Env {
MY_BUCKET: R2Bucket;
}
wrangler.toml でバインディングを設定します:
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-app-images"
Workers経由だとSDKの初期化も不要で、さらにシンプルに書けます。
まとめ
- Cloudflare R2はエグレス無料により、画像配信が多いSaaSで圧倒的に安い
- 無料枠(10GB/月・書き込み100万件)で小規模個人開発は事実上0円運用
- AWS S3 SDKがそのまま使えるため移行コストが低い
- Presigned URLでサーバーレスかつ安全なアップロードが実現できる
- Cloudflare Workersと組み合わせるとバインディングでさらにシンプルに実装可能
インフラコストをとことん下げた個人開発をするなら、R2は最初に選ぶべきストレージです。
クラウドコスト削減や設計をさらに深掘りしたい方は、以下の書籍も参考になります。