跳到內容

R2:零 egress 費用的物件儲存

查證日期
驗證環境wrangler@4.114.0·workerd@1.20260722.1·compatibility_date: 2026-07-24
對應範例examples/ch12-r2

R2 是 S3 相容的物件儲存,最大賣點是流出流量完全免費。這件事本身很好懂,所以這篇的重點放在三個沒那麼明顯、但會直接影響你架構和帳單的地方:

  1. ListObjects 是 Class A 操作,價格是 GetObject 的 12.5 倍。 一個天真的「列出所有物件再逐一處理」迴圈是 R2 上最典型的燒錢模式。
  2. 條件請求失敗時,你分不出 304 和 412。 實測兩者都回傳一個「沒有 body 的 R2Object」,完全一樣。
  3. Multipart 的大小規則在 complete() 才驗證。 你可以成功上傳 9,999 個大小不對的 part,然後在最後一步整批失敗。

這是 R2 API 最容易搞混的地方。實測兩者的差別:

Terminal window
$ curl -s localhost:8787/shapes
{
"getKeys": ["body","bodyUsed","arrayBuffer","bytes","text","json","blob","constructor"],
"hasBody": true,
"headHasBody": false,
"object": {
"key": "reports/2026-01.csv",
"size": 15,
"etag": "8bf9c7df3df8a11296ccc7d90a22240f",
"httpEtag": "\"8bf9c7df3df8a11296ccc7d90a22240f\"",
"storageClass": "",
"checksums": ["sha512","sha384","sha256","sha1","md5"],
"httpMetadata": { "contentType": "text/csv" },
"customMetadata": { "tenant": "t1" }
}
}
  • head() 回傳 R2Object —— 只有中繼資料,沒有 body。
  • get() 回傳 R2ObjectBody —— 多了 bodytext()json()arrayBuffer()blob(),還有一個文件沒列的 bytes()

etaghttpEtag 的差別要記住:前者是裸的,後者帶引號,可以直接塞進 header。

⚠️ 注意 storageClass 在本機是空字串 —— 見下面的本機差異一節。

🔴 條件請求:失敗時無法分辨 304 和 412

Section titled “🔴 條件請求:失敗時無法分辨 304 和 412”

正確的寫法很優雅 —— 直接把 request 的 headers 丟進去:

const obj = await bucket.get(key, { onlyIf: request.headers });
if (obj === null) return new Response("Not Found", { status: 404 });
if (!("body" in obj)) return new Response(null, { status: 304 });
return new Response(obj.body, { headers: ... });

R2 會讀 If-MatchIf-None-MatchIf-Modified-SinceIf-Unmodified-Since(除了 If-Range 之外都支援)。

但實測揭露一個問題:

Terminal window
$ curl -s localhost:8787/conditional
{
"etag": "\"8bf9c7df3df8a11296ccc7d90a22240f\"",
"ifNoneMatch_matching": { "isNull": false, "hasBody": false }, 該回 304
"ifNoneMatch_notMatching": { "isNull": false, "hasBody": true }, 該回 200
"ifMatch_failing": { "isNull": false, "hasBody": false }, 該回 412
"put_failingPrecondition": "returned null"
}

If-None-Match 命中(語意是 304)和 If-Match 失敗(語意是 412)回傳的東西完全一樣 —— 都是 isNull: false, hasBody: false

也就是說,binding 層級沒有辦法區分「內容沒變,回 304」和「前提條件不符,回 412」。S3 API 會給正確的狀態碼,binding 不會。

如果你的 API 要正確實作條件請求語意,就得自己判斷是哪一類 header

const obj = await bucket.get(key, { onlyIf: request.headers });
if (obj === null) return new Response(null, { status: 404 });
if (!("body" in obj)) {
// Cannot tell 304 from 412 from the binding — decide from the request.
const isCacheValidation =
request.headers.has("if-none-match") || request.headers.has("if-modified-since");
return new Response(null, { status: isCacheValidation ? 304 : 412 });
}

