Cloudflare R2 — Object Storage không phí Egress cho Developer

Posted on: 4/27/2026 12:15:37 PM

Mỗi tháng, hóa đơn AWS S3 của bạn có bao nhiêu phần trăm là phí egress — tiền trả cho việc người dùng download file từ bucket? Với nhiều dự án, con số này chiếm 50-80% tổng chi phí storage. Cloudflare R2 sinh ra để giải quyết đúng vấn đề này: S3-compatible object storage với zero egress fees — không tốn một đồng nào cho bandwidth ra ngoài. Bài viết này phân tích sâu kiến trúc R2, tích hợp Workers tại edge, các pattern upload/download production-ready, và so sánh chi tiết chi phí với AWS S3.

1. Vấn đề — "Egress Tax" của Cloud Storage

AWS S3 tính phí egress từ $0.09/GB cho data transfer ra Internet. Nghe có vẻ nhỏ, nhưng khi ứng dụng scale lên, chi phí này tăng phi tuyến tính:

$0Egress trên Cloudflare R2
$0.015/GB/tháng storage R2
10 GBFree tier storage mỗi tháng
PetabytesĐã migrate từ S3 sang R2
graph LR
    subgraph "AWS S3 — Chi phí ẩn"
        S3["S3 Bucket
$0.023/GB storage"] --> EG["Egress
$0.09/GB"] EG --> USER["Người dùng"] S3 --> CF["qua CloudFront
$0.085/GB"] CF --> USER end subgraph "Cloudflare R2 — Minh bạch" R2["R2 Bucket
$0.015/GB storage"] --> CDN["Cloudflare CDN
$0 egress"] CDN --> USER2["Người dùng"] end style S3 fill:#ff9800,stroke:#fff,color:#fff style EG fill:#e94560,stroke:#fff,color:#fff style R2 fill:#4CAF50,stroke:#fff,color:#fff style CDN fill:#4CAF50,stroke:#fff,color:#fff

Hình 1: So sánh luồng chi phí giữa AWS S3 và Cloudflare R2

Kịch bảnAWS S3 + CloudFrontCloudflare R2Tiết kiệm
Blog nhỏ — 50 GB storage, 500 GB egress/tháng$1.15 + $42.50 = $43.65$0.75 + $0 = $0.7598%
SaaS trung bình — 500 GB storage, 5 TB egress/tháng$11.50 + $425 = $436.50$7.50 + $0 = $7.5098%
Video platform — 5 TB storage, 50 TB egress/tháng$115 + $4,250 = $4,365$75 + $0 = $7598%
Enterprise — 50 TB storage, 200 TB egress/tháng$1,150 + $17,000 = $18,150$750 + $0 = $75096%

Tại sao egress lại đắt?

Cloud provider tính phí egress vì bandwidth có chi phí thực (transit, peering, infrastructure). Nhưng Cloudflare có lợi thế đặc biệt: họ sở hữu một trong những mạng CDN lớn nhất thế giới (330+ PoP tại 120+ quốc gia) và có peering agreement với hầu hết ISP lớn. Chi phí bandwidth của Cloudflare gần bằng 0 nhờ mô hình kinh doanh chính từ security + performance services, không phải từ storage egress. R2 chỉ đơn giản là không chuyển chi phí bandwidth sang khách hàng.

2. Kiến trúc Cloudflare R2

R2 không phải là một bản copy đơn giản của S3. Nó được thiết kế từ đầu để tích hợp sâu với hệ sinh thái Cloudflare:

graph TB
    CLIENT["Client
(Browser / Mobile / Server)"] subgraph "Cloudflare Edge — 330+ PoP" WORKER["Cloudflare Worker
Auth, Transform, Route"] CACHE["Edge Cache
Tự động cache objects"] end subgraph "Cloudflare R2 Storage" R2["R2 Bucket
S3-compatible API"] IA["Infrequent Access
$0.01/GB/tháng"] LIFECYCLE["Lifecycle Rules
Auto-transition"] end subgraph "Event System" NOTIFY["Event Notifications"] QUEUE["Cloudflare Queue"] CONSUMER["Consumer Worker
Process events"] end CLIENT --> WORKER CLIENT -->|"S3 API / Presigned URL"| R2 WORKER -->|"Bindings API"| R2 WORKER --> CACHE CACHE --> R2 R2 --> LIFECYCLE LIFECYCLE --> IA R2 --> NOTIFY NOTIFY --> QUEUE QUEUE --> CONSUMER style WORKER fill:#e94560,stroke:#fff,color:#fff style R2 fill:#f76c02,stroke:#fff,color:#fff style CACHE fill:#4CAF50,stroke:#fff,color:#fff style QUEUE fill:#2196F3,stroke:#fff,color:#fff

Hình 2: Kiến trúc tổng thể Cloudflare R2 với Workers và Event Notifications

Thành phầnMô tảLợi ích
R2 BucketObject storage phân tán, S3-compatible API. Hỗ trợ objects đến 5 TB.Migrate từ S3 không cần đổi code
Workers BindingTruy cập R2 trực tiếp từ Worker qua in-process binding — không qua HTTP.Latency cực thấp (~1-5ms), không tốn API call
Edge CacheObjects phổ biến được cache tự động tại 330+ PoP gần người dùng.Giảm latency cho read-heavy workload
Infrequent AccessStorage class rẻ hơn ($0.01/GB vs $0.015/GB) cho data ít truy cập.Tiết kiệm 33% cho cold data
Event NotificationsGửi event đến Cloudflare Queue khi object được tạo/xóa/sửa.Trigger xử lý async (thumbnail, transcode, index)
Lifecycle RulesTự động chuyển objects sang IA hoặc xóa sau N ngày.Quản lý chi phí tự động

3. Hai cách truy cập R2

R2 cung cấp 2 API hoàn toàn khác nhau, phù hợp với các use case riêng biệt:

3.1. S3-compatible API — Cho server-to-server

Dùng bất kỳ S3 SDK nào (AWS SDK, boto3, @aws-sdk/client-s3) với endpoint R2:

// Node.js — Upload file lên R2 bằng AWS SDK v3
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

const r2Client = new S3Client({
  region: "auto",
  endpoint: "https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

// Upload
await r2Client.send(new PutObjectCommand({
  Bucket: "my-bucket",
  Key: "uploads/avatar-123.webp",
  Body: fileBuffer,
  ContentType: "image/webp",
}));
// .NET — Upload file lên R2 bằng AWSSDK.S3
using Amazon.S3;
using Amazon.S3.Model;

var config = new AmazonS3Config
{
    ServiceURL = $"https://{accountId}.r2.cloudflarestorage.com",
    ForcePathStyle = true // R2 yêu cầu path-style
};

var s3Client = new AmazonS3Client(
    accessKeyId, secretAccessKey, config);

await s3Client.PutObjectAsync(new PutObjectRequest
{
    BucketName = "my-bucket",
    Key = "uploads/avatar-123.webp",
    InputStream = fileStream,
    ContentType = "image/webp"
});

3.2. Workers API — Cho edge processing

Khi xử lý tại edge, Workers binding nhanh hơn S3 API vì không qua HTTP:

// wrangler.toml
// [[r2_buckets]]
// binding = "MY_BUCKET"
// bucket_name = "my-bucket"

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const key = url.pathname.slice(1); // bỏ leading /

    switch (request.method) {
      case "GET": {
        const object = await env.MY_BUCKET.get(key);
        if (!object) return new Response("Not Found", { status: 404 });

        const headers = new Headers();
        object.writeHttpMetadata(headers);
        headers.set("etag", object.httpEtag);
        headers.set("cache-control", "public, max-age=86400");

        return new Response(object.body, { headers });
      }

      case "PUT": {
        const contentType = request.headers.get("content-type") ?? "";
        await env.MY_BUCKET.put(key, request.body, {
          httpMetadata: { contentType },
          customMetadata: { uploadedBy: "worker" },
        });
        return new Response(JSON.stringify({ key }), { status: 201 });
      }

      case "DELETE": {
        await env.MY_BUCKET.delete(key);
        return new Response(null, { status: 204 });
      }

      default:
        return new Response("Method Not Allowed", { status: 405 });
    }
  },
};

Workers Binding vs S3 API — Khi nào dùng cái nào?

Workers Binding: Khi bạn cần xử lý logic tại edge (auth, resize ảnh, transform data) trước/sau khi đọc/ghi R2. Latency ~1-5ms, không tốn Class A/B operation fees.

S3 API: Khi server backend (Node.js, .NET, Python) cần truy cập R2 trực tiếp, hoặc khi dùng tools có sẵn S3 support (Terraform, rclone, cyberduck). Tốn operation fees nhưng tương thích với toàn bộ hệ sinh thái S3.

4. Presigned URLs — Upload trực tiếp từ Browser

Pattern phổ biến nhất cho file upload: client lấy presigned URL từ server, rồi upload trực tiếp lên R2 mà không cần proxy qua backend.

sequenceDiagram
    participant B as Browser
    participant W as Worker / API Server
    participant R2 as Cloudflare R2

    B->>W: POST /api/upload/presign
{filename, contentType} W->>W: Validate user, generate presigned URL W-->>B: {uploadUrl, key} B->>R2: PUT uploadUrl
(file binary) R2-->>B: 200 OK B->>W: POST /api/upload/confirm
{key} W->>R2: HEAD key (verify exists) R2-->>W: 200 + metadata W-->>B: {url: "https://cdn.example.com/key"}

Hình 3: Presigned URL flow — Browser upload trực tiếp, không qua backend

// Worker — Tạo presigned URL cho upload
import { AwsClient } from "aws4fetch";

const r2 = new AwsClient({
  accessKeyId: R2_ACCESS_KEY_ID,
  secretAccessKey: R2_SECRET_ACCESS_KEY,
});

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();
    const key = `uploads/${crypto.randomUUID()}/${filename}`;

    // Tạo presigned PUT URL — hết hạn sau 1 giờ
    const url = new URL(
      `https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com/${env.BUCKET_NAME}/${key}`
    );
    url.searchParams.set("X-Amz-Expires", "3600");

    const signed = await r2.sign(
      new Request(url, {
        method: "PUT",
        headers: { "Content-Type": contentType },
      }),
      { aws: { signQuery: true } }
    );

    return Response.json({
      uploadUrl: signed.url,
      key,
    });
  },
};
// Frontend — Upload file dùng presigned URL
async function uploadFile(file: File) {
  // Bước 1: Lấy presigned URL
  const { uploadUrl, key } = await fetch("/api/upload/presign", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      filename: file.name,
      contentType: file.type,
    }),
  }).then(r => r.json());

  // Bước 2: Upload trực tiếp lên R2
  await fetch(uploadUrl, {
    method: "PUT",
    headers: { "Content-Type": file.type },
    body: file,
  });

  // Bước 3: Xác nhận với backend
  const result = await fetch("/api/upload/confirm", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ key }),
  }).then(r => r.json());

  return result.url;
}

