はじめに
S3をメインのファイルストレージとして使っていたが、Cloudflare R2という選択肢を調べた。
「データ転送費用がゼロ」という言葉が気になっていた。S3はダウンロード時に転送費用が発生するので、ファイルの配信が多いサービスではコストが積み上がる。R2を実際に使ってどこが違うのか確かめた。
R2とS3の基本比較
AWS S3:
AWSのオブジェクトストレージ
エグレス(転送)費用: $0.09/GB(リージョン外)
豊富なエコシステム
多機能
Cloudflare R2:
CloudflareのオブジェクトストレージS3互換
エグレス費用: 無料(Cloudflareネットワーク内)
S3互換API
Workers・Pagesと統合
料金の詳細比較
AWS S3(東京リージョン)
ストレージ: $0.025/GB/月
書き込み: $0.0047/1,000リクエスト(PUT)
読み込み: $0.00037/1,000リクエスト(GET)
エグレス: $0.114/GB(インターネットへの転送)
CloudFront経由: $0.085/GB(少し安い)
Cloudflare R2
ストレージ: $0.015/GB/月(S3より40%安い)
書き込み: $4.50/100万リクエスト(PUT)
読み込み: $0.36/100万リクエスト(GET)
エグレス: 無料(R2からの転送は課金なし)
無料枠:
ストレージ: 10GB/月
書き込み: 100万リクエスト/月
読み込み: 1,000万リクエスト/月
コスト試算(100GBストレージ、月1TB転送の場合):
S3:
ストレージ: $2.5
転送: $0.114 × 1,024 = $116.7
合計: 約$119/月
R2:
ストレージ: $1.5
転送: 無料
合計: 約$1.5/月
→ 転送量が多いほどR2が圧倒的に安くなる
R2バケットの作成と設定
# Wrangler CLIでR2バケットを作成
wrangler r2 bucket create my-app-files
# バケット一覧
wrangler r2 bucket list
# バケット削除
wrangler r2 bucket delete my-app-bucket
# wrangler.toml(Workers/Pagesで使う場合)
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-app-files"
S3互換APIでPythonから使う
R2はS3互換APIを提供しているので、boto3がほぼそのまま使える。
import boto3
import os
# R2の接続設定
r2_client = boto3.client(
"s3",
endpoint_url = f"https://{os.environ['CF_ACCOUNT_ID']}.r2.cloudflarestorage.com",
aws_access_key_id = os.environ["R2_ACCESS_KEY_ID"],
aws_secret_access_key = os.environ["R2_SECRET_ACCESS_KEY"],
region_name = "auto", # R2はリージョンを"auto"にする
)
# .env
CF_ACCOUNT_ID=your-cloudflare-account-id
R2_ACCESS_KEY_ID=your-r2-access-key
R2_SECRET_ACCESS_KEY=your-r2-secret-key
R2_BUCKET_NAME=my-app-files
R2のアクセスキーはCloudflareダッシュボードの「R2 → API」から生成する。
基本的な操作
import boto3
import os
import io
import pandas as pd
from botocore.exceptions import ClientError
# クライアントをラップしたクラス
class R2Client:
def __init__(self):
self.client = boto3.client(
"s3",
endpoint_url = f"https://{os.environ['CF_ACCOUNT_ID']}.r2.cloudflarestorage.com",
aws_access_key_id = os.environ["R2_ACCESS_KEY_ID"],
aws_secret_access_key = os.environ["R2_SECRET_ACCESS_KEY"],
region_name = "auto",
)
self.bucket = os.environ["R2_BUCKET_NAME"]
def upload_file(self, local_path: str, key: str) -> None:
self.client.upload_file(local_path, self.bucket, key)
print(f"アップロード完了: {key}")
def download_file(self, key: str, local_path: str) -> None:
self.client.download_file(self.bucket, key, local_path)
def put_object(
self,
key: str,
body: bytes,
content_type: str = "application/octet-stream",
) -> None:
self.client.put_object(
Bucket = self.bucket,
Key = key,
Body = body,
ContentType = content_type,
)
def get_object(self, key: str) -> bytes:
response = self.client.get_object(Bucket=self.bucket, Key=key)
return response["Body"].read()
def delete_object(self, key: str) -> None:
self.client.delete_object(Bucket=self.bucket, Key=key)
def list_objects(self, prefix: str = "") -> list[dict]:
paginator = self.client.get_paginator("list_objects_v2")
pages = paginator.paginate(Bucket=self.bucket, Prefix=prefix)
files = []
for page in pages:
if "Contents" in page:
files.extend(page["Contents"])
return files
def exists(self, key: str) -> bool:
try:
self.client.head_object(Bucket=self.bucket, Key=key)
return True
except ClientError:
return False
def presigned_url(self, key: str, expires: int = 3600) -> str:
return self.client.generate_presigned_url(
"get_object",
Params = {"Bucket": self.bucket, "Key": key},
ExpiresIn = expires,
)
def read_df(self, key: str) -> pd.DataFrame:
data = self.get_object(key)
if key.endswith(".parquet"):
return pd.read_parquet(io.BytesIO(data))
return pd.read_csv(io.BytesIO(data))
def write_df(
self,
df: pd.DataFrame,
key: str,
) -> None:
buffer = io.BytesIO()
if key.endswith(".parquet"):
df.to_parquet(buffer, index=False)
content_type = "application/octet-stream"
else:
buffer = io.StringIO()
df.to_csv(buffer, index=False)
body = buffer.getvalue().encode("utf-8")
self.put_object(key, body, "text/csv")
return
buffer.seek(0)
self.put_object(key, buffer.getvalue(), content_type)
S3との互換性確認
# S3のクライアントとR2のクライアントを切り替え可能にする
class StorageClient:
"""S3とR2を切り替え可能なストレージクライアント"""
def __init__(self, backend: str = "s3"):
if backend == "r2":
self.client = boto3.client(
"s3",
endpoint_url = f"https://{os.environ['CF_ACCOUNT_ID']}.r2.cloudflarestorage.com",
aws_access_key_id = os.environ["R2_ACCESS_KEY_ID"],
aws_secret_access_key = os.environ["R2_SECRET_ACCESS_KEY"],
region_name = "auto",
)
self.bucket = os.environ["R2_BUCKET_NAME"]
else:
self.client = boto3.client("s3", region_name="ap-northeast-1")
self.bucket = os.environ["S3_BUCKET_NAME"]
# 環境変数で切り替え
STORAGE_BACKEND = os.environ.get("STORAGE_BACKEND", "s3")
storage = StorageClient(backend=STORAGE_BACKEND)
R2の公開バケット設定
R2バケットをパブリックに設定するとCloudflare CDN経由で配信される。
→ エッジキャッシュが効く
→ 転送費用はゼロ
→ https://pub-xxxx.r2.dev/key形式でアクセス可能
# 公開バケットのURLを生成
def public_url(key: str) -> str:
account_id = os.environ["CF_ACCOUNT_ID"]
bucket = os.environ["R2_BUCKET_NAME"]
# カスタムドメインがある場合
custom_domain = os.environ.get("R2_CUSTOM_DOMAIN")
if custom_domain:
return f"https://{custom_domain}/{key}"
# デフォルトのR2.devドメイン
return f"https://pub-{account_id}.r2.dev/{key}"
# wrangler.tomlでカスタムドメインを設定
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-app-files"
# カスタムドメインをバケットに設定(Cloudflareダッシュボードで設定)
# files.example.com → my-app-files
WorkersからR2を使う
// Cloudflare WorkersからR2を操作する
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const pathname = url.pathname.slice(1); // 先頭の/を除去
if (request.method === "GET") {
// R2からファイルを取得
const object = await env.MY_BUCKET.get(pathname);
if (!object) {
return new Response("Not Found", { status: 404 });
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("ETag", object.etag);
headers.set("Cache-Control", "public, max-age=31536000");
return new Response(object.body, { headers });
}
if (request.method === "PUT") {
// R2にファイルをアップロード
await env.MY_BUCKET.put(pathname, request.body, {
httpMetadata: {
contentType: request.headers.get("Content-Type") ?? "application/octet-stream",
},
});
return Response.json({ uploaded: pathname });
}
if (request.method === "DELETE") {
await env.MY_BUCKET.delete(pathname);
return Response.json({ deleted: pathname });
}
return new Response("Method Not Allowed", { status: 405 });
},
};
S3とR2の使い分け
R2を選ぶとき:
✓ ファイル配信量が多い(動画・画像・ドキュメント)
✓ Cloudflare CDN経由での配信が目的
✓ Workers/Pagesとの統合が必要
✓ コスト削減が優先
S3を選ぶとき:
✓ AWSエコシステムと統合している(ECS、Lambda等)
✓ S3固有の機能が必要(S3 Select、Glacier等)
✓ Snowflakeとの直接連携が必要
✓ 既存のAWSインフラと統合
両方使う構成:
S3 — データパイプライン用(Snowflakeとの連携)
R2 — ユーザー向けファイル配信用(画像・ドキュメント)
実務での移行パターン
S3からR2に一部移行した場合の設計。
# config.py
import os
# ファイルの種類によって保存先を分ける
STORAGE_CONFIG = {
# ユーザーアップロードファイル → R2(配信コスト削減)
"user_uploads": {
"backend": "r2",
"bucket": os.environ.get("R2_BUCKET_NAME", "user-uploads"),
},
# データパイプライン用 → S3(Snowflake連携)
"pipeline_data": {
"backend": "s3",
"bucket": os.environ.get("S3_BUCKET_NAME", "pipeline-data"),
},
# レポートファイル → R2(ダウンロード配信)
"reports": {
"backend": "r2",
"bucket": os.environ.get("R2_BUCKET_NAME", "reports"),
},
}
def get_storage_client(purpose: str) -> StorageClient:
config = STORAGE_CONFIG.get(purpose, STORAGE_CONFIG["pipeline_data"])
return StorageClient(backend=config["backend"])
制約と注意点
R2の制約:
✗ S3のイベント通知(Lambda連携)がない
→ WorkersのQueue/Event Bridgeで代替
✗ S3 Selectがない
→ WorkersでETL処理を実装
✗ Glacierのような低コストアーカイブストレージがない
✗ Snowflakeの外部ステージとして直接使えない(S3が必要)
△ boto3の一部機能が動かない場合がある
S3互換として動かない主な操作:
- バケットACL(R2はACLをサポートしない)
- オブジェクトACL
- バケットポリシー(R2独自のアクセス制御)
# ACLを使っているS3コードをR2に移行するとエラーになる例
s3.put_object(
Bucket = bucket,
Key = key,
Body = data,
ACL = "public-read", # ← R2ではエラー
)
# R2ではACLは使わずバケット単位で公開設定する
s3.put_object(
Bucket = bucket,
Key = key,
Body = data,
# ACLパラメータを削除
)
まとめ
| 項目 | AWS S3 | Cloudflare R2 |
|---|---|---|
| エグレス費用 | $0.09〜$0.114/GB | 無料 |
| ストレージ費用 | $0.025/GB/月 | $0.015/GB/月 |
| S3互換 | ネイティブ | ほぼ互換(一部制約) |
| Workers連携 | 別途設定 | シームレス |
| Snowflake連携 | ◎ | ✗ |
| イベント通知 | Lambda連携可 | Workersで代替 |
| 向いている用途 | AWSエコシステム・データパイプライン | ファイル配信・Workers連携 |
転送量が多いファイル配信にR2を使うと、S3と比べて大幅なコスト削減になる。ただしSnowflakeの外部ステージや細かいS3固有の機能が必要な場合はS3が必要。「配信はR2、データパイプラインはS3」という使い分けが実務的な落とし所だった。