順帶一提,put() 的前提條件失敗時回傳的是 null(不是 body-less 物件),而且物件不會被寫入 —— 這是實作 optimistic concurrency 的正確工具。

三種形式,實測都可用:

Terminal window
$ curl -s localhost:8787/range
{
"full": "id,clicks\n1,10\n",
"first5": "id,cl", { offset: 0, length: 5 }
"suffix": "1,10\n", { suffix: 5 }
"rangeMeta": { "offset": 0, "length": 5 }
}

一樣可以直接把 headers 丟進去:range: request.headers。回傳物件的 .range 會告訴你實際服務了哪一段。

R2 沒有資料夾,只有 key。但 delimiter 可以模擬出來:

Terminal window
$ curl -s localhost:8787/list
{
"flat": {
"keys": ["exports/dump.json","logo.png","reports/2026-01.csv","reports/2026-02.csv"],
"truncated": false
},
"foldered": {
"keys": ["logo.png"],
"delimitedPrefixes": ["exports/", "reports/"]
},
"prefixed": [
{ "key": "reports/2026-01.csv", "custom": { "tenant": "t1" } },
{ "key": "reports/2026-02.csv", "custom": {} }
]
}

加了 delimiter: "/" 之後:objects 只剩頂層的檔案,子路徑被摺疊進 delimitedPrefixes。這是做檔案瀏覽器 UI 的正確方式 —— 而且比列出全部再自己分組便宜非常多(見下面的計費)。

include: ["customMetadata"] 讓列表直接帶回自訂中繼資料。

⚠️ compat date 陷阱compatibility_date 必須是 2022-08-04 或更新,否則 include 會被忽略,一律當成 ['httpMetadata', 'customMetadata']

分頁一律看 truncatedcursor 只在 truncated: true 時存在)。單次最多 1000 筆。

Terminal window
$ curl -s localhost:8787/checksum
{
"correct": { "ok": { "key": "ck/ok", "checksums": { "md5": "5d41402a...", "sha256": "2cf24dba..." }, ... } },
"wrong": { "threw": "Error: put: The SHA-256 checksum you specified did not match what we received. ..." },
"twoHashes": { "threw": "TypeError: You cannot specify multiple hashing algorithms." }
}

支援 md5 / sha1 / sha256 / sha384 / sha512一次只能指定一個。R2 收到後會驗證,不符就拒絕。

非 multipart 的上傳一定會有 MD5(就是 etag)。

🔴 Multipart:驗證發生在 complete(),不是 uploadPart()

Section titled “🔴 Multipart:驗證發生在 complete(),不是 uploadPart()”

規則:除了最後一個 part,其餘 part 必須是同樣大小,且最小 5 MiB。這比 S3 嚴格。

實測三種情況:

Terminal window
$ curl -s localhost:8787/multipart
{
"evenComplete": {
"ok": { "key": "big/even", "size": 13631488, "etag": "5f853ab4ba46f5da5f04e29de1cbc1be-3" }
},
"allPartsUploadedOk": [1, 2, 3],
"unevenComplete": {
"threw": "Error: completeMultipartUpload: Your proposed upload is smaller than the minimum allowed object size. (10011)"
},
"singleSmallPart": { "ok": { "key": "big/tiny", "size": 1024 } }
}

三個結論:

① 合法形狀(6 MiB / 6 MiB / 1 MiB)成功。 最後一個 part 可以比較小。注意 etag 是 ...-3 —— multipart 物件的 etag 帶著 part 數量後綴,和單次上傳的 MD5 不同。

② 中間放一個小 part:三個 part 全部上傳成功([1,2,3]),在 complete() 才炸。

這是這一節的重點。你可以花好幾分鐘上傳 9,999 個 part,全部回報成功,然後在最後一步拿到 10011任何 multipart 的實作都必須在切分時就保證大小一致,不能指望上傳過程給你訊號。

③ 只有一個小 part(1 KiB)反而成功。 5 MiB 的最小值不適用於最後一個(也是唯一一個)part。

