effidevFlutter・Cloudflareエッジ・クラウドコスト最適化

Cloudflare R2 vs S3 vs Firebase Storage: 月1TBダウンロードで$0 vs $90 vs $108、エグレス費用でストレージを選ぶ

Cloudflare R2 vs S3 vs Firebase Storage: 月1TBダウンロードで$0 vs $90 vs $108、エグレス費用でストレージを選ぶ

Flutterアプリに画像アップロード機能を組み込むとき、多くの人はこう検索する。「S3 vs Firebase Storage 料金比較」「R2 GBあたりの保存料金」。そしてGBあたりの保存料金がどれも似たり寄ったりなので、「使い慣れたものでいいか」と結論づける。だがサービスが成長し、ユーザーがプロフィール画像やフィード画像を毎日目にするようになると、請求書の中で最も大きな行はストレージ費用ではなくダウンロード(エグレス)費用になる。この記事はその数字を実際の単価で証明する。

要点: 保存料金はGBあたり数セント程度で、3つのサービスに大差はない。しかしエグレス(ダウンロード)費用は桁が違う。 Cloudflare R2はエグレスが構造的に$0、AWS S3はGBあたり$0.09、Firebase Storageは月100GB無料枠を超えるとGBあたり約$0.12を請求する。月間ダウンロードトラフィックがわずか1TBに達しただけで、R2は$0、S3は約$90、Firebase Storageは約$108になる。画像・動画を扱うアプリほど、この差はトラフィックに比例して線形に、そして心理的には指数関数的に広がっていく。

なぜ保存料金ではなくエグレスが問題なのか

Flutterアプリの画像・動画ストレージで発生するトラフィックパターンを考えれば答えが見えてくる。画像は**一度アップロードされ、数百〜数千回閲覧される。**プロフィール画像は1枚がそのユーザーのフォロワー数だけロードされ、フィードに投稿された1枚の写真はスクロールするたびに、アプリを再起動するたびに再取得される。つまり書き込み(Put/Upload)は一過性だが、読み取り(Get/Download)がトラフィックの大部分を占める。保存料金は「何GBをどれだけの期間保持しているか」に対する料金でストレージ総量に対して線形に加算されるが、エグレス料金は「何GBがどれだけの頻度で出ていくか」に加算されるため、実際の利用トラフィックに応じてはるかに速く膨らんでいく。

3社の料金体系を一覧で比較

まず各サービスの公式料金ページ基準(2026年7月時点確認)で、保存料金とエグレス料金を整理する。

区分 Cloudflare R2 AWS S3(Standard、us-east-1) Firebase Storage(Blaze)
保存料金 $0.015/GB-month $0.023/GB-month(最初の50TB) レガシーバケット基準で5GB超過分に$0.026/GB-month、新規バケットはリージョンごとに異なる
書き込み/変更リクエスト Class A: $4.50/100万件 PUT等: $0.005/1,000件 Firestoreとは別枠、Storage自体のリクエスト課金は相対的に小さい
読み取りリクエスト Class B: $0.36/100万件 GET等: $0.0004/1,000件 上記と同様
ダウンロード(エグレス) $0(全て無料) $0.09/GB(最初の10TB/月) 月100GB超過分にGBあたり約$0.12

R2とS3のリクエスト(オペレーション)単価は一見R2の方が高く見えるが、画像ビューアーアプリのように読み取りが圧倒的に多いワークロードでは、エグレスの1行が他の項目をすべて覆してしまう。以下で実際の数字で確認する。

無料枠を整理する: 本当に無料なのか

3社とも「無料枠」を謳っているが、その性質はまったく異なる。

この違いが、後述のシミュレーション表でS3とFirebase Storageの低コスト帯の数字が食い違って見える理由でもある。

実際の請求額シミュレーション: 月間ダウンロード100GB/1TB/10TB

以下の表はエグレストラフィックのみを対象に算出した月間請求額だ。計算条件は次のように明示的に設定した。

月間ダウンロードトラフィック Cloudflare R2 AWS S3 Firebase Storage
100GB $0 100GB × $0.09 = $9 無料枠内 → $0
1TB(1,000GB) $0 1,000GB × $0.09 = $90 (1,000-100)GB × $0.12 = $108
10TB(10,000GB) $0 10,000GB × $0.09 = $900 (10,000-100)GB × $0.12 = $1,188

