動詞實驗室 Logo動詞實驗室

【Cloudflare Workers 全端架構師之路 05】儲存篇:R2 檔案處理與零流量費架構

發布於 閱讀時間 8 分鐘

01. 前言:S3 帳單裡的隱形殺手

在雲端架構中,儲存成本通常由兩部分組成:儲存費 (Storage)流量費 (Egress)。 大家往往只關注儲存費,卻忽略了流量費才是真正的殺手。

情境分析:你經營一個圖片分享網站,儲存了 1TB 的照片。

  • AWS S3: 當這些照片被使用者下載 10TB 的流量時,AWS 會向你收取約 $900 USD 的流量費 (以 $0.09/GB 計算)。
  • Cloudflare R2: 流量費為 $0

這就是為什麼 R2 被稱為「S3 殺手」。對於高頻寬應用(影音串流、大型檔案下載、模型權重檔),R2 的架構優勢是壓倒性的。

02. 架構比較:R2 vs. AWS S3

特性Cloudflare R2AWS S3 (Standard)
API 相容性S3 Compatible (可直接用 AWS SDK)原生標準
儲存費用$0.015 / GB-month$0.023 / GB-month
出口流量費 (Egress)$0 (Free)~$0.09 / GB (前 100GB 免費)
存取效能自動分散至邊緣節點需搭配 CloudFront 才能達到邊緣效能
整合性原生整合 Workers (Binding)需透過 IAM Role / Access Key

架構師筆記: R2 的另一個隱藏優勢是不需要 CDN 設定。S3 通常需要掛一個 CloudFront 來加速並節省流量(雖然還是要錢)。R2 天生就跑在 Cloudflare 的邊緣網路上,這讓架構圖少了一整層複雜度。

03. 實作挑戰:為什麼不能由 Worker 轉傳檔案?

初學者常犯的錯誤是:寫一個 API 接收 multipart/form-data,Worker 讀取檔案內容,再 put 到 R2。

這樣做有兩個致命傷:

  1. 記憶體限制:Worker 的 Request Body 限制通常是 100MB (付費版)。
  2. 雙重頻寬:檔案先上傳到 Worker,Worker 再上傳到 R2。這浪費了 Worker 的 CPU 時間 (CPU Time),並增加了延遲。

最佳解法:Presigned URLs (預簽名網址) 讓 Worker 扮演「發證機」,使用者拿到「通行證 (Signed URL)」後,直接對 R2 上傳。

04. 環境建置:R2 + AWS SDK

雖然 Cloudflare 提供了原生的 env.BUCKET API,但為了產生 Presigned URL,我們目前仍需使用 AWS SDK for JavaScript v3

步驟 1: 建立 Bucket

npx wrangler r2 bucket create my-assets

wrangler.toml 設定:

[[r2_buckets]]
binding = "MY_BUCKET" # 原生操作用
bucket_name = "my-assets"

[vars]
# SDK 用 (請從 Dashboard 取得並透過 wrangler secret 設定真實 Key)
R2_ACCOUNT_ID = "你的_ACCOUNT_ID"
R2_BUCKET_NAME = "my-assets"

步驟 2: 安裝 SDK

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

05. 深度實作:安全上傳通道

我們將實作一個嚴格限制 檔案類型 (Content-Type)檔案大小 (Content-Length) 的上傳 API。

修改 src/index.ts

import { Hono } from 'hono'
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { v4 as uuidv4 } from 'uuid' // 建議用 UUID 生成檔名

type Bindings = {
  R2_ACCOUNT_ID: string;
  R2_ACCESS_KEY_ID: string;     // Secret
  R2_SECRET_ACCESS_KEY: string; // Secret
  R2_BUCKET_NAME: string;
}

const app = new Hono<{ Bindings: Bindings }>()

const getS3Client = (env: Bindings) => {
  return new S3Client({
    region: 'auto',
    endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
    credentials: {
      accessKeyId: env.R2_ACCESS_KEY_ID,
      secretAccessKey: env.R2_SECRET_ACCESS_KEY,
    },
  });
}

