🏗️

Vercelアカウントが停止したので画像配信をCloud Run + Cloudflare Workersで作り直した

に公開

はじめに

以前、プライベート写真の保存サービスを自作した記事を書きました。
https://zenn.dev/chot/articles/original-media-storage

使っていく中でVercelのFast Origin Transfer無料枠を超過し、アカウントが停止してしまいました。再度使えるようにするべく画像配信の仕組みをリニューアルしたため、経緯と新しい構成を紹介します。

旧構成の課題

前回の構成では、コストを下げるための試行錯誤の結果、画像のリサイズやフォーマット変換をNext.jsのAPI Routes(Sharp)で行い、Vercelにデプロイしていました。

しかし運用を続ける中でVercelのFast Origin Transferが無料枠を大幅に超過し、アカウントが停止してしまいました。画像配信の仕組みを根本的に見直す必要が出てきました。

画像の加工と高速配信を極めて安価(目指すは無料)で行う方法をいくつか検討しました。

選択肢 断念した理由
Next.jsのAPI Routes(Sharp) 旧構成。Fast Origin Transferの無料枠超過でアカウント停止
Vercelのnext/image Hobbyプランの最適化上限が5,000回/月で、数千枚規模の写真には不十分
Cloudflare Images 無料枠は5,000ユニーク変換/月。超過分は$0.50/1,000変換で、ギャラリー規模では課金が避けられない
Cloudflare Workers内で加工 無料プランのCPU時間は10ms/リクエスト。画像加工には到底足りず、WASMを使ってもなかなか厳しい

前回、Cloud Runについてあまり検討していませんでしたが無料枠が充実していることを知りました。単体では難しいもののWorkersキャッシュを活用し、初回以降のCloud Runリクエストを減らすことで、ほとんど無料に近いコストで運用できそうです。

新しい構成

モノレポ(pnpm + Turborepo)で4つのパッケージに分かれています。

パッケージ 役割 デプロイ先
app フロントエンド(Next.js) Vercel
cdn エッジキャッシュ + ルーティング(Hono) Cloudflare Workers
media-processor 画像加工サーバー(Rust) Cloud Run
storage-proxy B2ストレージのプロキシ(Hono) Cloudflare Workers

旧構成ではNext.jsのAPI Routesに集約されていた画像配信の責務を、役割ごとに分離しました。

リクエストの流れは以下の通りです。

画像リクエスト(キャッシュミス時)

  1. ブラウザからCDN Workerにリクエスト
  2. CDN Workerが認証を検証し、Cloud Runに画像加工をリクエスト
  3. Cloud RunがストレージプロキシWorker経由でB2からオリジナル画像を取得
  4. Cloud Runで加工(リサイズ・フォーマット変換)して返却
  5. CDN WorkerがCloudflare Cache APIでキャッシュし、ブラウザに返却

2回目以降はキャッシュヒットするため、2〜4の処理はスキップされます。

動画リクエスト / ダウンロード

動画やダウンロードは加工不要としています。
CDN WorkerからService Binding経由でストレージプロキシWorkerに直接ルーティングし、B2のオリジナルファイルをそのまま返します。Service BindingはCloudflare Workers同士をパブリックなネットワークを経由せずに直接呼び出せる仕組みで、外部からのアクセスが発生しないため追加の認証も不要です。Cloud Runを経由しないため不要なCPU消費を避けています。

アップロード

アップロードはNext.jsのAPI Routeで署名付きURLを発行し、ブラウザからB2に直接アップロードします。

Cloud Runで画像加工

Cloud Runを選んだ理由は、画像加工を行えることと、無料枠の大きさです。
画像加工はCPU集約的な処理のため、課金が発生するとすればCPUです。処理をRustで書くことでCPU・メモリ消費を抑え、無料枠内に収まりやすくしています。ランタイムはgcr.io/distroless/cc-debian12で動かしています。

CDN Workerからのリクエストで以下のパラメータを指定できます。

パラメータ 説明
w 幅(最大4096px)
h 高さ(最大4096px)
q 品質(1〜100)
f フォーマット(webp / avif / jpeg / png)

Cloudflare Workersでエッジキャッシュ

cdnパッケージはCloudflare Workers(Hono)上で動作し、画像配信の入口になっています。