数字をそのまま読むと、**月間1TBダウンロードでR2に対してS3は90倍、Firebase Storageは108倍高くつく。**そしてこの差はトラフィックが増えるほど絶対額としてさらに広がる。10TB帯ではR2との差がS3基準で年間$10,800、Firebase Storage基準で年間$14,256に達する。

なお、S3は10TBを超えると$0.085/GB(10〜50TB)、$0.070/GB(50〜150TB)と単価が緩やかに下がる段階(tiered)料金になっている。それでもR2の$0と比較すれば、依然として無意味なレベルの「割引」でしかない。

保存費用とリクエスト費用は相対的に小さい

同じアプリが画像500GBを常時保存していると仮定すると:

オペレーション費用も確認してみると、月10万件アップロード(Class A)・5,000万件閲覧(Class B)という、それなりにトラフィックのあるアプリでもR2基準ではClass Aは無料枠(100万件)以内で$0、Class Bは無料枠(1,000万件)を超えた4,000万件に$0.36/100万件を掛けて月$14.4に収まる。つまり保存費用もリクエスト費用も月数十ドル単位の話にとどまる。一方でエグレスはトラフィックが増えた瞬間に3桁、4桁のドルへと跳ね上がる。優劣を分ける変数は結局エグレス1つだけだ。

Flutterアプリでよくあるミス: オリジナル画像をそのまま配信する

Firebase Storage + Flutterの組み合わせで最も頻繁に見かけるパターンは次のようなものだ。

final ref = FirebaseStorage.instance.ref('posts/$postId/original.jpg');
final url = await ref.getDownloadURL();

// ListViewの中でそのままロード
Image.network(url);

カメラで撮影したオリジナル写真は通常3,000×4,000px、ファイルサイズ3〜8MBに達する。これをリサイズせずにフィードのサムネイル(実際の表示サイズは100×100px程度)としてそのまま配信すると、ユーザーがフィードをスクロールするたびに必要以上のバイト数がエグレスとして出ていく。ユーザー1万人が1日3回アプリを起動し、それぞれ30枚の画像をロードすると仮定すると、1日90万回のダウンロード、画像1枚あたり3MBとすれば1日約2.7TB、月間約81TBがあっという間に発生する。この数値をFirebase Storageの単価(月100GB無料枠超過後GBあたり約$0.12)にそのまま当てはめると、月1万ドル規模の請求書になる。

ここにキャッシュの問題も加わる。Firebase Storageのオブジェクトはカスタムキャッシュ設定をしない限り、デフォルトでCache-Control: public, max-age=3600が付与される。つまり1時間経過するとブラウザ/クライアントキャッシュが期限切れになり、アプリを再度開いたり、1時間後に同じ画面を再訪問したりすると、オリジナルファイルを最初からまた取得することになる。cached_network_imageのようなパッケージでクライアント側のディスクキャッシュを設定しても、初回アクセスやキャッシュ削除後にはやはりオリジナルを丸ごと取得しなければならない構造は変わらない。

実践的な改善の順序

  1. アップロード時点でリサイズする: Firebase Extensionsの「Resize Images」拡張機能を組み込むか、サーバー(Cloud Functions)側でアップロードトリガーとして複数サイズ(サムネイル200px、中間800px、オリジナル)を生成しそれぞれ保存する。一覧画面ではサムネイルのみを呼び出し、詳細画面に入ったときだけオリジナルをリクエストする。
  2. Cache-Controlを長く再設定する: 画像パスにコンテンツハッシュを含めて不変(immutable)ファイルにし、Cache-Control: public, max-age=31536000, immutableメタデータを明示的に設定する。ファイル名が変わらない限り、恒久的なキャッシュが可能になる。
  3. Flutter側で表示サイズとリクエストサイズを一致させる: Image.networkcacheWidth/cacheHeightを指定すればデコード費用は削減できるが、ネットワーク経由で受け取るバイト数そのものを減らすには、結局サーバー側が最初から小さいファイルを配信する必要がある。クライアントのデコード最適化とエグレス削減は別問題であることを混同してはいけない。
  4. 前段にCDNを置く: Firebase Hostingのrewriteや別のCDNで画像リクエストをキャッシュすれば、同じ画像への繰り返しリクエストがオリジン(Storage)まで到達しなくなり、エグレスが減る。ただしこの場合、CDN自体の帯域幅料金が別途発生し得る点は考慮しておく必要がある。

R2への移行チェックリスト

