R2 物件儲存:S3 相容、零 egress,存大檔不再被流量費坑 | Cloudflare 完整教學

2026/08/18
R2 物件儲存:S3 相容、零 egress,存大檔不再被流量費坑 | Cloudflare 完整教學

上一篇《KV 進階:一致性與快取》收尾時我們留了一個問題:KV 單值上限只有 25 MiB,而且它根本不是為「大型二進位檔案」設計的。那圖片、影片、使用者上傳的文件這些又大又重的東西該存哪?答案就是 Cloudflare R2——一個 S3 相容(S3-compatible)、而且零 egress 費用的物件儲存(object storage)。這篇我們從什麼是 R2、怎麼建 bucket + binding、get / put / list / delete 到 multipart 大檔上傳、httpMetadata、presigned URL 直傳、公開存取與自訂網域,一路帶你把大檔搬上 R2,不再被流量費坑。

前言

Cloudflare R2 是 Cloudflare 提供的物件儲存(object storage)服務——你可以把它想成一個放在雲端、S3 相容的巨大檔案倉庫,專門用來存放大型二進位檔案(binary blob):圖片、影片、音訊、PDF、備份、機器學習資料集等等。它跟你熟悉的 AWS S3 幾乎一樣好用(甚至可以直接沿用 S3 的 SDK),但有一個殺手級差異:傳出資料完全不收費(zero egress)

打個比方:如果說上兩篇講的 KV遍佈全球每家便利商店的小型販賣機(放的是設定、session 這類小東西,拿取極快),那 R2 就是一座大型物流倉庫——它能存放又大又重的貨物(單一物件最大 5 TB),而且最特別的是:從這座倉庫「出貨」(把檔案傳給使用者)完全免運費。傳統雲端倉庫(S3)是「進倉便宜、出貨昂貴」,每傳出 1 GB 就跟你收約 $0.09;R2 則把「出貨費」直接歸零,這對圖片站、影片平台、檔案下載這類「大量傳出」的場景,能省下驚人的流量費。

這篇我們聚焦 R2 物件儲存本身,承接上兩篇的 KV,把 Cloudflare 的儲存版圖補齊。讀完你會掌握:

  • R2 是什麼、為何是 S3 相容——物件儲存的心智模型、可直接用 @aws-sdk/client-s3 操作、零 egress 為何是最大賣點
  • 建立 bucket 與 binding——wrangler r2 bucket createwrangler.jsoncr2_buckets 設定
  • 核心 API——put / get / list / deleteR2ObjecthttpMetadatawriteHttpMetadata、multipart 分段上傳大檔
  • 對外服務檔案——presigned URL 讓客戶端直傳、公開存取(r2.dev vs 自訂網域)
  • 與 KV 的差異與選型——什麼放 R2、什麼放 KV、兩者如何混用

核心概念

什麼是物件儲存?R2 的資料模型

**物件儲存(object storage)**跟你熟悉的檔案系統(資料夾/檔案)不太一樣。它的核心是三個東西:

  • Bucket(儲存桶):一個命名的容器,你所有物件都放在某個 bucket 裡。
  • Key(鍵):物件的唯一識別字串,例如 photos/2026/avatar.jpg。注意——雖然 key 裡有 / 看起來像資料夾,但 R2 其實沒有真正的資料夾,/ 只是 key 字串的一部分,「資料夾」是靠 key 前綴(prefix)模擬出來的。
  • Object(物件):實際存放的資料本體(bytes)+ 附帶的 metadata(如 content-type、自訂標籤)。

所以 R2 的心智模型很單純:一個巨大的 key → 檔案 對應表。你用 key 存(put)、用 key 取(get)、用 key 刪(delete)、用前綴列舉(list)。這跟 KV 的鍵值模型很像,差別在於 R2 的「值」是可以到 5 TB 的大型二進位檔案,而且強一致(strong consistency)——寫入後立刻就能讀到,沒有 KV 那種最終一致的傳播延遲。

為什麼「S3 相容」很重要