5. Multipart Upload — File lớn hàng GB

Với file trên 100 MB, multipart upload chia file thành nhiều part nhỏ (5-100 MB mỗi part), upload song song, và R2 tự ghép lại. Nếu 1 part fail, chỉ cần retry part đó.

// Worker — Multipart upload qua Workers API
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const key = "videos/large-file.mp4";

    // Bước 1: Khởi tạo multipart upload
    const mpu = await env.MY_BUCKET.createMultipartUpload(key, {
      httpMetadata: { contentType: "video/mp4" },
    });

    // Bước 2: Upload từng part (5 MB minimum, trừ part cuối)
    const partSize = 10 * 1024 * 1024; // 10 MB
    const body = await request.arrayBuffer();
    const uploadedParts: R2UploadedPart[] = [];

    for (let i = 0; i * partSize < body.byteLength; i++) {
      const start = i * partSize;
      const end = Math.min(start + partSize, body.byteLength);
      const chunk = body.slice(start, end);

      const part = await mpu.uploadPart(i + 1, chunk);
      uploadedParts.push(part);
    }

    // Bước 3: Complete — R2 ghép tất cả parts
    const object = await mpu.complete(uploadedParts);

    return Response.json({
      key: object.key,
      size: object.size,
      etag: object.httpEtag,
    });
  },
};

