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
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).
| R2 | S3 | |
|---|---|---|
| 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:
| R2 | S3 Glacier | |
|---|---|---|
| Storage | $0.15 | $0.01 |
| Retrieval | miễ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
① 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:
- R2 bucket → Settings → Public access → Connect Domain.
- Chọn domain thuộc Cloudflare DNS (
cdn.example.com). - Cloudflare tự tạo SSL cert + route.
- 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ụ:
- Tuần 1: dual-write — code upload ghi cả S3 và R2.
- Tuần 2: migrate dữ liệu lịch sử (Super Slurper).
- Tuần 3: dual-read — đọc R2, phương án dự phòng S3 nếu miss.
- Tuần 4: giám sát, đảm bảo R2 coverage 100%.
- Tuần 5: tắt ghi S3, chỉ R2.
- 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.