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 create、wrangler.jsonc的r2_buckets設定 - 核心 API——
put/get/list/delete、R2Object、httpMetadata與writeHttpMetadata、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 帶來兩個好處:
- 零學習成本遷移:你原本用
@aws-sdk/client-s3操作 S3 的程式碼,幾乎只要改一下endpoint和region: "auto",就能直接指向 R2。 - 生態系直接可用:任何支援 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 R2 | AWS 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),/ 只是字串的一部分 |
R2Object | get / head 回傳的物件,含 body、size、httpEtag、httpMetadata 等 |
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 開發用 URL | https://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.etag 與 object.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、依欄位篩選 | D1 | R2/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.jsonc的r2_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》見。