其他限制:單一 part 最大 5 GiB、最多 10,000 個 part、物件最大 4.995 TiB。未完成的 multipart 預設 7 天後自動 abort,但在那之前會佔用儲存空間並計費 —— 失敗路徑一定要呼叫 abort()

這是 R2 最重要的成本知識。

StandardInfrequent Access
儲存$0.015 / GB-月$0.010 / GB-月
Class A$4.50 / 百萬$9.00 / 百萬
Class B$0.36 / 百萬$0.90 / 百萬
取回費$0.01 / GB
Egress免費免費

哪些操作屬於哪一類,比費率本身更重要:

Class A(貴 12.5 倍)ListObjectsPutObjectCopyObjectCreateMultipartUploadUploadPartCompleteMultipartUploadListPartsListMultipartUploadsListBucketsPutBucket*

Class B(便宜)GetObjectHeadObjectHeadBucketGetBucket*

免費DeleteObjectDeleteBucketAbortMultipartUpload

三個直接的推論:

① 列出物件比讀取物件貴 12.5 倍。

// ❌ Two Class A ops per page, plus a Class B per object.
for await (const page of listAll(bucket)) {
for (const o of page.objects) await bucket.get(o.key);
}
// ✅ Keep the key list in D1 and go straight to get().
const keys = await db.select().from(exports).where(...);

把「有哪些物件」這件事存在 D1 或 KV,R2 只負責存位元組。 這是 R2 架構的基本模式。

② 一個 10,000 part 的上傳 = 10,002 次 Class A。 約 $0.045。單次大檔沒問題,但如果你的系統每天做幾千次大檔上傳,part 大小的選擇就是成本決策:part 越大、Class A 越少。

③ 刪除免費。 所以清理不用心疼,倒是 list() 找出要刪什麼要小心。

免費額度(僅 Standard):10 GB-月儲存、100 萬次 Class A、1,000 萬次 Class B、無限免費 egress

  • 最短儲存 30 天 —— 就算你 3 天就刪掉,還是收 30 天。
  • 取回費 $0.01/GB,而且 Class A/B 都貴一倍。

所以 IA 只適合確定很少讀、而且會放很久的資料。存進去 3 天就刪反而更貴。

⚠️ 而且:用 lifecycle 規則把物件從 IA 轉回 Standard 是做不到的(只能用 CopyObject)。

項目
物件大小5 TiB(實際 4.995 TiB)
單次上傳5 GiB(實際 4.995 GiB)
Key 長度1,024 bytes
自訂中繼資料8,192 bytes(比 KV 的 1024 寬鬆很多)
同一個 key 每秒寫入1 次(超過回 429)
delete() 批次1,000 個 key
list() 單次1,000 筆
Bucket 數 / 帳號1,000,000
自訂網域 / bucket100
事件通知規則 / bucket100

注意那個每個 key 每秒 1 次寫入 —— 和 KV 一樣的限制(第 8 篇)。R2 也不能拿來做熱點計數器。

官方原文,三段都值得引用:

“Public access through r2.dev subdomains is rate-limited and should only be used for development purposes.”

Avoid creating a CNAME record pointing to the r2.dev subdomain. This is an unsupported access path, and we cannot guarantee consistent reliability or performance.”

Disable public access to your r2.dev subdomain when using products like WAF or Cloudflare Access. If you do not disable public access, your bucket will remain publicly available through your r2.dev subdomain.”

最後那一段是安全問題:你在自訂網域前面架了 WAF 和 Access,但 r2.dev 那條路徑還開著,等於完全繞過。

production 一律用自訂網域。順帶解鎖 Cloudflare Cache(含 Smart Tiered Cache)、Zero Trust Access、WAF 規則、Bot Management、TLS 設定。

Presigned URL 走 S3 API:支援 GET / HEAD / PUT / DELETE(不支援 POST 表單上傳),有效期 1 秒到 7 天。⚠️ presigned URL 只能配 S3 API 網域,不能用在自訂網域上。