R2 的 API 相容 Amazon S3,這是刻意的設計。S3 是業界事實標準,幾乎所有雲端工具、備份軟體、SDK 都支援 S3 協定。R2 相容 S3 帶來兩個好處:

  1. 零學習成本遷移:你原本用 @aws-sdk/client-s3 操作 S3 的程式碼,幾乎只要改一下 endpointregion: "auto",就能直接指向 R2。
  2. 生態系直接可用:任何支援 S3 的工具(rclone、備份軟體、資料湖引擎)都能把 R2 當成 S3 用。

在 Cloudflare Workers 裡,你有兩種方式操作 R2:

  • Workers Binding(推薦、最省事):透過 wrangler.jsonc 綁定 bucket,程式裡直接用 env.MY_BUCKET.put(...),不需要金鑰,延遲最低。
  • S3 相容 API:用 @aws-sdk/client-s3,適合需要 presigned URL、CORS 設定、生命週期規則,或程式碼要同時支援 S3 與 R2 的場景。

零 egress:R2 最大的賣點

再強調一次這個核心競爭力。傳統物件儲存的計費有三塊:儲存費操作費egress(資料傳出)費。前兩者各家差不多,真正咬人的是 egress——你越受歡迎、越多人下載你的檔案,egress 帳單就越恐怖。

費用項目Cloudflare R2AWS S3
儲存費(Standard)$0.015 / GB-月$0.023 / GB-月
Class A 操作(寫入類,每百萬次)$4.50$5.00
Class B 操作(讀取類,每百萬次)$0.36$0.40
Egress(資料傳出)$0.00$0.09 / GB

具體感受一下:一個每月傳出 10 TB 的媒體站,egress 費用在 R2 是 $0,在 S3 約 $900+——一年下來省超過一萬美元。這就是為什麼 R2 特別適合圖片、影片、Podcast、檔案下載、CDN 起源這類高頻傳出的場景。

關鍵術語速覽

術語一句話定義
Bucket存放物件的命名容器,你的檔案都在某個 bucket 裡
Key物件的唯一識別字串(如 img/a.jpg),/ 只是字串的一部分
R2Objectget / head 回傳的物件,含 bodysizehttpEtaghttpMetadata
httpMetadata綁在物件上的 HTTP 標頭資訊(content-type、cache-control 等)
egress資料傳出費用——R2 收 $0,這是它最大賣點
presigned URL帶臨時簽章、有時效的網址,讓客戶端直接上傳/下載,不經過 Worker
multipart upload把大檔切成多段平行/續傳上傳的機制,適合 > 100 MB 的檔案

實作範例

我們用一組可執行的 TypeScript 範例,把上面每個概念落地。

1. 建立 bucket 與 binding

先用 Wrangler CLI 建一個 bucket(可指定位置提示,靠近你的使用者):

# 建立 bucket(location 可選:apac 亞太、wnam 西北美、weur 西歐 等)
wrangler r2 bucket create my-app-files --location=apac

# 列出所有 bucket、查看詳情
wrangler r2 bucket list
wrangler r2 bucket info my-app-files

接著在 wrangler.jsonc 裡把 bucket 綁定到你的 Worker,程式裡才能用 env.MY_BUCKET 存取:

{
  "name": "my-app",
  "main": "src/index.ts",
  "compatibility_date": "2024-09-23",
  "r2_buckets": [
    {
      "binding": "MY_BUCKET",       // 程式中用 env.MY_BUCKET 存取
      "bucket_name": "my-app-files" // 對應剛剛建立的 bucket 名稱
    }
  ]
}

再加上 TypeScript 型別定義,讓 env.MY_BUCKET 有正確的型別:

// env.d.ts
interface Env {
  MY_BUCKET: R2Bucket;
}

有了 binding,你就能在 Worker 裡直接操作 R2,完全不需要金鑰——這是 binding 相對 S3 SDK 最大的便利。

2. put / get / delete:基本存取

先看最基本的上傳、下載、刪除。上傳時務必設定 httpMetadata.contentType,否則瀏覽器不知道這是圖片還是別的,可能無法正確顯示:

// 上傳:一定要設 content-type,否則瀏覽器不知道怎麼處理這個檔案
await env.MY_BUCKET.put("photos/avatar.jpg", imageBuffer, {
  httpMetadata: {
    contentType: "image/jpeg",
    cacheControl: "public, max-age=31536000, immutable", // 讓 CDN/瀏覽器長快取
  },
  customMetadata: {
    userId: "user123",               // 自訂標籤,可存業務資料
    uploadedAt: new Date().toISOString(),
  },
});