R2はS3互換APIを提供しているため、既存のS3ベースのコードベースは思ったより少ない変更で移行できる。ただし100%同一というわけではない。

1) まずサポートされていない機能を確認する。 R2のS3互換APIはPutObjectGetObjectHeadObjectDeleteObjectListObjectsV2、マルチパートアップロード(CreateMultipartUpload/UploadPart/CompleteMultipartUpload)など、主要なオブジェクトオペレーションはほぼサポートしている。一方でACL(x-amz-acl)、Object Lock/バージョニング、オブジェクト・バケットのタグ付け、バケットポリシー・通知・ロギング・レプリケーション、SSE-KMSサーバーサイド暗号化はサポートされていない。細かなIAMポリシーやS3バケットポリシーでアクセス制御を組んでいるプロジェクトであれば、この部分をCloudflare APIトークンベースの権限モデルとして再設計する必要がある。

2) データ移行にはSuper Slurperを使う。 CloudflareのSuper Slurperは、S3およびS3互換ストレージ(バックアップとしてBackblaze B2、Wasabi、MinIO、DigitalOcean Spacesなど)からR2への大量移行を無料で実行してくれる。課金されるのは移行中にR2側で発生するClass Aオペレーション費用のみだ。ただし移行過程でオブジェクトが複数パートに分割されて転送されることがあり、ETagがオリジナルと100%一致しない場合がある点はチェックリストに入れておくべきだ(ETagを整合性検証に使っているコードがあれば、別のハッシュ検証に置き換える)。

3) Presigned URLの発行方法が異なる。 R2でもAWS SDKでpresigned URLをそのまま作成できる。

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const S3 = new S3Client({
  region: "auto", // R2はリージョンの概念がないため必ず"auto"
  endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: "<ACCESS_KEY_ID>",
    secretAccessKey: "<SECRET_ACCESS_KEY>",
  },
});

const putUrl = await getSignedUrl(
  S3,
  new PutObjectCommand({
    Bucket: "my-bucket",
    Key: "posts/123/original.jpg",
    ContentType: "image/jpeg",
  }),
  { expiresIn: 3600 }, // 1秒〜7日(604,800秒)の範囲で設定可能
);

Flutterクライアントはこのバックエンドから受け取ったURLに対してそのままPUTすればよい。

final res = await http.put(
  Uri.parse(presignedUrl),
  headers: {'Content-Type': 'image/jpeg'},
  body: imageBytes,
);

注意すべき点は、presigned URLはR2のデフォルトエンドポイントであるr2.cloudflarestorage.comでのみ動作し、カスタムドメインでは機能しないということだ。アップロードはpresigned URLで、公開ダウンロードはカスタムドメインで分離する構成が一般的だ。

4) Workersバインディングでオリジン自体をなくす。 バックエンドがCloudflare Workersであれば、S3互換APIすら経由せずネイティブバインディングで直接アクセスできる。

# wrangler.toml
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-app-media"
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const key = url.pathname.slice(1);

    if (request.method === "GET") {
      const obj = await env.MY_BUCKET.get(key);
      return obj ? new Response(obj.body) : new Response("Not found", { status: 404 });
    }
    if (request.method === "PUT") {
      await env.MY_BUCKET.put(key, request.body);
      return new Response(`Put ${key} successfully!`);
    }
    return new Response("Method not allowed", { status: 405 });
  },
};

この方式なら認証・リサイズ・透かし処理のようなロジックをWorker内で処理しつつ、エグレスは依然として$0という利点を維持できる。

5) 公開ドメインを接続する。 R2バケットはデフォルトで非公開だ。本番環境では開発用のr2.devサブドメイン(レートリミットがあるため運用には推奨されない)ではなく、カスタムドメインをCloudflare DNSに接続して公開読み取りアクセスを構成するのが標準的な方法だ。

それでもS3・Firebase Storageの方が良いケース

エグレス費用だけを見ればR2が圧倒的だが、次のような状況ではあえて移行しない方が合理的だ。

結論: 判断基準はトラフィックプロファイルだ

3社の保存料金はGBあたり1〜3セントの範囲でほぼ変わらない。勝負は結局、ダウンロードトラフィックがどれだけ出ていくか、そしてそのトラフィックにいくら請求されるかで決まる。

数字は嘘をつかない。トラフィックが大きくなるほど、「エグレスが無料」というたった一文が請求書全体を決定づける。