R2 object storage: S3-compat, egress free, và 4 access pattern

R2 là object storage S3-compatible của Cloudflare, không phí egress. So sánh R2 vs S3, 4 pattern phục vụ object, migration từ S3, gotcha về consistency, metadata, lifecycle.

· 7 phút đọc · Read in English
Cloudflare R2 object storage: S3-compatible API không phí egress, 4 pattern phục vụ object (Worker binding, public bucket, presigned URL, multipart), migration từ S3 và gotcha về consistency/metadata/lifecycle

TL;DR

R2 là object storage của Cloudflare: S3 API v4 compat, zero egress fee, tích hợp tự nhiên với Worker qua binding, custom domain 1 click.

Luận điểm chính:

Nếu tải có bất kỳ khối lượng egress đáng kể (media, download, phân phối asset) → R2 tiết kiệm 90%+ so với S3. Nếu chỉ vài GB lưu kho backup, S3 Glacier rẻ hơn. Quyết định dựa trên tỉ lệ egress / storage, không phải “Cloudflare tốt hơn AWS”.

Bài này đi qua: so sánh R2 vs S3, 4 access pattern (Worker binding proxy, public bucket, presigned URL, multipart), migration từ S3, gotcha về strong consistency, metadata, lifecycle.


Dành cho ai

  • Dev đang trả tiền egress S3 nhiều và xem xét R2.
  • Team xây dựng media storage (ảnh, video, PDF) cần custom domain + CDN.
  • Ai migrate từ S3 mà muốn giữ boto3 / aws-sdk client.

Nên đọc trước: Part 3 (Storage layer), Part 5 (KV — decision tree khi nào dùng R2).

Sau bài này bạn sẽ:

  • Quyết định được R2 vs S3 dựa trên mô hình chi phí.
  • Chọn đúng pattern truy cập (Worker proxy vs public bucket vs presigned).
  • Migration từ S3 qua Super Slurper hoặc tự quản lý.
  • Tránh gotcha về eventual consistency list, CORS, cache.

Bài này không nói về gì

  • S3 intro: giả định bạn đã dùng S3.
  • Image transform (resize, crop): đó là Cloudflare Images, Part 16.
  • R2 SQL (beta): chưa cover.
  • R2 Data Catalog: tải analytics, ngoài phạm vi series này.

R2 vs S3: khi nào R2 thắng

So sánh R2 và S3 theo API compat, egress fee, storage price, request cost, region model, custom domain, Worker access. R2: S3 v4 compat, $0 egress, $0.015/GB storage, custom domain 1 click qua Cloudflare DNS, native Worker binding. S3: $0.09/GB egress to internet, $0.023/GB Standard storage, cần CloudFront + Route53 cho custom domain.

Khi R2 thắng rõ

  • Lưu trữ media: ảnh blog, video ngắn, PDF report — tỉ lệ egress/storage cao.
  • CDN origin: R2 là origin, Cloudflare CDN cache miễn phí.
  • Client tải trực tiếp: app tải file từ storage, không qua backend.
  • Avatar, upload người dùng: tỉ lệ egress thường > 10x storage.

Khi S3 vẫn tốt hơn

  • Backup lưu trữ lạnh: S3 Glacier Deep Archive $0.00099/GB/tháng.
  • Tích hợp với hệ sinh thái AWS: Athena query, Lambda trigger, SNS notify — R2 chưa có tương đương tích hợp sẵn.
  • Tuân thủ region cụ thể: S3 có chọn region tường minh, R2 dùng location hint.
  • Dùng trong VPC, không công khai: S3 VPC endpoint tiết kiệm egress.

Mô hình chi phí thực tế

Giả sử: 100 GB storage, 10 TB egress/tháng (ví dụ blog media + download).

R2S3
Storage$1.50$2.30
Egress$0$900 (10 TB × $0.09)
Request~$1-5~$1-5
Total~$5~$905

99% chi phí là egress. Đó là lý do R2 xuất hiện ngay sau AWS tăng egress.

Ngược lại, 10 GB backup không egress:

R2S3 Glacier
Storage$0.15$0.01
Retrievalmiễn phí$0.03/GB

Với backup hiếm restore, S3 Glacier rẻ hơn 15x.