Lưu ý quan trọng về Multipart Upload

1. Minimum part size: Mỗi part (trừ part cuối) phải ≥ 5 MB. Vi phạm sẽ lỗi.

2. Maximum parts: Tối đa 10,000 parts per upload.

3. Auto-abort: Multipart upload chưa complete sẽ tự động bị hủy sau 7 ngày. Các parts đã upload chiếm storage và tính phí cho đến khi bị abort.

4. Resume: Dùng resumeMultipartUpload(key, uploadId) để tiếp tục upload bị gián đoạn — không cần bắt đầu lại từ đầu.

6. Event Notifications — Xử lý bất đồng bộ

R2 Event Notifications cho phép trigger Worker khi object thay đổi — pattern lý tưởng cho image processing, video transcoding, search indexing:

graph LR
    UPLOAD["Upload ảnh
avatar.jpg"] --> R2["R2 Bucket"] R2 -->|"Event: object-create"| QUEUE["Cloudflare Queue"] QUEUE --> WORKER["Consumer Worker"] WORKER -->|"Resize 3 kích thước"| R2_THUMB["R2 Bucket
/thumbs/"] WORKER -->|"Phân tích nội dung"| AI["Workers AI
Image Classification"] WORKER -->|"Cập nhật metadata"| DB["D1 Database"] style R2 fill:#f76c02,stroke:#fff,color:#fff style QUEUE fill:#2196F3,stroke:#fff,color:#fff style WORKER fill:#e94560,stroke:#fff,color:#fff

Hình 4: Event-driven pipeline — Upload ảnh → Resize + AI classify + Update DB

// wrangler.toml — Cấu hình Event Notifications
// [[r2_buckets]]
// binding = "MY_BUCKET"
// bucket_name = "my-bucket"
//
// [[queues.consumers]]
// queue = "r2-events"
// max_batch_size = 10
// max_batch_timeout = 5

// Consumer Worker — Xử lý event từ R2
export default {
  async queue(
    batch: MessageBatch<R2EventNotification>,
    env: Env
  ): Promise<void> {
    for (const message of batch.messages) {
      const event = message.body;

      if (event.action === "PutObject") {
        const key = event.object.key;

        // Chỉ xử lý ảnh
        if (key.match(/\.(jpg|jpeg|png|webp)$/i)) {
          const original = await env.MY_BUCKET.get(key);
          if (!original) continue;

          const imageData = await original.arrayBuffer();

          // Tạo thumbnail 200x200
          const thumb = await resizeImage(imageData, 200, 200);
          await env.MY_BUCKET.put(
            `thumbs/${key}`,
            thumb,
            { httpMetadata: { contentType: "image/webp" } }
          );

          // Cập nhật database
          await env.DB.prepare(
            "UPDATE files SET thumbnail_key = ? WHERE key = ?"
          ).bind(`thumbs/${key}`, key).run();
        }
      }

      message.ack();
    }
  },
};

7. Migrate từ S3 sang R2

Cloudflare cung cấp 2 công cụ migration chính thức, phù hợp với các tình huống khác nhau:

Công cụCách hoạt độngPhù hợp
Super SlurperCopy toàn bộ bucket từ S3/GCS sang R2. Chạy nền, xử lý petabytes data.Migration một lần, cần copy toàn bộ data trước khi switch.
SippyIncremental migration — khi client request object chưa có trong R2, Sippy tự động fetch từ S3, lưu vào R2, và trả về. Lần sau request lại sẽ lấy từ R2.Zero-downtime migration, không cần copy toàn bộ upfront.
sequenceDiagram
    participant C as Client
    participant R2 as Cloudflare R2
    participant S3 as AWS S3 (source)

    Note over R2: Sippy đã enabled

    C->>R2: GET /images/photo.jpg
    R2->>R2: Check local storage
    alt Object tồn tại trong R2
        R2-->>C: 200 OK (từ R2)
    else Object chưa có
        R2->>S3: Fetch /images/photo.jpg
        S3-->>R2: Object data
        R2->>R2: Lưu vào R2 storage
        R2-->>C: 200 OK (đã cache)
    end

    Note over C,S3: Lần request tiếp theo sẽ lấy từ R2,
không cần gọi S3 nữa

Hình 5: Sippy migration — Lazy copy từ S3, không downtime, không trả egress S3 trước

Chiến lược migration thực tế

Bước 1: Bật Sippy trên R2 bucket, trỏ đến S3 bucket nguồn.

Bước 2: Đổi DNS/CDN trỏ sang R2. Client request sẽ được Sippy phục vụ — object chưa có sẽ tự fetch từ S3.

Bước 3: Song song, chạy Super Slurper để copy phần data còn lại (objects chưa được request).

Bước 4: Khi Super Slurper xong, tắt Sippy. R2 đã có toàn bộ data.

Cách này không phải trả egress S3 cho toàn bộ data — chỉ trả cho phần Super Slurper copy (nếu dùng Sippy, Cloudflare cover egress fee cho phần lazy-fetch).

8. Lifecycle Rules và Infrequent Access

R2 đơn giản hóa storage class chỉ còn 2 tier — không có hàng chục tier gây confusion như S3:

Tiêu chíR2 StandardR2 Infrequent AccessS3 StandardS3 Glacier
Storage$0.015/GB$0.01/GB$0.023/GB$0.004/GB
Egress$0$0$0.09/GB$0.09/GB + retrieval
Retrieval feeKhông$0.01/GBKhông$0.03-0.05/GB
Min durationKhông30 ngàyKhông90-180 ngày
AvailabilityNgay lập tứcNgay lập tứcNgay lập tứcVài phút → vài giờ
# Wrangler CLI — Quản lý lifecycle rules
# Thêm rule: chuyển sang IA sau 90 ngày, xóa sau 365 ngày
npx wrangler r2 bucket lifecycle add my-bucket \
  --prefix "logs/" \
  --transition-to-ia-after 90 \
  --expire-after 365

# Liệt kê rules hiện tại
npx wrangler r2 bucket lifecycle list my-bucket

# Xóa rule
npx wrangler r2 bucket lifecycle remove my-bucket --id rule-id-123

9. Production Patterns

9.1. CDN Cache + R2 — Tối ưu read-heavy workload

// Worker — Serve R2 objects với CDN caching
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const key = url.pathname.slice(1);

    // Check Cache API trước
    const cache = caches.default;
    let response = await cache.match(request);
    if (response) return response;

    // Không có cache → lấy từ R2
    const object = await env.MY_BUCKET.get(key);
    if (!object) {
      return new Response("Not Found", { status: 404 });
    }

    const headers = new Headers();
    object.writeHttpMetadata(headers);
    headers.set("etag", object.httpEtag);

    // Cache static assets 1 năm, dynamic content 1 giờ
    const isStatic = key.match(/\.(js|css|woff2|webp|avif|svg)$/);
    headers.set(
      "cache-control",
      isStatic
        ? "public, max-age=31536000, immutable"
        : "public, max-age=3600, s-maxage=86400"
    );

    response = new Response(object.body, { headers });

    // Lưu vào edge cache
    request.method === "GET" && cache.put(request, response.clone());

    return response;
  },
};