// 刪除單一物件(即使 key 不存在也不報錯)
await env.MY_BUCKET.delete("photos/old-avatar.jpg");

// 批次刪除(一次最多 1,000 個)
await env.MY_BUCKET.delete(["cache/a.html", "cache/b.html"]);

下載時,get 回傳的是一個 R2Object(找不到時回 null)。這裡有個關鍵技巧:用 writeHttpMetadata 把物件的 HTTP metadata 自動寫回回應標頭,瀏覽器就能拿到正確的 content-type、cache-control:

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const object = await env.MY_BUCKET.get("photos/avatar.jpg");

    // get 找不到物件會回傳 null,一定要處理
    if (object === null) {
      return new Response("找不到物件", { status: 404 });
    }

    const headers = new Headers();
    object.writeHttpMetadata(headers);        // 自動寫回 content-type、cache-control 等
    headers.set("etag", object.httpEtag);     // 注意:用 httpEtag(帶引號),不是 etag

    // object.body 是可串流的 ReadableStream,直接當回應 body,不緩衝進記憶體
    return new Response(object.body, { headers });
  },
} satisfies ExportedHandler<Env>;

注意兩個常見坑:一是用 object.httpEtag(符合 HTTP 規範、含引號)而非 object.etag(無引號);二是 object.body 是一個 ReadableStream,直接丟給 Response 就會串流輸出,不會把整個檔案讀進記憶體——這對大檔非常重要。

3. list:列舉物件與模擬資料夾

list 用前綴(prefix)篩選,並用 delimiter 模擬資料夾層級。分頁務必用 truncated 屬性判斷,不要拿物件數量跟 limit 比:

// 分頁列出某前綴下的所有物件(正確做法:用 truncated 判斷是否還有下一頁)
async function listAll(env: Env, prefix: string): Promise<R2Object[]> {
  const all: R2Object[] = [];
  let cursor: string | undefined;

  while (true) {
    const page = await env.MY_BUCKET.list({ prefix, limit: 1000, cursor });
    all.push(...page.objects);
    if (!page.truncated) break;   // 沒有下一頁就結束
    cursor = page.cursor;         // 帶著 cursor 抓下一頁
  }
  return all;
}

// 用 delimiter 模擬「資料夾」:列出 photos/ 底下的「子資料夾」
const result = await env.MY_BUCKET.list({ prefix: "photos/", delimiter: "/" });
console.log(result.delimitedPrefixes); // 例如 ["photos/2025/", "photos/2026/"]

4. multipart:大型檔案分段上傳

當檔案很大(官方建議 > 100 MB)時,一次 put 整個塞進去不理想——網路一斷就得從頭再來。Multipart upload 把檔案切成多段,可平行上傳、可續傳,任何一段失敗只需重傳那一段:

async function uploadLargeFile(env: Env, key: string, file: ArrayBuffer) {
  const PART_SIZE = 5 * 1024 * 1024; // 每段 5 MB(最小值,最後一段除外)

  // 1. 建立 multipart 上傳工作
  const upload = await env.MY_BUCKET.createMultipartUpload(key, {
    httpMetadata: { contentType: "video/mp4" },
  });

  const parts: R2UploadedPart[] = [];
  const partCount = Math.ceil(file.byteLength / PART_SIZE);

  try {
    // 2. 逐段上傳(part number 從 1 開始,不是 0)
    for (let i = 0; i < partCount; i++) {
      const start = i * PART_SIZE;
      const chunk = file.slice(start, start + PART_SIZE);
      const part = await upload.uploadPart(i + 1, chunk);
      parts.push(part);
    }

    // 3. 全部上傳完,呼叫 complete 合併成一個物件
    const object = await upload.complete(parts);
    return object;
  } catch (error) {
    // 4. 中途失敗一定要 abort,否則未完成的分段會佔空間、產生費用
    await upload.abort();
    throw error;
  }
}

Multipart 限制:單一物件最大 5 TB、最多 10,000 段、最小段 5 MB(最後一段除外)、part number 從 1 起算、未完成的上傳 7 天後自動中止。