Binding

{
  "r2_buckets": [
    {
      "binding": "BUCKET",
      "bucket_name": "my-media",
      "preview_bucket_name": "my-media-preview"
    }
  ]
}

Trong Worker:

interface Env {
  BUCKET: R2Bucket;
}

API cơ bản

// Put object
await env.BUCKET.put("images/logo.png", imageBytes, {
  httpMetadata: { contentType: "image/png" },
  customMetadata: { uploadedBy: "user:123" }
});

// Get object
const object = await env.BUCKET.get("images/logo.png");
if (!object) return new Response("Not found", { status: 404 });

// Stream response
return new Response(object.body, {
  headers: {
    "Content-Type": object.httpMetadata?.contentType ?? "application/octet-stream",
    "ETag": object.httpEtag,
  }
});

// Delete
await env.BUCKET.delete("images/logo.png");

// List with prefix
const listed = await env.BUCKET.list({ prefix: "images/", limit: 100 });
for (const obj of listed.objects) {
  console.log(obj.key, obj.size, obj.uploaded);
}

// Head (metadata only, không load body)
const head = await env.BUCKET.head("images/logo.png");

4 pattern truy cập

R2 access patterns: cách serve object qua Worker binding proxy, public bucket custom domain, presigned URL S3-compat, và multipart upload cho file lớn. Decision tree chọn pattern dựa trên yêu cầu auth, transform, size.

① Worker binding (proxy)

Client → Worker → R2, kiểm soát đầy đủ.

async fetch(request, env) {
  const url = new URL(request.url);
  const key = url.pathname.slice(1);

  // Auth check
  const jwt = request.headers.get("Cf-Access-Jwt-Assertion");
  const claims = await verifyJwt(jwt, env);
  if (!claims) return new Response("Unauthorized", { status: 401 });

  // Fetch R2
  const object = await env.BUCKET.get(key);
  if (!object) return new Response("Not found", { status: 404 });

  // Log access
  ctx.waitUntil(logAccess(env, claims.email, key));

  // Stream
  return new Response(object.body, {
    headers: {
      "Content-Type": object.httpMetadata?.contentType ?? "application/octet-stream",
      "Cache-Control": "private, max-age=300",
    }
  });
}

Khi dùng:

  • Cần xác thực per-object.
  • Cần biến đổi (resize, watermark, encrypt-at-rest).
  • Cần log mỗi lần truy cập.
  • Object nhỏ, traffic qua Worker chấp nhận được.

Gotcha: mỗi request là 1 lần gọi Worker + 1 subrequest R2. Với traffic lớn, chi phí Worker cao hơn R2. Cho file > vài MB, xem pattern khác.

② Public bucket + custom domain

Client → cdn.example.com/key, Cloudflare serve trực tiếp không qua Worker.

Thiết lập:

  1. R2 bucket → Settings → Public access → Connect Domain.
  2. Chọn domain thuộc Cloudflare DNS (cdn.example.com).
  3. Cloudflare tự tạo SSL cert + route.
  4. Object công khai request https://cdn.example.com/key.

Khi dùng:

  • Asset công khai: ảnh blog, logo, PDF report.
  • Không cần xác thực.
  • Traffic lớn (CDN cache giúp).

Gotcha:

  • Toàn bộ bucket công khai. Không kiểm soát per-object.
  • Cache CDN: vô hiệu hóa qua Cloudflare dashboard hoặc purge API.
  • CORS: đặt qua cấu hình bucket, không per-object.

Blog này dùng pattern này cho images.cloudsecop.net (phục vụ cover ảnh SVG/PNG).

③ Presigned URL (S3 compat)

Worker sign URL, client upload/download trực tiếp R2.

import { AwsClient } from "aws4fetch";

async function getPresignedPutUrl(env: Env, key: string): Promise<string> {
  const aws = new AwsClient({
    accessKeyId: env.R2_ACCESS_KEY_ID,
    secretAccessKey: env.R2_SECRET_ACCESS_KEY,
    service: "s3",
  });

  const endpoint = `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${env.BUCKET_NAME}/${key}`;

  const signed = await aws.sign(
    new Request(endpoint, { method: "PUT" }),
    { aws: { signQuery: true }, expiresIn: 3600 }  // 1 hour
  );

  return signed.url;
}

