媒體儲存雙軌策略 — 精選內容用 Cloudflare R2 + CDN;UGC 用 GCS Signed URL
Context
媒體(音訊 MP3)儲存策略目前沒有 ADR 記錄。決策實際上在 2026-05 已做成,但只埋在已歸檔的
Spectra change docs/openspec/changes/archive/2026-05-04-media-delivery-cdn-strategy/
(cap ip-content-r2-storage,後併入 E15-ugc-playback)的 design.md 決策二裡。L3 DECISION
應該在 docs/adr/ 有一個可發現的家,這份 ADR 回溯記錄該決策,並補上一個當初沒做的成本比較
(R2 vs GCS + Google Cloud CDN;原 design 只比了 R2 vs GCS + Cloudflare CDN)。
系統有兩類音訊內容,存取特性截然不同(已由 contents.source_type 欄位編碼):
- UGC / 個人化(
source_type IN (ai_generated, ai_custom)):每個孩子的 AI 生成故事/兒歌 (ai_generated)與 E18 Custom Creator 自製育兒內容(ai_custom,content_generation.py 帶family_id/child_profile_id)。皆 family 私有、唯一、重用率極低(含孩子名字,CDN cache hit 估 < 5%)。 - YiBi 精選 / IP(
source_type IN (curated, licensed)):Admin portal 產生或授權的故事/音樂, 跨家庭共享、高重用。
contents.source_type共四個值(見E11-device-tokenspec):curated/licensed/ai_generated/ai_custom。前兩者公開共享,後兩者 family 私有——這條公開/私有界線正是下方雙軌的依據。
現況:production 全部走 GCS(Terraform 寫死 STORAGE_PROVIDER=gcs)。R2 provider 已實作但尚未上線;
切換前還有 bug(issue #368:R2 upload() 回傳 CDN URL 而非 object key,導致 delete 靜默失敗)待修。
本 ADR 記錄的是目標策略,非當前 production 狀態。
Decision
媒體儲存依 source_type 分兩軌:
| 內容類別 | source_type | 儲存 + 交付 | URL 形式 |
|---|---|---|---|
| YiBi 精選 / IP(公開共享) | curated, licensed | Cloudflare R2 + Cloudflare CDN | 公開 CDN URL(build_ip_content_path() 路徑) |
| UGC(family 私有) | ai_generated, ai_custom | GCS + Signed URL | 過期簽章 URL(預設 1h TTL) |
隱私邊界鐵則:路由依據是「公開共享 vs family 私有」,不是「AI 生成 vs 人工」。任何帶
family_id的內容(含ai_custom)一律走私有 GCS Signed URL 軌,不得放上公開 R2/CDN,無論 egress 成本。
選擇依據有兩條獨立的軸,兩者都指向同一個雙軌結論:
軸一:成本(egress 主導)
單位價格(2026-06;Google 以 GiB 計價,≈1.074 GB,下表已大致正規化為 GB 比較):
| 成本項 | R2 + Cloudflare CDN | GCS + Google Cloud CDN | GCS 直連 Signed URL |
|---|---|---|---|
| 儲存 /GB-月 | $0.015 | $0.020 | $0.020 |
| 對使用者 egress /GB | $0(免費) | ~$0.08(cache egress,首 10 TB NA/EU) | ~$0.12(首 1 TB) |
| Cache fill /GB | — | ~$0.01 | — |
| 請求 ops | Class B $0.36/M | cache lookup $0.75/M + cache miss 時 GCS ops | Class B $0.40/M |
| 固定成本 | 無 | HTTPS Load Balancer ~$18/月 | 無 |
精選內容(目錄 ~2 GB)兩情境試算:
| 每月 egress | R2 + CDN | GCS + Cloud CDN | GCS 直連 |
|---|---|---|---|
| 120 GB(~1k 裝置) | < $0.5 | ~$28 | ~$14 |
| 1,200 GB(~10k 裝置) | < $0.5 | ~$115 | ~$144 |
- R2 因 egress 免費,成本幾乎不隨流量變動(只剩儲存 + 讀取 ops);Google 兩案都隨 egress 線性成長。
- 10k 裝置規模下,R2 相對 Google 兩案省 ~$115–144/月,且隨裝置數線性放大。
- Crossover 提醒:GCS + Cloud CDN 要 egress > ~450 GB/月才贏 GCS 直連(因為 LB $18/月固定成本
- cache egress $0.08 vs 直連 $0.12 的差額需要足夠流量才回本)。低流量時加 Google CDN 反而更貴。
軸二:存取控制(決定性,非成本可取代)
R2 這套設計回傳的是公開 CDN URL,無 per-request 認證(backend/src/infrastructure/providers/storage/r2.py
的 get_url() 回傳 {CDN_BASE_URL}/{path};backend/src/infrastructure/config/signed_url.py 對 R2 URL 直接
pass-through 不簽章)。
- 精選內容公開共享 → 公開 URL 完全合適,且能吃到 CDN 邊緣快取。
- UGC 是 family-scoped 私有資料 → 必須用簽章 + 過期 URL 做存取控制,不能放在公開 CDN bucket。
因此即使 R2 egress 免費,私有 UGC 也不適合走目前的 R2 公開路徑;保留在 GCS Signed URL 是存取控制的要求,
不只是快取經濟。contents.source_type 已把這條公開/私有界線編碼進資料層。
Alternatives Considered
A. 全部走 GCS + Google Cloud CDN
- 優點:單一雲(GCP),統一 IAM / 可觀測性;Signed URL 原生支援,私有內容沒問題。
- 缺點:egress 不免費(cache egress ~$0.08/GB)成本線性成長;HTTPS LB ~$18/月固定成本; 低流量(< ~450 GB/月)時 CDN 反而比 GCS 直連貴;UGC cache hit < 5%,對 UGC 加 CDN 是純負擔。
- 未採用:精選內容的 egress 成本顯著高於 R2;對 UGC 無快取收益。
B. 全部走 GCS 直連 Signed URL(= 現況 production)
- 優點:最簡單,單一系統,私有/公開都用簽章 URL 統一處理。
- 缺點:egress 最貴(~$0.12/GB),無快取層,origin 服務每一個 byte;精選內容跨家庭重複下載時成本爆炸。
- 未採用(對精選內容):高重用共享內容不放 CDN 是把錢丟進 egress;保留作為 UGC 的軌道。
C. 全部走 R2 + Cloudflare CDN(含 UGC)
- 優點:egress 全免費,成本最低。
- 缺點:R2 公開 CDN URL 無法安全服務私有 UGC(見軸二)。
- 未採用:違反 UGC 的存取控制需求。
Consequences
- 正面:精選內容 egress 成本扁平、可預測,隨裝置量成長不爆炸;UGC 維持簽章存取控制。
- 代價(接受):
- 跨雲營運面:精選儲存在 Cloudflare R2,其餘基礎設施在 GCP(兩套憑證/監控/帳務)。
- 公開 URL 限制:R2 軌道只能放公開內容;任何需要 per-request 授權的內容不得進此軌。
- 兩套 provider 需各自維運(
STORAGE_PROVIDER切換 +CDN_BASE_URL設定)。
- 實作前置(R2 上線前必做):
- 修 issue #368(R2
upload()改回傳 object key,signed_urlR2 分支組 CDN URL,factory 加CDN_BASE_URLguard)。 - R2 路徑沿用既有
build_ip_content_path()(v{version}/ip/{type-plural}/{id}/audio.mp3,type 段已複數化:story → stories、music → music,見r2.py:16-23)。注意此函式已存在但尚未接上 curated 上傳—— 目前 curated 音訊上傳走curated/{id}.mp3(content_generation.py),遷移 R2 時才改套用。 - 上線追蹤見 issue #369(音訊儲存遷移 R2 + CDN)。
- 修 issue #368(R2
Follow-up / Open Question(不在本 ADR 決策範圍,僅記錄)
UGC 目前付全額 GCS egress(~$0.12/GB)。理論上 UGC 改用 R2 S3 presigned URL(不走 CDN) 也能拿到免費 egress 又保住存取控制(presigned URL 有過期,等同簽章)。代價是多一條 R2 私有路徑 + presign 邏輯,且失去 GCS 既有的 IAM signBlob 整合。記為未來優化,待 UGC egress 量體成長、或 R2 軌道穩定後再評估,不在本決策內。
Links
- 原始決策(Spectra change,archived):
docs/openspec/changes/archive/2026-05-04-media-delivery-cdn-strategy/design.md(決策二) - 相關 spec:
docs/openspec/specs/E15-ugc-playback/ - R2 上線實作:issue #368(delete bug)、issue #369(遷移 R2 + CDN)
- 架構總覽:
docs/architecture/system-overview.md(§2.4 儲存層) - 定價來源:Cloudflare R2 pricing、GCS pricing、Google Cloud CDN pricing