2026年4月29日、いつものように自分のサイトを開くと、アプリのアイコンもスクリーンショットも軒並み表示されなくなっていました。

サイトは落ちていない。デプロイも壊れていない。でも画像だけがごっそり消えている — 個人開発者が一度は踏むと言われる、Vercel無料枠の落とし穴でした。この記事は、その障害の原因と当日中の対応、途中で踏んだ二次的な罠まで含めた実録です。

この記事で分かること

  • Vercel Image Optimizationのクォータ超過で実際に何が起きるか(402エラーと画像消失)
  • 無料枠の消費がどんなペースで溶けたかの実測
  • 当日中に行った対応の順序と、途中で踏んだGoogle CDNの429という二次トラブル
  • 同じ障害を防ぐための設定と運用のチェックリスト

結論: 無料枠の画像最適化は「見えない従量課金」だった

先に結論です。

  • 原因はVercel Image Optimizationの月間クォータ超過(5,038/5,000)。超過後は画像最適化APIが402(Payment Required)を返し、next/image経由の画像が表示されなくなる
  • 機能追加でサイトが賑やかになるほど、画像最適化の消費は加速度的に増える。うちの場合、月の前半はほぼゼロ→後半は1日800〜960のペースだった
  • 即時対応はnext.config.jsimages.unoptimized: true(最適化の全停止)。以降クォータの心配はなくなった
  • 学びは「無料枠は使った分だけ減る"見えない従量"として設計に織り込むべき」ということ

何が起きたか: 画像だけが消えるサイト

症状は独特でした。ページは普通に開く。テキストも出る。でも画像コンポーネント(next/image)で配信している画像 — アプリアイコン、スクリーンショット、アバター — が全滅している。

ブラウザの開発者ツールでネットワークを見ると、/_next/image?url=...へのリクエストが402 Payment Requiredを返していました。402は「支払いが必要」という、Webでほぼ見かけないレアなHTTPステータスです。

Vercelのダッシュボードで使用量を確認すると、Image Optimizationの月間使用量が5,038/5,000。無料枠の上限を超えた瞬間から、最適化リクエストが全部402で弾かれていたのです。

原因: 機能追加の「成功」がクォータを溶かした

使用量のグラフを見ると、原因は一目瞭然でした。

  • 4月1日〜16日: 消費ほぼゼロ
  • 4月17日以降: 1日800〜960のペースで急増
  • 4月29日: 上限5,000に到達

4月17日に何があったか。月刊機関紙「村だより」の創刊、村の地図、リアクション機能の拡張 — サイトの画像量が一気に増える大型アップデートをリリースした日です。

next/imageで表示するすべての画像(アプリアイコン・スクリーンショット・アバター・OG画像・村だよりの表紙)がVercelの最適化を経由する構成だったので、ページが賑やかになった分だけ、変換の回数が積み上がっていきました。

皮肉なことに、サイトが良くなるほど死に近づくカウントダウンが回っていたわけです。しかも普段見ないダッシュボードの中で、静かに。

当日中の対応: 2段階で決着(と、途中の罠)

発覚した日の夕方、その日のうちに対応しました。コミットログがそのまま障害対応のタイムラインになっています。

第1手(17時44分): 消費源の大半を条件付きで止める

まず、消費の大半を占めるSupabase Storage配信の画像(アイコン・スクリーンショット等)だけ最適化をスキップする作戦です。判定ヘルパーを作り、各<Image>に条件付きでunoptimizedを渡しました。

/**
 * Supabase Storage 配信の URL かどうかを判定する。
 * Vercel Image Optimization のクォータ消費を避けるため、
 * Supabase ホストの画像には `unoptimized` を付けて素のまま配信する。
 */
export function isSupabaseStorageUrl(url: string | null | undefined): boolean {
  if (!url) return false
  return /^https:\/\/[^/]*\.supabase\.co\/storage\//.test(url)
}
<Image
  src={app.icon_url}
  unoptimized={isSupabaseStorageUrl(app.icon_url)}
  ...
/>

途中の罠: Google CDNの429

この流れで「アバターも全部unoptimizedにすればいいのでは」と試したところ、今度はGoogleのCDN(lh3.googleusercontent.com)から429 Too Many Requestsが返ってくる場面がありました。

ここで気づいたのですが、Vercelの画像最適化は変換だけでなく、外部CDNへのリクエストをまとめて肩代わりするキャッシュの役目も果たしていました。最適化を外すと、ユーザーのブラウザが直接Googleのサーバーを叩くようになり、レートリミットに引っかかる。1つのエラーを消す対応が、別のエラーを呼ぶ — 障害対応あるあるです。

第2手(18時02分): グローバル無効化で決着

条件分岐で部分的に守るより、まず全面的に止血する方が確実 — と判断して、最終的にはnext.config.jsでグローバルに無効化しました。

// next.config.js
const nextConfig = {
  images: {
    // Vercel Image Optimization の月間クォータを節約するため、
    // 全画像で最適化を無効化(origin から直接配信)。
    // Pro にアップグレードした際は false に戻すと、各 <Image> の
    // unoptimized={isSupabaseStorageUrl(...)} 条件が自動的に有効になる。
    unoptimized: true,
    // ...
  },
}

これでその日のうちに画像は復活。以降、クォータ消費はほぼゼロになり、幸い429も通常の運用では問題になっていません。

ポイントは、第1手で作った条件付きunoptimized消さずに残したことです。設定のコメントにも書いてあるとおり、将来Proプラン($20/月)に上げてunoptimized: trueを外せば、「Supabase配信だけ素通し・それ以外は最適化」という省エネ構成が自動で復活します。応急処置の中に、恒久対応への伏線を仕込んでおくイメージです。

いま、どうしているか

3ヶ月以上たった現在も、無料枠+グローバル無効化のまま運用しています。

  • 表示速度の体感差は許容範囲でした。もともとアップロード画像はサイズが常識的で、村だよりのサムネイルも手元で1200×630のJPEGに圧縮してから置く運用にしているためです
  • 月$20のProに上げる判断基準は「最適化の有無がユーザー体験の差になったとき」と決めています。今はまだそのときではない、という判断です

再発防止チェックリスト

無料枠でVercel+next/imageを使うなら、これだけは確認しておくことをおすすめします。

  • VercelダッシュボードのUsageを月1回は見る(超過は静かにやってくる)
  • 画像が増える機能をリリースしたら、翌週に消費ペースを確認する
  • 外部ストレージ(Supabase等)配信の画像は、最適化を通す必要が本当にあるか検討する
  • unoptimizedで運用するなら、アップロード時の圧縮を習慣にする(原本を置かない)
  • 402と429の意味を覚えておく(どちらも「コードのバグではない」障害のサイン)

まとめ

  • Vercel無料枠のImage Optimizationは月間クォータ制。超過すると402で画像が消える
  • 消費はサイトの成長に比例して加速する。機能追加の成功がクォータ枯渇の原因になり得る
  • 即時対応はimages.unoptimized: true。ただし最適化には外部CDNの盾という隠れた役割もあり、外すと429を呼ぶことがある
  • 応急処置の中に恒久対応への伏線(条件付きunoptimized)を残しておくと、後の移行が一瞬で済む

無料枠は「タダで使える枠」ではなく「残高の見えにくいプリペイドカード」です。残高確認の習慣さえあれば、怖いものではありません。このサイトも今日も無料枠で元気に動いています。