R2 在 wrangler dev 是本機模擬的,資料在 .wrangler/state/v3/r2。用 wrangler r2 object put <BUCKET>/<KEY> --file=<PATH> --local 灌種子資料。

但有一個明確的差異:

Terminal window
$ curl -s localhost:8787/storageclass
{ "standard": "", "infrequent": "" }

storageClass 在本機一律是空字串,不管你傳 "Standard" 還是 "InfrequentAccess"。儲存級別沒有被模擬。

本機production
put / get / head / delete / list
條件請求
Range
Checksum 驗證
Multipart 與大小規則
storageClass❌ 空字串
事件通知 → Queues❌ 不會觸發
每個 key 每秒 1 寫❌ 不擋
Class A/B 計費

要接遠端真實 bucket 就在 binding 上設 "remote": true(第 2 篇;Wrangler ≥ 4.37.0,舊名 experimental_remote 已改名)。注意會動到真實資料、會計費、會有網路延遲。

GA,設定走 CLI:

Terminal window
npx wrangler r2 bucket notification create <BUCKET> \
--event-type object-create --queue <QUEUE> --prefix "reports/"

事件類型只有兩種:

  • object-create —— PutObjectCopyObjectCompleteMultipartUpload
  • object-delete —— DeleteObjectLifecycleDeletion

訊息長這樣:

{
"account": "3f4b7e3d...",
"action": "CopyObject",
"bucket": "my-bucket",
"object": { "key": "my-new-object", "size": 65536, "eTag": "c846ff7a..." },
"eventTime": "2024-05-24T19:36:44.379Z",
"copySource": { "bucket": "my-bucket", "object": "my-original-object" }
}

⚠️ delete 事件沒有 object.sizeobject.eTag 每個 bucket 最多 100 條規則,而 Queues 每秒 5,000 則的上限(第 19 篇)在這裡也適用。

兩個都還在 beta

  • R2 Data Catalog(Apache Iceberg REST catalog):public beta。價格已公布,但官方說「billing is not yet enabled,會提前 30 天通知」。
  • R2 SQL:open beta,目前不計費。

第 23 篇會用到它們,屆時會明確標示 beta。現在不要把它們寫進 production 架構圖。


完整程式碼:examples/ch12-r2/

Terminal window
cd examples/ch12-r2 && npm install && npm run dev
Terminal window
B=localhost:8787
curl -s "$B/seed"
curl -s "$B/shapes" # R2Object vs R2ObjectBody
curl -s "$B/conditional" # 304 和 412 分不出來
curl -s "$B/range" # offset/length 與 suffix
curl -s "$B/list" # delimiter -> delimitedPrefixes
curl -s "$B/checksum" # 驗證失敗與「只能一個 hash」
curl -s "$B/multipart" # 大小規則在 complete() 才驗
curl -s "$B/storageclass" # 本機一律空字串
curl -s "$B/delete" # 刪不存在的 key 也沒事

R2 在 LinkForge 存三類東西,各有不同的 key 設計。

exports/{tenantId}/{yyyy-mm}/clicks.csv 週報 CSV(第 21 篇 Workflow 產生)
og/{tenantId}/{slug}.png OG 預覽圖(第 30 篇 Browser Run 產生)
qr/{tenantId}/{slug}.svg QR code

① key 一律以 {tenantId} 開頭。

因為 list({ prefix }) 是唯一能限縮列表範圍的機制。租戶 id 放最前面,「列出這個租戶的匯出檔」才會是一次 prefix 查詢而不是全 bucket 掃描。這同時是第 41 篇多租戶隔離的一部分 —— 但不是隔離的唯一防線(prefix 只是慣例,不是權限邊界,真正的檢查在應用層)。

② 檔案清單存在 D1,不靠 list()

CREATE TABLE exports (
id INTEGER PRIMARY KEY,
tenant_id TEXT NOT NULL,
r2_key TEXT NOT NULL,
size INTEGER NOT NULL,
created_at INTEGER NOT NULL
) STRICT;