// POST: 取得上傳連結
app.post('/upload/sign', async (c) => {
  const { contentType, contentLength } = await c.req.json();
  
  // 1. 安全性檢查
  // 限制只能上傳圖片
  if (!['image/jpeg', 'image/png', 'image/webp'].includes(contentType)) {
    return c.json({ error: 'Only images are allowed' }, 400);
  }
  // 限制大小 (例如 10MB)
  if (contentLength > 10 * 1024 * 1024) {
    return c.json({ error: 'File too large' }, 400);
  }

  const s3 = getS3Client(c.env);
  const fileKey = `uploads/${uuidv4()}`; // 隨機檔名避免覆蓋

  // 2. 產生簽名
  const command = new PutObjectCommand({
    Bucket: c.env.R2_BUCKET_NAME,
    Key: fileKey,
    ContentType: contentType,
    ContentLength: contentLength, // 強制檢查長度
  });

  // URL 有效期 5 分鐘
  const signedUrl = await getSignedUrl(s3, command, { expiresIn: 300 });

  return c.json({ 
    uploadUrl: signedUrl,
    key: fileKey 
  });
})

// GET: 取得私密檔案下載連結
app.get('/file/:key', async (c) => {
  const key = c.req.param('key');
  const s3 = getS3Client(c.env);

  const command = new GetObjectCommand({
    Bucket: c.env.R2_BUCKET_NAME,
    Key: key,
  });

  // 下載連結 1 小時有效
  const downloadUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });

  return c.json({ url: downloadUrl });
})

export default app

06. 前端如何配合?

拿到 uploadUrl 後,前端不能用 FormData 上傳,而是要直接把 File 物件塞進 Body 進行 PUT 請求。

// 前端範例代碼
const file = document.getElementById('fileInput').files[0];

// 1. 取得簽名
const res = await fetch('/upload/sign', {
  method: 'POST',
  body: JSON.stringify({ 
    contentType: file.type,
    contentLength: file.size 
  })
});
const { uploadUrl } = await res.json();

// 2. 直接上傳 R2 (不經過你的 Worker)
await fetch(uploadUrl, {
  method: 'PUT',
  headers: { 'Content-Type': file.type },
  body: file
});

07. 小結與下一步

我們現在擁有了一個強大的檔案處理系統:

  1. 省錢:流量費 $0。
  2. 效能:使用者直接連線 R2 邊緣節點上傳。
  3. 安全:透過 Worker 嚴格驗證檔案類型與權限。

到目前為止,我們已經具備了 Database (D1)、Storage (R2) 和 Cache (KV)。但在這一切之上,還有一個至關重要的層級我們還沒觸碰:安全性 (Security)

如果有人惡意刷爆你的 API 怎麼辦?如果我們需要身分驗證 (Login) 怎麼辦?

在下一篇 Part 6: 安全篇,我們將探討如何使用 Hono Middleware 實作 JWT 驗證CORS 策略 以及 Rate Limiting (速率限制),為你的 Edge 架構穿上防彈衣。


Ken Huang

關於作者

Ken Huang

擁有超過 9 年軟體開發經驗的資深工程師,現任 APP 工程師,專精於 Android / iOS 雙平台開發。同時持續拓展後端與雲端技術範疇,致力於朝向全端架構師的領域邁進。

具備完整的專案生命週期實戰經驗:

  • 新創開發:主導多個從零到一的新專案規劃、技術選型與開發落地。
  • 系統維護與重構:擁有維護與升級 10 年以上大型歷史專案(Legacy Code)的豐富經驗,擅長進行代碼重構與效能優化。

身為知名 PTT 第三方 APP BePTT 的開發者,致力於將 Clean Architecture、Scrum 敏捷開發與現代化軟體工程原則應用於產品中,打造極致的使用者體驗。

verb.tw 是我紀錄技術軌跡與架構思考的基地,內容涵蓋 App 開發實戰、全端技術整合與軟體工程最佳實踐。

Senior App EngineerAndroid/iOS ExpertBePTT CreatorSystem Architecture