9.2. Access Control — Signed URLs + CORS

// Worker — Kiểm tra auth trước khi cho download
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Verify JWT token
    const token = request.headers.get("Authorization")?.replace("Bearer ", "");
    if (!token) return new Response("Unauthorized", { status: 401 });

    const payload = await verifyJWT(token, env.JWT_SECRET);
    if (!payload) return new Response("Forbidden", { status: 403 });

    const key = new URL(request.url).pathname.slice(1);

    // Kiểm tra quyền truy cập file
    const allowed = await checkPermission(env.DB, payload.userId, key);
    if (!allowed) return new Response("Forbidden", { status: 403 });

    const object = await env.MY_BUCKET.get(key);
    if (!object) return new Response("Not Found", { status: 404 });

    const headers = new Headers();
    object.writeHttpMetadata(headers);
    headers.set("cache-control", "private, no-store");

    return new Response(object.body, { headers });
  },
};

10. So sánh tổng quan với các Object Storage khác

Tiêu chíCloudflare R2AWS S3GCSAzure BlobBackblaze B2
Egress$0$0.09/GB$0.12/GB$0.087/GB$0.01/GB
Storage$0.015/GB$0.023/GB$0.020/GB$0.018/GB$0.006/GB
Free tier10 GB + 10M ops5 GB (12 tháng)5 GB5 GB (12 tháng)10 GB
S3 compatibleGốcCó (XML API)Không
Edge integrationWorkers bindingLambda@EdgeCloud FunctionsAzure FunctionsKhông
CDN tích hợpCloudflare CDNCloudFrontCloud CDNAzure CDNCloudflare (partner)
Storage classes2 (Standard + IA)6+441
Event systemQueuesSNS/SQS/LambdaPub/SubEvent GridWebhooks