5. presigned URL:讓客戶端直接上傳,不經過 Worker

上傳大檔時,讓檔案「流經」你的 Worker 是浪費——正確做法是用 presigned URL 讓前端直接 PUT 到 R2。Worker 只負責「發放上傳許可證」。這需要用到 S3 SDK:

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== "POST") {
      return new Response("Method Not Allowed", { status: 405 });
    }

    const { filename, contentType } = await request.json<{
      filename: string;
      contentType: string;
    }>();

    // 安全驗證:只允許特定檔案類型,避免被濫用
    const allowed = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
    if (!allowed.includes(contentType)) {
      return new Response("不支援的檔案類型", { status: 400 });
    }

    // R2 的 S3 用戶端:region 必填 "auto",endpoint 指向你的帳號
    const s3 = new S3Client({
      region: "auto", // R2 必填,固定填 "auto"
      endpoint: `https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com`,
      credentials: {
        accessKeyId: env.R2_ACCESS_KEY_ID,
        secretAccessKey: env.R2_SECRET_ACCESS_KEY,
      },
    });

    // 用隨機 key 避免覆蓋、避免路徑穿越
    const key = `uploads/${crypto.randomUUID()}/${filename}`;

    // 產生上傳 presigned URL(效期 1 小時,最長可到 7 天)
    const uploadUrl = await getSignedUrl(
      s3,
      new PutObjectCommand({ Bucket: "my-app-files", Key: key, ContentType: contentType }),
      { expiresIn: 3600 },
    );

    return Response.json({ uploadUrl, key });
  },
} satisfies ExportedHandler<Env>;

前端拿到 uploadUrl 後,直接 PUT 檔案到 R2,完全不經過 Worker:

// 前端:先跟 Worker 換取上傳 URL,再直接 PUT 到 R2
async function uploadFile(file: File) {
  const { uploadUrl, key } = await fetch("/api/upload-url", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ filename: file.name, contentType: file.type }),
  }).then((r) => r.json());

  // 這一步的大流量直接在瀏覽器與 R2 之間,不佔用 Worker 資源
  const res = await fetch(uploadUrl, {
    method: "PUT",
    headers: { "content-type": file.type },
    body: file,
  });

  if (!res.ok) throw new Error(`上傳失敗:${res.status}`);
  console.log(`上傳成功,key:${key}`);
}

6. 公開存取:r2.dev vs 自訂網域

R2 bucket 預設是**私有(private)**的。要讓檔案能被公開讀取,有兩種方式:

# 方式二:自訂網域(生產環境推薦)——把 files.example.com 綁到 bucket
wrangler r2 bucket domain add my-app-files --domain=files.example.com
方式URL 格式用途
r2.dev 開發用 URLhttps://pub-{hashId}.r2.dev/{key}僅供開發測試(有速率限制)
自訂網域(推薦)https://files.example.com/{key}生產環境,可整合 WAF / Access / 快取規則

生產環境請用自訂網域:r2.dev URL 有速率限制、不適合正式流量;自訂網域則能掛上 Cloudflare 的 WAF 防護、Access 身份驗證、Cache Rules 快取規則,把 R2 變成一個完整的、免 egress 費的 CDN 起源。

常見錯誤與最佳實踐

坑一:把 R2 當資料庫來查詢。

R2 只能用 key 或前綴(prefix)查詢,沒有 WHERE、沒有 JOIN、沒有「依上傳時間排序」這種查詢能力。想「找出某使用者上傳的所有 2026 年的 PDF」這種需求,別想在 R2 裡查——正確做法是把檔案本體放 R2、把可查詢的 metadata(擁有者、類型、時間、R2 的 key)放 D1 或 KV 做索引。查詢走資料庫,拿到 key 後再去 R2 取檔案。這是 R2 最常被誤用的地方。

坑二:上傳忘了設 content-type

// ❌ 錯誤:沒設 content-type,瀏覽器可能把圖片當成純文字下載
await env.MY_BUCKET.put("photos/a.jpg", buffer);

// ✅ 正確:一定要在 httpMetadata 設 contentType
await env.MY_BUCKET.put("photos/a.jpg", buffer, {
  httpMetadata: { contentType: "image/jpeg" },
});