Client:

const { url } = await fetch("/api/upload-url", { method: "POST" }).then(r => r.json());
await fetch(url, { method: "PUT", body: file });

Khi dùng:

  • Client upload trực tiếp, không qua Worker (tiết kiệm thời gian CPU Worker).
  • Cần TTL (URL tự hết hạn).
  • Muốn giữ boto3 / aws-sdk client trên backend khác.

Gotcha:

  • Cần R2 access key + secret (tạo ở dashboard).
  • Presigned URL KHÔNG có xác thực trong URL ngoài signature. Nếu lộ URL trong TTL → bất kỳ ai dùng được.
  • TTL tối đa 7 ngày.

④ Multipart upload

Cho file > 100MB hoặc cần resume.

// Step 1: create upload
const upload = await env.BUCKET.createMultipartUpload("large-video.mp4", {
  httpMetadata: { contentType: "video/mp4" }
});

// Step 2: upload parts (có thể song song, từ nhiều request)
const part1 = await upload.uploadPart(1, chunk1);  // chunk 5-5000 MB mỗi part
const part2 = await upload.uploadPart(2, chunk2);

// Step 3: complete
await upload.complete([part1, part2]);

// Hoặc abort nếu fail
// await upload.abort();

Khi dùng:

  • Upload > 100MB trong 1 request Worker (subrequest wall time limit).
  • Client upload resumable (tạm dừng + tiếp tục).
  • Stream từ nguồn lớn (biến đổi + upload lũy tiến).

Gotcha:

  • Multipart upload bỏ dở tốn storage. Đặt chính sách vòng đời tự động xóa.
  • Kích thước part 5MB - 5GB.
  • Tối đa 10,000 part / upload.

Migration từ S3

Super Slurper (dashboard, 1 click)

R2 dashboard → Bucket → Data Migration → From S3.

Cung cấp:

  • Tên S3 bucket + AWS credentials (chỉ đọc OK).
  • R2 bucket đích.

Cloudflare chạy copy bất đồng bộ, email khi xong. Dung lượng lớn (TB) chạy vài giờ. Không phải đồng bộ realtime.

Tự quản lý (aws-sdk + env.BUCKET.put)

Cho đồng bộ liên tục hoặc biến đổi trong quá trình migrate:

