1. はじめに:作っているもの紹介
こんにちは!個人でWebサービスやゲームを作ったり趣味でイラストを書いているすすずです。
私は現在、絵を描く人向けに「絵描きの絵日記」というWebアプリを開発・運用しています。
| 絵描きの絵日記とは? |
|---|
| 毎日描いたイラストをカレンダー形式で楽しく記録できる絵日記サービス。 「今月どれくらい描けたかな?」が一目でわかる月間カレンダーや、SNS共有用のまとめカード生成、さらには月間の進捗を動画(MP4)にして書き出す「月間振り返り動画生成機能」などを搭載しています。 (スタック: Next.js App Router / Supabase / Vercel / Tailwind CSS / PWA) |
![]() |
| https://www.artwork-diary.com/ |
絵日記サービスなので、当然ながら「1日1枚、高解像度のイラスト画像」が毎日アップロードされます。
最初は「よーし!AuthもDBもストレージも全部Supabaseにまとめて爆速開発だ!」と意気揚々とリリースしたのですが、ある日、ダッシュボードを見てあることに気づいてしまいました。
2. 「やべ、ストレージと転送量、絶対足りねえ!!」
Supabaseは神サービスです。Auth(Googleログイン)もPostgreSQLもRLS(Row-Level Security)も最高に使いやすい。しかし、無料枠(Free Tier)のストレージスペックを改めて確認した瞬間、冷や汗が吹き出しました。
足りないストレージ
- ストレージ容量:1 GB
- 下り転送量(Egress):月間 5 GB
「……ん? 1GB?」
イラスト画像はクライアント側でWebP変換(150KB〜300KB程度)に圧縮しているとはいえ、約3,000〜5,000枚で1GBの天井に激突します。アクティブユーザーが100人集まって毎日描いたら、わずか1ヶ月ちょっとでストレージが満杯になる計算です。
さらに恐ろしいのが「月間 5GB の Egress(下り転送量)制限」。ありがたいことにユーザーも増え、ストレージの残り容量は日に日に減っていってました。
このままユーザーが増えて、絵日記のカレンダーやフィードをユーザーが閲覧したり、万が一SNSで誰かの投稿がバズったりしたら(皮算用)……数日で月間5GBを使い果たして画像配信が停止(または課金)します。それは嫌だ。
各種ストレージサービスの比較(1ドル=155円換算)
| サービス | 無料容量 | 下り転送量(Egress) | 容量超過後のコスト | イラスト10万枚保存時 |
|---|---|---|---|---|
| Supabase (Free) | 1 GB | 月 5 GB | 上限到達で停止/要Pro | 運用不可 |
| Supabase (Pro) | 100 GB | 月 250 GB | $25/月(約3,800円)固定 | 約 3,800 円 / 月〜 |
| AWS S3 | 5 GB (1年のみ) | 約 $0.09/GB (従量) | 保存料+高額な転送料 | 数百円〜数千円(バズると従量課金) |
| Cloudflare R2 ⭐ | 10 GB | 完全無料($0 / 無制限) | $0.015 / GB (約2.3円/GB) | 約 23 円 / 月 |
「下り転送量(Egress)が……完全無料……だと……!?」
そう、救世主は Cloudflare R2 でした。
容量10GB(約3万〜5万枚)まで永久無料。超過しても1GBあたり月約2.3円。そして何より配信転送料金が1円もかからない。AWS S3で最も恐れられる「バズった時の高額請求」が原理的に起きないのです。しかもS3互換APIを備えています。
「よし!DBと認証は便利なSupabaseをそのまま使い、画像保存・配信だけをCloudflare R2に切り出そう!」
こうして、稼働中の本番サービスにおける「ストレージお引越し」を決意しました。
3. 移行自体はスムーズに完了……したはずだった
移行の設計はシンプルです。
- 画像アップロードは Next.js の Route Handler(
/api/upload)を経由して@aws-sdk/client-s3で R2 に保存。 - R2 バケットにカスタムドメイン(例:
media.example.com)を接続し、Cloudflare のエッジキャッシュ(cf-cache-status: HIT)で配信。 - 過去の全画像データもバッチスクリプトを書いて R2 へ同期。
テストアップロードも大成功。カレンダー画面にも綺麗なイラストが爆速で表示されます。
「よっしゃ!! これで転送量破産に怯える夜とはおさらばだ!!」
……と、祝杯をあげようとしたその時、機能テストで悲鳴が上がりました。
「カレンダーのまとめ画像保存と、月間振り返り動画の書き出しが全部エラーになって動かない!!」
コンソールを見ると、悪名高きあのエラーが赤々と表示されていました。
SecurityError: The operation is insecure.
Failed to execute 'toBlob' on 'HTMLCanvasElement': Tainted canvases may not be exported.
4. ブラウザの「CORSキャッシュ衝突」でCanvasが爆死した話
エラーメッセージの意味は明白です。
「CanvasにCORS許可のない外部画像が描画されたため、セキュリティ保護(情報漏洩防止)のためにCanvasが汚染(Tainted)され、画像や動画データとしての書き出し(toBlob / toDataURL)が禁止された」というもの。
しかし、納得がいきません。
なぜなら、Cloudflare R2側にはあらかじめCORS設定を入れていたからです。
[
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}
]
※例
curl に Origin ヘッダーを付けて確認すると、ちゃんとCORSヘッダーが返ってきます。
$ curl -I -H "Origin: https://your-app.com" https://media.example.com/image.webp
Access-Control-Allow-Origin: *
R2のCORS設定自体は正しく動作しています。
「設定は合ってるのに、なんでブラウザのCanvasは汚染されたと言い張るんだ……!?」
数時間に及ぶデバッグの末、ついに犯人を突き止めました。
犯人は「ブラウザのHTTPディスクキャッシュ」だった
原因はR2でもNext.jsでもなく、ブラウザ(ChromeやSafari)のHTTPキャッシュ仕様にありました。
ポイントは、R2(S3互換ストレージ全般)はリクエストに Origin ヘッダーが含まれている場合にのみCORSレスポンスヘッダーを返すという点です。通常の <img> タグはブラウザが Origin ヘッダーを送らない(no-cors モード)ため、CORSヘッダーなしのレスポンスがキャッシュされます。
この悲劇が起きるメカニズムは以下の通りです。
【ステップ1: 通常の画面表示】
ユーザーが絵日記カレンダーを開く。
HTMLの <img> タグが画像(https://media.example.com/...)を読み込む。
このとき、ブラウザは「no-cors」モード(Originヘッダーなし)で画像をリクエストする。
⬇️
R2は画像を返す(※Originヘッダーがないため、CORSヘッダーは付かない)。
ブラウザは、この「CORSヘッダーのないレスポンス」をローカルのディスクキャッシュに保存する。
【ステップ2: Canvasでの合成・動画生成】
ユーザーが「カレンダーを画像で保存」または「振り返り動画を書き出す」ボタンを押す。
JavaScript(Canvas / WebCodecs)が、同じ画像URLを fetch(url, { mode: 'cors' }) や
<img crossOrigin="anonymous"> で読み込もうとする。
⬇️
ここでブラウザが余計な気を利かせる:
「あ! その画像のURL、さっきステップ1でキャッシュしたやつがあるよ!
ネットワーク使わずにキャッシュ返しとくね!」
⬇️
ブラウザは、先ほどステップ1で保存した
【CORSヘッダーが付いていないキャッシュ】をCanvasに返してしまう!
⬇️
Canvas「CORSヘッダー(Access-Control-Allow-Origin)が付いてない!
汚染(Tainted)判定! 画像のエクスポートをブロック!」
⬇️
💥 Tainted canvases may not be exported で爆死
サーバー側が正しくCORSを設定していても、「先に通常の <img> タグで画像が表示されていた場合、ブラウザがその non-CORS キャッシュを再利用してしまう」ために発生するのです。
5. 解決策:キャッシュキーの強制分離 & Blob URL化
この問題を解決するために、2段階の防衛策を実装しました。
対策①:クエリパラメータでキャッシュキーを強制分離する
ブラウザのHTTPキャッシュは「URL文字列全体」をキーとして判定します。
そのため、Canvasや動画生成エンジンで画像を読み込む際は、末尾に ?cors=1(または既存クエリがあれば &cors=1)を強制付与します。
これにより、ブラウザに対して「これは <img> で読み込んだ画像とは別のリソースだから、さっきのキャッシュを使わずに、必ずCORSモードで新規リクエストを飛ばせ!」と強制できます。
対策②:fetch して Blob URL に変換してから Canvas に渡す
さらに堅牢にするため、取得したレスポンスを response.blob() でバイナリ化し、URL.createObjectURL(blob) でローカルの Blob URL に変換します。
blob:https://your-app.com/... のようなローカルBlob URLは、ブラウザから見て「100% 同一オリジンの安全なリソース」として扱われます。これにより、CanvasがTaintedになるリスクを原理的にゼロにできます。
CORSキャッシュ衝突を回避する画像ローダー
実際にアプリに組み込んだコード(抜粋)がこちらです。
/**
* ブラウザのCORSキャッシュ衝突を回避し、
* Canvas汚染(Tainted Canvas)を起こさずに画像を読み込むローダー
*/
export async function loadSafeCanvasImage(url: string): Promise<HTMLImageElement> {
// すでに blob: や data: の場合はそのまま読み込む
if (url.startsWith('blob:') || url.startsWith('data:')) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Image load failed'));
img.src = url;
});
}
// 1. 通常の<img>キャッシュと衝突しないよう、?cors=1 でキャッシュキーを強制分離
const corsUrl = url.includes('?') ? `${url}&cors=1` : `${url}?cors=1`;
try {
// 2. 必ず mode: 'cors' でフェッチし、CORSヘッダー付きで取得
const response = await fetch(corsUrl, { mode: 'cors' });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// 3. レスポンスを Blob に変換し、同一オリジンのローカル Blob URL を生成
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
return await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(blobUrl); // メモリリーク防止
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(blobUrl);
reject(new Error('Blob image decode failed'));
};
img.src = blobUrl; // ローカルURLなので、Canvasから見て同一オリジン
});
} catch (err) {
console.warn('CORS fetch failed, falling back to direct load:', err);
// フォールバック: 直接 Image オブジェクトで読み込む(?cors=1 は維持)
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(err);
img.src = corsUrl;
});
}
}
この対策を入れた瞬間、カレンダーまとめカードの生成も、月間振り返り動画のレンダリングも、一度のエラーもなく完璧に動作するようになりました。
6. 考察:これはベストプラクティスなのか?
ここまで読んで「?cors=1 でキャッシュキーを分離するのはクライアント側のハック(対症療法)じゃないの? もっと綺麗な方法があるのでは?」と思った方もいるかもしれません。
実際、私もそう思いました。そして「より綺麗な正攻法」を2つ検討し、1つは実際に本番で試しています。
検討① Cloudflare Transform Rules で常にCORSヘッダーを返す
Cloudflare の Transform Rules(Modify Response Header)を使い、「Origin ヘッダーの有無に関わらず、画像レスポンスには常に Access-Control-Allow-Origin: * を付与する」という設定をエッジに入れるアプローチです。
一見これで完全解決しそうですが、ブラウザのキャッシュ判定は Vary: Origin の有無やキャッシュパーティションの実装に依存しており、ブラウザごとに挙動が微妙に異なります。特にSafari / WebKit や PWA standalone 環境では、CDN側でヘッダーを正しく返していてもディスクキャッシュの再検証をスキップする挙動が過去に複数報告されています。
つまり、Transform Rules を入れたとしても ?cors=1 によるキャッシュキー分離は安全のために残しておくべきであり、**「入れても ?cors=1 を外せないなら、入れるメリットが薄い」**という結論に至りました。
検討② <img crossOrigin="anonymous"> を全画像に付与する
すべての <img> タグに crossOrigin="anonymous" を付ければ、ブラウザは最初の画像読み込み時から Origin ヘッダーを送信します。R2は Access-Control-Allow-Origin: * 付きでレスポンスを返し、ブラウザはCORSヘッダー付きのレスポンスをキャッシュするため、後からCanvasで同じURLを使っても汚染されない――理論上は最も美しい解決策です。
実際にこのアプローチを本番環境に投入しました。 しかし、わずか数分で撤回することになりました。
理由は単純です。crossOrigin="anonymous" を付けた <img> タグは、CORSが一度でも失敗すると画像が一切表示されなくなるからです。CDNの一時的な不調、キャッシュ状態の汚れ、ブラウザ固有のバグ――これらが1つでも起きた瞬間、カレンダー上のイラストが全部真っ白になります。
| アプローチ | 正常時 | CORS失敗時 |
|---|---|---|
現在の方式(?cors=1 + Blob) |
画像表示 ✅ / Canvas ✅ | 画像表示 ✅ / Canvas ❌ |
crossOrigin="anonymous" 方式 |
画像表示 ✅ / Canvas ✅ | 画像表示 ❌ / Canvas ❌ |
このサービスにおいて「画像が表示されない」は致命傷です。一方、「動画生成が失敗する」はリトライで済みます。
結論:障害の局所化という設計判断
現在の ?cors=1 + Blob URL化は、二重ダウンロードというコストを払う代わりに、「通常の画像表示は何があっても壊さず、Canvas生成の中だけで安全にCORS処理を完結させる」という障害の局所化を実現しています。
この非対称性(画像表示 >> 動画生成)を考えると、今の設計が最も合理的でした。
7. まとめ
今回の移行を経て得られた知見をまとめます。
-
Supabase × Cloudflare R2 の組み合わせは、画像を大量に扱う個人開発において最強クラスのコスパを実現できる。認証・DB は Supabase、画像配信は R2(Egress 完全無料)という分離構成がおすすめです。
-
ストレージ移行そのものは簡単。本当の罠はブラウザのキャッシュ仕様にある。S3互換ストレージ(R2, MinIO, Backblaze B2 等)に移行して Canvas や動画生成機能を持つアプリでは、CORSキャッシュ衝突に必ず遭遇します。
-
?cors=1によるキャッシュキー分離 + Blob URL化は、フロントエンドだけで完結する確実な防衛策。CDNやブラウザの気まぐれに左右されず、どの環境でも安定して動作します。
同じように「Supabaseのストレージ無料枠が足りない」「S3互換ストレージに移行したら Canvas が壊れた」という方の参考になれば幸いです!
「こうすればいいじゃん」というツッコミも大歓迎です!!
