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社とも「無料枠」を謳っているが、その性質はまったく異なる。
- Cloudflare R2: Standardストレージ基準で**保存10GB-monthまで無料、Class A(書き込み)リクエスト月100万件まで無料、Class B(読み取り)リクエスト月1,000万件まで無料。**そしてこの無料枠とは別に、エグレスはストレージクラスやトラフィック量に関係なく常に無料だ。R2から直接出ていくダウンロードトラフィックそのものには、そもそも課金項目自体が存在しない。
- Firebase Storage(Spark/Blaze共通): 新規バケット(
*.firebasestorage.app)基準でダウンロードが月100GBまで無料、保存は5GB-monthまで無料。有料プラン(Blaze)に切り替えても、この無料割り当て自体は維持される。ただしこの無料割り当てはus-central1、us-west1、us-east1の3リージョンにのみ適用されるという条件が付く。 - AWS S3: 「インターネットへのデータ転送が月100GBまで無料」という枠は確かに存在する。だが公式ドキュメントの文言をそのまま引用すると、この100GBは**「aggregated across all AWS Services and Regions」**、つまりアカウント全体・全リージョン・全サービスを横断して合算される単一のプールだ。EC2インスタンスがAPIレスポンスを送信しようと、CloudFrontがキャッシュミスでオリジン(S3)からデータを引っ張ってこようと、RDSのバックアップをダウンロードしようと、すべて同じ100GBのプールを消費する。ストレージ単体しか運用していない学習用アカウントならこの無料枠にも意味があるが、すでに他のワークロードが稼働している本番AWSアカウントでは、事実上常に消費し尽くされている状態と見なす方が安全だ。
この違いが、後述のシミュレーション表でS3とFirebase Storageの低コスト帯の数字が食い違って見える理由でもある。
実際の請求額シミュレーション: 月間ダウンロード100GB/1TB/10TB
以下の表はエグレストラフィックのみを対象に算出した月間請求額だ。計算条件は次のように明示的に設定した。
- 1TB = 1,000GBと単純化(請求書上の実際の単位はGiB/TiBに近いが、桁数を比較する目的のため十進単位で計算)。
- R2: エグレスは常に$0。(保存・オペレーション費用は別節で扱う)
- S3: アカウント全体で共有される無料枠(100GB)はすでに他のAWSトラフィックで消費済みと仮定し、最初の10TB/月帯域の単価$0.09/GBをそのまま適用。
- Firebase Storage: Storage専用の無料割り当て(月100GB)はこのアプリのためだけに毎月新たに満たされると仮定し、超過分にGBあたり$0.12を適用。
| 月間ダウンロードトラフィック | 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を常時保存していると仮定すると:
- R2: 500GB × $0.015 = 月$7.5
- S3: 500GB × $0.023 = 月$11.5
- Firebase Storage: レガシー単価基準で500GB × $0.026 ≈ 月$13(新規バケットはリージョンごとに異なり、公式ページに単一の数字が明記されていない — 確認が必要な場合は必ずコンソールの見積もりを見るべきだ)
オペレーション費用も確認してみると、月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のようなパッケージでクライアント側のディスクキャッシュを設定しても、初回アクセスやキャッシュ削除後にはやはりオリジナルを丸ごと取得しなければならない構造は変わらない。
実践的な改善の順序
- アップロード時点でリサイズする: Firebase Extensionsの「Resize Images」拡張機能を組み込むか、サーバー(Cloud Functions)側でアップロードトリガーとして複数サイズ(サムネイル200px、中間800px、オリジナル)を生成しそれぞれ保存する。一覧画面ではサムネイルのみを呼び出し、詳細画面に入ったときだけオリジナルをリクエストする。
- Cache-Controlを長く再設定する: 画像パスにコンテンツハッシュを含めて不変(immutable)ファイルにし、
Cache-Control: public, max-age=31536000, immutableメタデータを明示的に設定する。ファイル名が変わらない限り、恒久的なキャッシュが可能になる。 - Flutter側で表示サイズとリクエストサイズを一致させる:
Image.networkにcacheWidth/cacheHeightを指定すればデコード費用は削減できるが、ネットワーク経由で受け取るバイト数そのものを減らすには、結局サーバー側が最初から小さいファイルを配信する必要がある。クライアントのデコード最適化とエグレス削減は別問題であることを混同してはいけない。 - 前段にCDNを置く: Firebase Hostingのrewriteや別のCDNで画像リクエストをキャッシュすれば、同じ画像への繰り返しリクエストがオリジン(Storage)まで到達しなくなり、エグレスが減る。ただしこの場合、CDN自体の帯域幅料金が別途発生し得る点は考慮しておく必要がある。
R2への移行チェックリスト
R2はS3互換APIを提供しているため、既存のS3ベースのコードベースは思ったより少ない変更で移行できる。ただし100%同一というわけではない。
1) まずサポートされていない機能を確認する。 R2のS3互換APIはPutObject、GetObject、HeadObject、DeleteObject、ListObjectsV2、マルチパートアップロード(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が圧倒的だが、次のような状況ではあえて移行しない方が合理的だ。
- すでに堅牢なIAM/セキュリティ体系が組まれているAWS組織: バケットポリシー、VPCエンドポイント、クロスアカウントアクセス、Object Lockを利用したコンプライアンス要件が絡んでいる場合、R2がサポートしない機能のせいで移行自体が困難になったり、再設計コストがエグレス削減分を上回ったりする可能性がある。
- Firestore/Authと強く結合したFirebaseエコシステム: Firebase Storage Security Rulesで
request.auth.uidベースの細かなアクセス制御をすでに組んでいる、あるいはStorageのアップロードトリガーでCloud Functions/Extensions(サムネイル生成、動画トランスコーディング)を連携させて使っている場合、この統合をそのまま維持する方が開発速度の面で有利だ。特にMVP・初期スタートアップ段階のようにダウンロードトラフィックがまだ月100GB前後であれば、エグレス費用の差自体が微々たるもので、移行コストを正当化しにくい。 - CDN統合の完成度: S3+CloudFrontの組み合わせは、署名付きクッキー、地域別アクセス制御、Lambda@Edgeなど長年かけて磨き上げられた機能が多い。R2もCloudflare CDNと統合されるが、すでにCloudFront中心で設計されたキャッシュ無効化・エッジロジックが多いなら、切り替えコストを別途見積もる必要がある。
- チーム内の運用知識とツールチェーン: Terraformモジュール、モニタリングダッシュボード、アラートルールがすでにAWS/GCP基準で整備されている場合、エグレス費用の削減額がこの運用資産を再構築する人件費より小さいことがある。この場合はR2を新規に作るメディアパイプラインにのみ優先的に導入し、既存のバケットはそのままにしておく段階的な移行が現実的だ。
結論: 判断基準はトラフィックプロファイルだ
3社の保存料金はGBあたり1〜3セントの範囲でほぼ変わらない。勝負は結局、ダウンロードトラフィックがどれだけ出ていくか、そしてそのトラフィックにいくら請求されるかで決まる。
- 月間ダウンロードが100GB以下と小さい場合(初期MVP、社内ツール)、3社のどれを使ってもコストの差は微々たるものだ。この場合は既存エコシステムとの統合(Firebase Auth、AWS IAM)を優先して検討すればよい。
- 月間ダウンロードが1TBを超えた瞬間から、R2と残り2社との差は2桁の倍率まで広がる。画像・動画がコアコンテンツのソーシャル/コマース系Flutterアプリなら、この時点でR2への移行を真剣に検討すべきだ。
- 10TB以上のダウンロードが出るサービスであれば、エグレス費用の差が年間数万ドル単位まで広がるため、前述の移行チェックリスト(S3互換APIの制約、Super Slurper、presigned URL、Workersバインディング)を基準に実際の見積もりを出してみるとよい。
数字は嘘をつかない。トラフィックが大きくなるほど、「エグレスが無料」というたった一文が請求書全体を決定づける。