// Worker hoặc script Node
import { S3Client, GetObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";

async function migrate(env: Env) {
  const s3 = new S3Client({ region: "us-east-1" });
  let continuationToken: string | undefined;

  do {
    const list = await s3.send(new ListObjectsV2Command({
      Bucket: "old-s3-bucket",
      ContinuationToken: continuationToken,
    }));

    for (const obj of list.Contents ?? []) {
      const { Body } = await s3.send(new GetObjectCommand({
        Bucket: "old-s3-bucket",
        Key: obj.Key!,
      }));

      const stream = Body as ReadableStream;
      await env.BUCKET.put(obj.Key!, stream, {
        httpMetadata: { contentType: obj.ContentType ?? "application/octet-stream" }
      });
    }

    continuationToken = list.NextContinuationToken;
  } while (continuationToken);
}

Chiến lược dual-write

Cho hệ thống quan trọng, không ngừng dịch vụ:

  1. Tuần 1: dual-write — code upload ghi cả S3 và R2.
  2. Tuần 2: migrate dữ liệu lịch sử (Super Slurper).
  3. Tuần 3: dual-read — đọc R2, phương án dự phòng S3 nếu miss.
  4. Tuần 4: giám sát, đảm bảo R2 coverage 100%.
  5. Tuần 5: tắt ghi S3, chỉ R2.
  6. Tháng 2: xóa S3 (sau thời gian ân hạn).

Không vội ở bước 5. Một object miss = người dùng phàn nàn.


Gotcha

① List eventual consistency

await env.BUCKET.put("a.txt", "...");
const listed = await env.BUCKET.list({ prefix: "a" });
// Có thể không thấy "a.txt" ngay

list() có thể trễ vài giây sau put(). Get bằng exact key thì strong consistent, nhưng list là eventual.

Đừng phụ thuộc vào list ngay sau write để xác nhận.

② Custom metadata limit

customMetadata tối đa 2 KB tổng (key + value). Nhiều metadata → dùng key trong cấu trúc key hoặc D1 pointer.

// Nặng metadata → tách
await env.BUCKET.put(`user/${userId}/file/${fileId}`, body);
await env.DB.prepare("INSERT INTO file_meta (key, metadata) VALUES (?, ?)")
  .bind(key, JSON.stringify(rich_meta))
  .run();

③ Content-Type không tự phát hiện

// SAI: R2 không sniff, trả "application/octet-stream"
await env.BUCKET.put("photo.jpg", body);

// ĐÚNG:
await env.BUCKET.put("photo.jpg", body, {
  httpMetadata: { contentType: "image/jpeg" }
});

Trình duyệt sẽ không hiển thị ảnh nếu content-type sai.

④ CORS cho upload từ trình duyệt

Mặc định R2 bucket không cho CORS. Trình duyệt fetch từ example.com → R2 trả chặn.

Cấu hình CORS qua dashboard hoặc Wrangler:

wrangler r2 bucket cors put my-bucket --file cors.json

cors.json:

[{
  "AllowedOrigins": ["https://my-app.com"],
  "AllowedMethods": ["GET", "PUT"],
  "AllowedHeaders": ["content-type"],
  "MaxAgeSeconds": 3600
}]

⑤ Cache CDN không vô hiệu hóa tự động

Public bucket + custom domain → Cloudflare cache object. put() key mới không xóa cache cũ.

Cần:

// Vô hiệu hóa qua API
await fetch(`https://api.cloudflare.com/client/v4/zones/${zone}/purge_cache`, {
  method: "POST",
  headers: { "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ files: [`https://cdn.example.com/${key}`] })
});

Hoặc dùng key có phiên bản (image-v2.png) để bỏ qua cache.

⑥ Quy tắc vòng đời cho dọn dẹp multipart

wrangler r2 bucket lifecycle put my-bucket --file lifecycle.json
{
  "rules": [{
    "id": "cleanup-incomplete-multipart",
    "enabled": true,
    "abortIncompleteMultipartUpload": {
      "daysAfterInitiation": 7
    }
  }]
}

Không có quy tắc này, multipart upload fail → part mồ côi, tốn storage.

⑦ Egress miễn phí không có nghĩa “infinite scale”

R2 $0 egress nhưng vẫn tính Class B request ($0.36/M read). 1 triệu lượt xem mỗi request tải 10 ảnh = 10 triệu Class B = $3.60. Cộng với lần gọi Worker. Vẫn rẻ, nhưng không hoàn toàn miễn phí.


Production checklist

  • Chọn pattern truy cập đúng (Worker / public / presigned / multipart) cho từng trường hợp sử dụng.
  • Content-Type đặt tường minh khi put.
  • CORS đã cấu hình nếu client trình duyệt upload trực tiếp.
  • Quy tắc vòng đời dọn dẹp multipart bỏ dở (7 ngày mặc định).
  • Custom metadata < 2 KB; nặng → D1 pointer.
  • Public bucket không chứa dữ liệu nhạy cảm (toàn bucket lộ).
  • TTL presigned URL tối thiểu cần thiết (không tối đa 7 ngày).
  • Chính sách cache CDN khớp với chiến lược vô hiệu hóa.
  • List không dùng để xác nhận write gần đây (eventual consistency).

Kết

R2 là object storage S3-compatible với zero egress, tích hợp Worker tích hợp sẵn. Tiết kiệm chi phí lớn cho tải media / CDN origin; không phải “thắng mọi tải” — backup lạnh vẫn S3 Glacier.

4 pattern truy cập phủ 99% trường hợp sử dụng: Worker binding (xác thực), public bucket (asset tĩnh), presigned URL (client trực tiếp), multipart (file lớn). Migration từ S3 có Super Slurper (1 click) hoặc chiến lược dual-write (quan trọng).

Part 8 tới: Queues và Durable Objects — async messaging và stateful coordination. Cả 2 primitive khó nhất để dùng đúng, nhưng là điều khiến Workers thật sự full-stack.


Tham khảo