Dashboard 的「我的匯出檔」列表完全不呼叫 R2。理由就是 Class A 的價差 —— D1 查一次幾乎不用錢,list() 是 $4.50/百萬。R2 只在使用者真的按下載時才被讀取(Class B,$0.36/百萬)。

③ 下載走自訂網域 + presigned 或 Worker 代理,絕不用 r2.dev

匯出檔含租戶資料,公開 bucket 不是選項。實作是 Worker 驗證 JWT 之後串流回去:

const obj = await c.env.EXPORTS.get(key, { onlyIf: c.req.raw.headers });
if (!obj) return c.notFound();
if (!("body" in obj)) return new Response(null, { status: 304 });
const headers = new Headers();
obj.writeHttpMetadata(headers); // contentType, cacheControl, ...
headers.set("etag", obj.httpEtag); // note: the quoted form
return new Response(obj.body, { headers });

writeHttpMetadata() 把上傳時存的 HTTP 中繼資料直接寫進 response headers —— 不用自己記得有哪些欄位。

④ OG 圖用 Standard,匯出檔 90 天後轉 IA。

OG 圖每次社群分享都會被讀,屬於熱資料。匯出檔通常下載一次就不再碰,適合用 lifecycle 規則轉 IA —— 但要注意 30 天最短儲存與轉不回來的限制,所以門檻設 90 天而不是 30 天。

以 1 萬個連結、每月 1,000 次匯出、每月 10 萬次 OG 圖讀取計:

項目類別成本
OG 圖寫入1 萬Class A$0.045
OG 圖讀取10 萬Class B$0.036
匯出檔寫入1,000Class A$0.005
匯出檔下載1,000Class B$0.0004
儲存約 2 GB$0.03
Egress全部$0

每月不到 $0.12,而且全部在免費額度內。

對照組:如果 dashboard 的列表改成呼叫 list(),每月 1 萬次列表就是 $0.045 —— 比所有其他操作加起來還多。這就是為什麼清單要放在 D1。

本篇交付物packages/shared 的 R2 key 規則、exports 資料表、apps/api 的下載路由(含條件請求)、lifecycle 規則設定。


① 用 list() 當作查詢機制

Class A,是 get() 的 12.5 倍。清單存 D1 或 KV。

② 以為能分辨 304 和 412

binding 對兩者回傳一模一樣的 body-less R2Object。要自己看是哪類 header。

③ multipart 切分大小不一致

uploadPart() 全部會成功,complete() 才炸 10011

④ 失敗時沒呼叫 abort()

未完成的上傳會佔儲存空間並計費,7 天後才自動清。

⑤ production 用 r2.dev

限速、官方明說僅供開發,而且會繞過你的 WAF / Access。

⑥ 對 r2.dev 建 CNAME

官方明說是不支援的存取路徑。

⑦ 對同一個 key 高頻寫入

每秒 1 次,超過回 429。和 KV 一樣。

⑧ 隨手用 Infrequent Access

30 天最短儲存 + 取回費 + Class A/B 加倍,而且 lifecycle 轉不回 Standard。

⑨ 在本機驗證 storageClass 或事件通知

storageClass 一律空字串,事件通知不會觸發。

⑩ 用 experimental_remote

2025-09-16 GA 時已改名為 remote

⑪ 期待 presigned URL 能配自訂網域

只能配 S3 API 網域。


  1. **ListObjects 是 Class A,GetObject 是 Class B,差 12.5 倍。**清單存 D1,R2 只存位元組。
  2. **Multipart 的大小規則在 complete() 才驗。**切分時就要保證,別指望上傳過程報錯。
  3. **r2.dev 會繞過你的 WAF 和 Access。**production 一律自訂網域,並關掉 r2.dev


下一篇13. 儲存選型:KV / D1 / R2 / DO / Hyperdrive 決策樹 —— Part 2 收尾。把五種儲存的一致性模型、成本維度和適用形狀收攏成一張可以貼在牆上的決策圖。