R2:零 egress 費用的物件儲存
這篇要解決的問題
Section titled “這篇要解決的問題”R2 是 S3 相容的物件儲存,最大賣點是流出流量完全免費。這件事本身很好懂,所以這篇的重點放在三個沒那麼明顯、但會直接影響你架構和帳單的地方:
ListObjects是 Class A 操作,價格是GetObject的 12.5 倍。 一個天真的「列出所有物件再逐一處理」迴圈是 R2 上最典型的燒錢模式。- 條件請求失敗時,你分不出 304 和 412。 實測兩者都回傳一個「沒有 body 的
R2Object」,完全一樣。 - Multipart 的大小規則在
complete()才驗證。 你可以成功上傳 9,999 個大小不對的 part,然後在最後一步整批失敗。
兩個 shape:R2Object 與 R2ObjectBody
Section titled “兩個 shape:R2Object 與 R2ObjectBody”這是 R2 API 最容易搞混的地方。實測兩者的差別:
$ 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—— 多了body、text()、json()、arrayBuffer()、blob(),還有一個文件沒列的bytes()。
etag 和 httpEtag 的差別要記住:前者是裸的,後者帶引號,可以直接塞進 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-Match、If-None-Match、If-Modified-Since、If-Unmodified-Since(除了 If-Range 之外都支援)。
但實測揭露一個問題:
$ 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 的正確工具。
Range 請求
Section titled “Range 請求”三種形式,實測都可用:
$ 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 會告訴你實際服務了哪一段。
list() 與「資料夾」
Section titled “list() 與「資料夾」”R2 沒有資料夾,只有 key。但 delimiter 可以模擬出來:
$ 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']。
分頁一律看 truncated(cursor 只在 truncated: true 時存在)。單次最多 1000 筆。
Checksum:上傳時驗證完整性
Section titled “Checksum:上傳時驗證完整性”$ 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 嚴格。
實測三種情況:
$ 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()。
💰 計費:ListObjects 是 Class A
Section titled “💰 計費:ListObjects 是 Class A”這是 R2 最重要的成本知識。
| Standard | Infrequent 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 倍):ListObjects、PutObject、CopyObject、CreateMultipartUpload、UploadPart、CompleteMultipartUpload、ListParts、ListMultipartUploads、ListBuckets、PutBucket*
Class B(便宜):GetObject、HeadObject、HeadBucket、GetBucket*
免費:DeleteObject、DeleteBucket、AbortMultipartUpload
三個直接的推論:
① 列出物件比讀取物件貴 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。
Infrequent Access 的兩個隱藏成本
Section titled “Infrequent Access 的兩個隱藏成本”- 最短儲存 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 |
| 自訂網域 / bucket | 100 |
| 事件通知規則 / bucket | 100 |
注意那個每個 key 每秒 1 次寫入 —— 和 KV 一樣的限制(第 8 篇)。R2 也不能拿來做熱點計數器。
🔴 r2.dev 不能用在 production
Section titled “🔴 r2.dev 不能用在 production”官方原文,三段都值得引用:
“Public access through
r2.devsubdomains is rate-limited and should only be used for development purposes.”
“Avoid creating a CNAME record pointing to the
r2.devsubdomain. This is an unsupported access path, and we cannot guarantee consistent reliability or performance.”
“Disable public access to your
r2.devsubdomain when using products like WAF or Cloudflare Access. If you do not disable public access, your bucket will remain publicly available through yourr2.devsubdomain.”
最後那一段是安全問題:你在自訂網域前面架了 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 網域,不能用在自訂網域上。
本機能驗什麼、不能驗什麼
Section titled “本機能驗什麼、不能驗什麼”R2 在 wrangler dev 是本機模擬的,資料在 .wrangler/state/v3/r2。用 wrangler r2 object put <BUCKET>/<KEY> --file=<PATH> --local 灌種子資料。
但有一個明確的差異:
$ 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 已改名)。注意會動到真實資料、會計費、會有網路延遲。
事件通知 → Queues
Section titled “事件通知 → Queues”GA,設定走 CLI:
npx wrangler r2 bucket notification create <BUCKET> \ --event-type object-create --queue <QUEUE> --prefix "reports/"事件類型只有兩種:
object-create——PutObject、CopyObject、CompleteMultipartUploadobject-delete——DeleteObject、LifecycleDeletion
訊息長這樣:
{ "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.size 和 object.eTag。 每個 bucket 最多 100 條規則,而 Queues 每秒 5,000 則的上限(第 19 篇)在這裡也適用。
Data Catalog 與 R2 SQL 的狀態
Section titled “Data Catalog 與 R2 SQL 的狀態”兩個都還在 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/
cd examples/ch12-r2 && npm install && npm run devB=localhost:8787curl -s "$B/seed"curl -s "$B/shapes" # R2Object vs R2ObjectBodycurl -s "$B/conditional" # 304 和 412 分不出來curl -s "$B/range" # offset/length 與 suffixcurl -s "$B/list" # delimiter -> delimitedPrefixescurl -s "$B/checksum" # 驗證失敗與「只能一個 hash」curl -s "$B/multipart" # 大小規則在 complete() 才驗curl -s "$B/storageclass" # 本機一律空字串curl -s "$B/delete" # 刪不存在的 key 也沒事接進 LinkForge
Section titled “接進 LinkForge”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 formreturn 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,000 | Class A | $0.005 |
| 匯出檔下載 | 1,000 | Class 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 網域。
本篇要記住的三句話
Section titled “本篇要記住的三句話”- **
ListObjects是 Class A,GetObject是 Class B,差 12.5 倍。**清單存 D1,R2 只存位元組。 - **Multipart 的大小規則在
complete()才驗。**切分時就要保證,別指望上傳過程報錯。 - **
r2.dev會繞過你的 WAF 和 Access。**production 一律自訂網域,並關掉r2.dev。
- R2 Workers API 參考
- 計費(Class A / B 的完整操作清單)
- 限制
- Multipart 物件
- 公開 bucket
- 儲存級別 · 事件通知
下一篇:13. 儲存選型:KV / D1 / R2 / DO / Hyperdrive 決策樹 —— Part 2 收尾。把五種儲存的一致性模型、成本維度和適用形狀收攏成一張可以貼在牆上的決策圖。