Cloudflare Cache APIを使い、加工済み画像を1年間キャッシュします。

Cache-Control: public, max-age=31536000, immutable

このヘッダーによりブラウザにも同じ期間キャッシュされます。2回目以降の同じ画像はブラウザキャッシュから即座に返るため、エッジにもCloud Runにも到達しません。ブラウザキャッシュにない場合はエッジキャッシュ(Cache API)から返り、どちらにもない場合のみCloud Runで加工が行われます。

認証の仕組み

プライベートな写真を扱うため、画像のURLを知っていても許可されたユーザー以外は閲覧できないようにしています。CDN Workerが画像配信の入口となり、ここで認証を通過しなければ画像単体のURLを直接叩いても表示されません。

各サービス間でも認証を行い、どの経路からも不正アクセスできない構成にしています。

経路 認証方式
ブラウザ → CDN Worker Auth.jsのJWT
CDN Worker → Cloud Run GCP OIDCトークン
CDN Worker → ストレージプロキシWorker Cloudflare Service Binding
Cloud Run → ストレージプロキシWorker Cloudflare Access Service Token
ストレージプロキシWorker → B2 AWS Signature v4

GCP OIDCトークン(CDN Worker → Cloud Run)

CDN WorkerからCloud Runへのリクエストには、GCPのOIDCトークンを使用しています。Cloud Run側でサービスに「未認証の呼び出しを許可しない」設定をしておくと、有効なOIDCトークンを持たないリクエストは自動的に拒否されます。

GCPで専用のサービスアカウントを作成し、CDN WorkerでサービスアカウントキーからOIDCトークンを生成。Authorization: Bearer <token>ヘッダーに付与してリクエストしています。

Cloudflare Access Service Token(Cloud Run → ストレージプロキシWorker)

Cloud RunからストレージプロキシWorkerへのリクエストには、Cloudflare Access Service Tokenを使用しています。Cloudflare AccessでストレージプロキシWorkerへのアクセスポリシーを設定し、Service Tokenを持つリクエストのみ許可しています。

Cloud Run側ではリクエストヘッダーにCF-Access-Client-IdCF-Access-Client-Secretを付与することで認証を通過します。

どのサービスも直接アクセスすると401/403が返るため、正規のルート以外から取得することはできません。

コスト感

ストレージ(変更なし)

前回と同じくBackblaze B2のストレージ料金が基本です。Cloudflareとの提携によりWorkersからのダウンロード配信は無料です。

1$ / 150円換算

50GB 100GB 200GB
Google Drive 290円 290円 440円
作ったもの 36円 81円 171円

画像配信(リニューアル部分)

Cloud Runはリクエストベースの課金(サービス)を使用しています。

リソース 無料枠(月) 超過単価
CPU 180,000 vCPU秒 $0.000024 / vCPU秒
メモリ 360,000 GiB秒 $0.0000025 / GiB秒
リクエスト 200万リクエスト $0.40 / 100万リクエスト

使い始めはCDNキャッシュがないため全てCloud Runで加工されますが、次回以降はキャッシュを返すので、料金はほぼかかっていません。
特にアカウント開設時は無料トライアル期間中のため実質無料です。

Cloudflare Workers(CDN Worker、ストレージプロキシWorker)は無料プランで十分運用できています。

まとめ

旧構成ではNext.jsのAPI Routesに画像配信の責務が集中しており、Vercelの制約に縛られていました。Cloud Run(Rust)+ Cloudflare Workersの構成にリニューアルしたことで、月額はほぼストレージ料金のみとなり、画像配信の加工・キャッシュを健全に運用できる形となりました。

  • Cloud Runの無料枠 + CDNキャッシュで画像加工・配信コストが実質ゼロ
  • Rustによる高速・省メモリな画像処理で無料枠に収まりやすい

無料枠ではCloudFront Functions + Lambdaの構成のほうが大きいですが、今回はエッジでのJWT検証やService Bindingによるサービス間連携など、Workersの柔軟性が必要だったためCloudflareベースの構成としています。

また、各サービス間のアクセス制限など色々な選択肢があるのを知り、良い勉強になりました。

Vercelのアカウントの復旧についても語りたいところですが、これはまた別のお話…

Discussion