beio Logobeio

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

發布於

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

熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。

最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。

這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。

Android APP DevelopmentiOS APP DevelopmentBePTT CreatorFull Stack Learner