Khi nào KHÔNG nên dùng R2?

1. Cần multi-region replication: R2 lưu data tại 1 region duy nhất (tự động chọn gần nhất). S3 có Cross-Region Replication cho compliance/DR.

2. Hệ sinh thái AWS sâu: Nếu dự án dùng nhiều AWS services (Lambda, SQS, DynamoDB), S3 tích hợp tự nhiên hơn — R2 cần thêm lớp kết nối.

3. Archival storage cực rẻ: S3 Glacier Deep Archive chỉ $0.00099/GB — rẻ hơn R2 IA rất nhiều cho data lưu trữ dài hạn hiếm khi truy cập.

4. Compliance đặc thù: S3 có nhiều chứng chỉ hơn (FedRAMP High, HIPAA, PCI DSS Level 1). R2 đang bổ sung dần.

5. Analytics trên storage: S3 Select, Athena query trực tiếp trên S3. R2 không có tương đương.

11. Kết luận

Cloudflare R2 không phải là "S3 rẻ hơn" — nó là một mô hình kinh doanh khác biệt cho object storage, loại bỏ hoàn toàn "egress tax" mà cloud providers truyền thống đã tính trong hơn một thập kỷ. Với S3-compatible API, tích hợp sâu Workers tại edge, event notifications, và Sippy migration không downtime — R2 đã sẵn sàng cho production ở mọi quy mô.

Nếu ứng dụng của bạn phục vụ nhiều file cho người dùng cuối (ảnh, video, documents, static assets), R2 có thể giảm 90-98% chi phí storage so với S3. Hãy bắt đầu với free tier (10 GB storage, 10 triệu operations/tháng), migrate dần bằng Sippy, và chỉ trả tiền khi thực sự scale.

Nguồn tham khảo