沒設 content-type 是新手最常見的坑——檔案存進去了,但瀏覽器打開卻是亂碼或強制下載,就是這個原因。

坑三:公開權限設太寬 + 路徑穿越攻擊。

// ❌ 危險:直接拿使用者輸入當 key,可能被 "../../../secrets" 攻擊
const key = new URL(request.url).pathname.slice(1);
await env.MY_BUCKET.get(key);

// ✅ 安全:驗證 key 格式,擋掉路徑穿越
function isValidKey(key: string): boolean {
  return Boolean(key) && !key.includes("..") && !key.startsWith("/") && !key.includes("\0");
}

另外,若你已經用自訂網域 + WAF/Access 保護檔案,記得關掉 r2.dev 公開 URL,否則使用者可以繞過你的安全機制直接存取。

坑四:object.etagobject.httpEtag 用錯、串流長度未知。

object.httpEtag(含引號、符合 HTTP 規範)寫回標頭,不要用 object.etag。另外,直接把一個「長度未知的串流」丟給 put 可能被靜默截斷;若來源是遠端 fetch,先 await response.arrayBuffer() 確保完整性再上傳。

KV vs R2 選型:一張表劃清邊界。

你的需求該用什麼為什麼
小型鍵值(設定、session、flag),讀多寫少KV最終一致、熱讀取 < 5ms,單值上限 25 MiB
大型二進位檔案(圖片、影片、備份、資料集)R2單物件最大 5 TB,強一致,零 egress
使用者直接上傳大檔R2 + presigned URL客戶端直傳,不佔用 Worker 資源
需要 SQL 查詢、JOIN、依欄位篩選D1R2/KV 都只能用 key/prefix 查
「檔案本體 + 可查詢 metadata」R2 存檔 + KV/D1 存索引各取所長,最常見的實務組合

最佳實踐小結:記住 R2 的心法——檔案本體放 R2、可查詢的 metadata 放資料庫(別把 R2 當 DB 查);上傳一律設 content-type;下載用 writeHttpMetadata + httpEtag + 串流 body;大檔用 multipart、客戶端上傳用 presigned URL;生產環境用自訂網域而非 r2.dev。守住這幾條,R2 就是你存放大型檔案、又不想被流量費坑的最佳選擇。

小結

上一篇《KV 進階:一致性與快取》,我們把 KV 的最終一致性、cacheTtl、寫入速率限制徹底拆開,讓你把邊緣鍵值儲存用得又快又對;這一篇,我們補上 Cloudflare 儲存版圖的另一塊——R2 物件儲存:

  • R2 是什麼——S3 相容的物件儲存,單一物件最大 5 TB、強一致,核心賣點是零 egress 費用(資料傳出 $0),對圖片/影片/下載這類高頻傳出場景省下大筆流量費。
  • 建立與存取——wrangler r2 bucket create 建桶、wrangler.jsoncr2_buckets 綁定,程式裡用 env.BUCKET.put/get/list/delete,不需金鑰;下載用 writeHttpMetadata + httpEtag + 串流 body
  • 進階能力——大檔用 multipart 分段上傳(切段、可續傳、失敗要 abort);讓客戶端直傳用 presigned URL(Worker 只發許可證,大流量繞過 Worker);公開存取用自訂網域而非 r2.dev。
  • 選型——小鍵值、讀多寫少 → KV;大型檔案 → R2;要 SQL 查詢 → D1。最常見的實務組合是**「檔案放 R2、metadata 放 KV/D1」**,別把 R2 當資料庫查。

到這裡,Cloudflare 的兩大「非結構化」儲存——KV(鍵值)R2(物件)——就都講完了。但很多應用真正需要的是關聯式資料:使用者表、訂單表、商品表,要能用 SQL 做 JOIN、WHERE、交易。這種結構化資料,KV 和 R2 都不擅長。下一篇《D1 入門:Serverless SQLite》,我們就進入 Cloudflare 的關聯式資料庫——一個跑在邊緣、用 SQL 查詢的 Serverless SQLite,看看它如何補上儲存版圖最後一塊拼圖。

想先查閱官方對 R2 的完整說明,可以隨時參考 Cloudflare R2 官方文件。物件儲存這一課已就緒,我們下一篇《D1 入門:Serverless SQLite》見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →