0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Cloudflare R2 vs AWS S3 — データ転送費用ゼロの恩恵を確かめた

0
Posted at

はじめに

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」という使い分けが実務的な落とし所だった。


参考


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?