Object Storage và File Upload System Design 2026 — S3, Cloudflare R2, Presigned URL và Chunked Upload

Posted on: 4/20/2026 4:13:32 PM

Table of contents

  1. 1. Object Storage là gì và tại sao không nên lưu file trên application server?
  2. 2. Presigned URL — Bí mật của Direct Upload an toàn
    1. 2.1. Luồng hoạt động của Presigned URL Upload
      1. Tại sao cần bước Confirm?
    2. 2.2. Presigned URL trên từng nền tảng
      1. Lưu ý bảo mật Presigned URL
  3. 3. Multipart Upload — Xử lý file lớn hàng GB
    1. 3.1. Luồng Multipart Upload trên S3/R2
      1. Cách chọn Part Size tối ưu
    2. 3.2. Client-side implementation với JavaScript
  4. 4. Tus Protocol — Chuẩn mở cho Resumable Upload
    1. 4.1. Các HTTP method trong Tus Protocol
    2. 4.2. Tus Extensions quan trọng
      1. Tus đang được IETF chuẩn hóa
  5. 5. So sánh S3 vs Cloudflare R2 vs Azure Blob Storage
    1. Khi nào chọn nền tảng nào?
  6. 6. Kiến trúc File Upload System cho Production
    1. 6.1. Temp Bucket vs Production Bucket Pattern
    2. 6.2. Server-side Upload Service trên .NET 10
  7. 7. Post-Upload Processing Pipeline
    1. 7.1. Virus Scanning — Không thể bỏ qua
    2. 7.2. Content Validation — Không tin Content-Type header
  8. 8. CDN Integration và Delivery Optimization
    1. 8.1. Cloudflare R2 + Custom Domain
    2. 8.2. S3 + CloudFront
    3. 8.3. Image Optimization on-the-fly
      1. Cache-Control Header cho static assets
  9. 9. Security và Access Control nâng cao
    1. 9.1. Signed URL cho Private Content
    2. 9.2. CORS Configuration
      1. Không dùng AllowedOrigins: ["*"] trong production
  10. 10. Lifecycle Policy và Cleanup Strategy
    1. 10.1. S3 Lifecycle Rules
    2. 10.2. Background Job dọn orphaned files
  11. 11. Performance Monitoring và Metrics
  12. 12. Tổng kết Best Practices
    1. Checklist thiết kế File Upload System
    2. Nguồn tham khảo

Mọi ứng dụng web đều cần upload file — từ avatar đơn giản đến video hàng GB. Nhưng khi hệ thống scale lên hàng triệu user, câu hỏi không còn là "upload lên server rồi lưu ở đâu" mà là làm sao thiết kế một pipeline upload có khả năng chịu lỗi, resumable, bảo mật, và tối ưu chi phí. Bài viết này đi sâu vào kiến trúc Object Storage và File Upload System Design cho production 2026 — từ Presigned URL, Chunked Upload, Tus Protocol đến so sánh thực tế giữa AWS S3, Cloudflare R2 và Azure Blob Storage.

$0.00Egress fee của Cloudflare R2
5TBKích thước tối đa 1 object trên S3
10,000Số part tối đa cho Multipart Upload
~70%Giảm tải server nhờ Direct Upload

1. Object Storage là gì và tại sao không nên lưu file trên application server?

Object Storage là mô hình lưu trữ trong đó dữ liệu được quản lý dưới dạng các object — mỗi object gồm data (nội dung file), metadata (thông tin mô tả), và một unique key (khóa định danh). Khác với file system truyền thống (dùng thư mục/phân cấp) hay block storage (dùng sector/block), object storage cung cấp flat namespace với khả năng scale gần như vô hạn.

Tại sao không nên lưu file trực tiếp trên application server?

  • Không scale được: Khi chạy nhiều instance (horizontal scaling), file trên instance A không tồn tại trên instance B. Phải dùng shared storage phức tạp.
  • Disk là bottleneck: Disk I/O của application server có giới hạn. Một file upload 500MB chiếm bandwidth và CPU decompress trong khi request khác phải chờ.
  • Không có CDN tích hợp: Phải tự cấu hình reverse proxy + CDN edge cache, phức tạp và dễ sai.
  • Backup/Recovery phức tạp: Object storage cung cấp versioning, cross-region replication, lifecycle policy sẵn có.
graph TB
    subgraph "❌ Anti-pattern: Server-side Storage"
        U1["Client"] -->|"Upload 500MB"| S1["App Server
CPU + Disk I/O"] S1 -->|"Lưu vào disk"| D1["Local Disk
Không replicate"] S1 -->|"Serve file"| U1 end subgraph "✅ Best Practice: Object Storage" U2["Client"] -->|"1. Xin Presigned URL"| S2["App Server
Chỉ xử lý logic"] S2 -->|"2. Trả URL"| U2 U2 -->|"3. Upload trực tiếp"| OS["Object Storage
S3 / R2 / Azure Blob"] OS -->|"4. CDN Serve"| CDN["Edge CDN
300+ PoPs"] CDN -->|"5. Tải nhanh"| U2 end style U1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style S1 fill:#e94560,stroke:#fff,color:#fff style D1 fill:#ff9800,stroke:#fff,color:#fff style U2 fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50 style S2 fill:#4CAF50,stroke:#fff,color:#fff style OS fill:#2c3e50,stroke:#fff,color:#fff style CDN fill:#e94560,stroke:#fff,color:#fff

Hình 1: Anti-pattern vs Best Practice — Direct Upload tới Object Storage

2. Presigned URL — Bí mật của Direct Upload an toàn

Presigned URL (hay Signed URL / SAS Token) là cơ chế cho phép client upload hoặc download file trực tiếp tới object storage mà không cần đi qua application server, đồng thời không cần cấp credential cho client. Server tạo ra một URL có chữ ký số (HMAC-SHA256), client dùng URL này để PUT/GET object trong thời gian giới hạn.

2.1. Luồng hoạt động của Presigned URL Upload

sequenceDiagram
    participant C as Client (Browser)
    participant A as API Server
    participant S as Object Storage (S3/R2)

    C->>A: POST /api/upload/init
{filename, contentType, size} Note over A: Validate: file type, size limit,
user quota, auth check A->>S: Generate Presigned PUT URL
(expires: 15 min, max-size) S-->>A: Presigned URL + headers A-->>C: {uploadUrl, objectKey, headers} C->>S: PUT uploadUrl
Content-Type: image/jpeg
Body: raw file bytes S-->>C: 200 OK + ETag C->>A: POST /api/upload/confirm
{objectKey, etag} Note over A: Verify ETag, update DB,
trigger post-processing A-->>C: {fileUrl, metadata}

Hình 2: Sequence diagram — Presigned URL Upload flow

Tại sao cần bước Confirm?

Client có thể nhận Presigned URL nhưng không upload (URL expire) hoặc upload file khác (khác content-type). Bước confirm giúp server verify rằng file thực sự đã tồn tại trên storage, ETag khớp, và kích thước hợp lệ — sau đó mới ghi record vào database. Nếu không có bước này, database sẽ có các record trỏ tới object không tồn tại.

2.2. Presigned URL trên từng nền tảng

AWS S3 — PutObject Presigned URL:

// .NET 10 — AWS SDK for .NET
using Amazon.S3;
using Amazon.S3.Model;

var s3Client = new AmazonS3Client();

var request = new GetPreSignedUrlRequest
{
    BucketName = "my-upload-bucket",
    Key = $"uploads/{userId}/{Guid.NewGuid()}/{fileName}",
    Verb = HttpVerb.PUT,
    Expires = DateTime.UtcNow.AddMinutes(15),
    ContentType = contentType,
};
// Giới hạn kích thước upload
request.Headers["x-amz-content-sha256"] = "UNSIGNED-PAYLOAD";

string presignedUrl = s3Client.GetPreSignedURL(request);

Cloudflare R2 — S3-compatible API:

// R2 dùng S3-compatible API, chỉ cần đổi endpoint
var r2Client = new AmazonS3Client(
    new BasicAWSCredentials(r2AccessKey, r2SecretKey),
    new AmazonS3Config
    {
        ServiceURL = "https://<account-id>.r2.cloudflarestorage.com",
        ForcePathStyle = true
    }
);

var request = new GetPreSignedUrlRequest
{
    BucketName = "my-r2-bucket",
    Key = $"uploads/{objectKey}",
    Verb = HttpVerb.PUT,
    Expires = DateTime.UtcNow.AddMinutes(15),
    ContentType = contentType,
};

string presignedUrl = r2Client.GetPreSignedURL(request);

Azure Blob Storage — SAS Token:

// .NET 10 — Azure.Storage.Blobs SDK
using Azure.Storage.Blobs;
using Azure.Storage.Sas;

var blobClient = new BlobClient(connectionString, "uploads", objectKey);

var sasBuilder = new BlobSasBuilder
{
    BlobContainerName = "uploads",
    BlobName = objectKey,
    Resource = "b",
    ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(15)
};
sasBuilder.SetPermissions(BlobSasPermissions.Write | BlobSasPermissions.Create);

Uri sasUri = blobClient.GenerateSasUri(sasBuilder);

Lưu ý bảo mật Presigned URL

Presigned URL có thể bị leak qua browser history, proxy logs, hay Referer header. Luôn đặt expiry ngắn (5-15 phút), giới hạn content-typekích thước tối đa trong policy. Với S3, dùng x-amz-content-length-range condition trong POST policy để ngăn upload file quá lớn.

3. Multipart Upload — Xử lý file lớn hàng GB

Khi file vượt quá 100MB, single PUT request trở nên không đáng tin cậy — network timeout, connection reset, hay browser memory limit đều có thể khiến upload thất bại và phải làm lại từ đầu. Multipart Upload chia file thành nhiều part nhỏ (5MB - 5GB mỗi part), upload song song, và hỗ trợ retry từng part riêng lẻ.

3.1. Luồng Multipart Upload trên S3/R2

sequenceDiagram
    participant C as Client
    participant A as API Server
    participant S as S3/R2

    C->>A: POST /upload/multipart/init
{filename, size, contentType} A->>S: CreateMultipartUpload S-->>A: uploadId A-->>C: {uploadId, partSize, totalParts} loop Mỗi Part (song song) C->>A: GET /upload/multipart/presign
{uploadId, partNumber} A->>S: Generate Presigned URL cho part S-->>A: partPresignedUrl A-->>C: {partUrl} C->>S: PUT partUrl
Body: chunk bytes S-->>C: 200 + ETag end C->>A: POST /upload/multipart/complete
{uploadId, parts: [{partNumber, etag}]} A->>S: CompleteMultipartUpload
{parts} S-->>A: final ETag + location A-->>C: {fileUrl, metadata}

Hình 3: Multipart Upload flow — chia file thành nhiều part, upload song song

Cách chọn Part Size tối ưu

  1. Minimum: 5MB (giới hạn của S3/R2, trừ part cuối)
  2. Recommended: 8-16MB cho file < 1GB, 32-64MB cho file > 1GB
  3. Maximum: 5GB mỗi part, tối đa 10,000 parts
  4. Concurrency: 3-6 part song song trên mạng tốt, 1-2 part trên mạng yếu
  5. Adaptive: Theo dõi tốc độ upload và điều chỉnh concurrency tự động

3.2. Client-side implementation với JavaScript

class ChunkedUploader {
  constructor(file, { partSize = 8 * 1024 * 1024, concurrency = 4 } = {}) {
    this.file = file;
    this.partSize = partSize;
    this.concurrency = concurrency;
    this.totalParts = Math.ceil(file.size / partSize);
    this.completedParts = [];
    this.abortController = new AbortController();
  }

  async upload(apiBase) {
    // 1. Init multipart upload
    const { uploadId } = await fetch(`${apiBase}/upload/multipart/init`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        filename: this.file.name,
        contentType: this.file.type,
        size: this.file.size,
      }),
    }).then(r => r.json());

    // 2. Upload parts với concurrency limit
    const partNumbers = Array.from({ length: this.totalParts }, (_, i) => i + 1);
    await this.#uploadPartsWithConcurrency(apiBase, uploadId, partNumbers);

    // 3. Complete
    const result = await fetch(`${apiBase}/upload/multipart/complete`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ uploadId, parts: this.completedParts }),
    }).then(r => r.json());

    return result;
  }

  async #uploadPartsWithConcurrency(apiBase, uploadId, partNumbers) {
    const executing = new Set();
    for (const partNumber of partNumbers) {
      const promise = this.#uploadPart(apiBase, uploadId, partNumber)
        .then(() => executing.delete(promise));
      executing.add(promise);
      if (executing.size >= this.concurrency) {
        await Promise.race(executing);
      }
    }
    await Promise.all(executing);
  }

  async #uploadPart(apiBase, uploadId, partNumber, retries = 3) {
    const start = (partNumber - 1) * this.partSize;
    const end = Math.min(start + this.partSize, this.file.size);
    const chunk = this.file.slice(start, end);

    // Lấy presigned URL cho part
    const { partUrl } = await fetch(
      `${apiBase}/upload/multipart/presign?uploadId=${uploadId}&partNumber=${partNumber}`
    ).then(r => r.json());

    for (let attempt = 0; attempt < retries; attempt++) {
      try {
        const response = await fetch(partUrl, {
          method: 'PUT',
          body: chunk,
          signal: this.abortController.signal,
        });
        const etag = response.headers.get('ETag');
        this.completedParts.push({ partNumber, etag });
        return;
      } catch (err) {
        if (attempt === retries - 1) throw err;
        await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
      }
    }
  }

  abort() {
    this.abortController.abort();
  }
}

4. Tus Protocol — Chuẩn mở cho Resumable Upload

Multipart upload giải quyết vấn đề file lớn, nhưng vẫn có hạn chế: nếu browser bị đóng giữa chừng hoặc user chuyển mạng, client phải biết chính xác part nào đã upload thành công để resume — logic này khá phức tạp. Tus Protocol (viết tắt từ "transloadit upload server") là một giao thức mở xây dựng trên HTTP, cung cấp cơ chế resumable upload chuẩn hóa với API đơn giản.

graph LR
    subgraph "Tus Protocol Flow"
        A["POST /files
Upload-Length: 1GB"] -->|"201 Created
Location: /files/abc"| B["PATCH /files/abc
Upload-Offset: 0
Body: chunk 1"] B -->|"204 No Content
Upload-Offset: 50MB"| C["❌ Mất kết nối"] C -->|"HEAD /files/abc"| D["Server trả về
Upload-Offset: 50MB"] D -->|"PATCH /files/abc
Upload-Offset: 50MB
Body: chunk 2"| E["✅ Upload hoàn tất"] end style A fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style B fill:#4CAF50,stroke:#fff,color:#fff style C fill:#e94560,stroke:#fff,color:#fff style D fill:#ff9800,stroke:#fff,color:#fff style E fill:#4CAF50,stroke:#fff,color:#fff

Hình 4: Tus Protocol — Resume upload từ vị trí đã upload thành công

4.1. Các HTTP method trong Tus Protocol

Method Endpoint Mục đích Header quan trọng
POST /files Tạo upload mới Upload-Length, Upload-Metadata, Tus-Resumable
HEAD /files/{id} Kiểm tra offset hiện tại (resume) Tus-Resumable
PATCH /files/{id} Upload data từ offset Upload-Offset, Content-Type: application/offset+octet-stream
DELETE /files/{id} Hủy upload (Extension: Termination) Tus-Resumable
OPTIONS /files Discover server capabilities

4.2. Tus Extensions quan trọng

  • Creation: Cho phép tạo upload resource mới qua POST. Gần như bắt buộc cho mọi implementation.
  • Concatenation: Ghép nhiều upload song song thành 1 file, hỗ trợ parallel upload kiểu multipart.
  • Checksum: Verify data integrity mỗi PATCH request bằng CRC32, MD5, hoặc SHA1.
  • Expiration: Server thông báo thời điểm upload sẽ bị xóa nếu chưa hoàn thành — giúp client biết deadline.
  • Creation With Upload: Gộp POST + PATCH đầu tiên thành 1 request, giảm latency cho file nhỏ.

Tus đang được IETF chuẩn hóa

IETF draft "Resumable Uploads for HTTP" đang chuẩn hóa một phần của Tus Protocol thành RFC chính thức. Cloudflare Stream, Supabase Storage, Vimeo và nhiều nền tảng lớn đều hỗ trợ Tus. Thư viện Uppy (JavaScript) và tusd (Go server) là reference implementation phổ biến nhất.

5. So sánh S3 vs Cloudflare R2 vs Azure Blob Storage

Tiêu chí AWS S3 Cloudflare R2 Azure Blob Storage
Giá Storage $0.023/GB/tháng (Standard) $0.015/GB/tháng $0.018/GB/tháng (Hot)
Egress Fee $0.09/GB (sau 100GB free) $0 — Miễn phí hoàn toàn $0.087/GB
Free Tier 5GB storage, 20K GET, 2K PUT/tháng (12 tháng) 10GB storage, 10M GET, 1M PUT/tháng (vĩnh viễn) 5GB LRS Hot, 20K GET, 10K Write/tháng (12 tháng)
Multipart Upload Có — max 10,000 parts, 5MB-5GB/part Có — S3-compatible API Có — Block Blob (max 50,000 blocks, 4GB/block)
Presigned URL Có — max 7 ngày expiry Có — S3-compatible SAS Token — cực kỳ granular
CDN tích hợp CloudFront (tính phí riêng) Tích hợp sẵn 300+ PoPs Azure CDN (tính phí riêng)
S3-compatible API Gốc Không (API riêng)
Object Lock (WORM) Chưa có Có (Immutable Blob Storage)
Versioning Chưa có (2026)
Event Notification S3 Event → Lambda/SQS/SNS R2 Event → Workers Event Grid → Azure Functions
Ecosystem Rộng nhất — Lambda, Athena, EMR Workers, D1, KV, AI Gateway Functions, Cognitive Services, Synapse

Khi nào chọn nền tảng nào?

Cloudflare R2 là lựa chọn tốt nhất cho workload egress-heavy — media delivery, CDN origin, public assets. Tiết kiệm tới 98-99% chi phí bandwidth so với S3. AWS S3 phù hợp khi bạn đã trong hệ sinh thái AWS và cần tính năng nâng cao (Object Lock, S3 Select, Glacier). Azure Blob tương tự với hệ sinh thái Azure — tích hợp sâu với Azure Functions, Cognitive Services.

6. Kiến trúc File Upload System cho Production

Một hệ thống file upload production-ready cần giải quyết nhiều vấn đề ngoài việc "PUT file lên storage": validation, virus scanning, image processing, metadata indexing, access control, và cleanup orphaned files.

graph TB
    subgraph "Client Layer"
        CL["Web/Mobile Client
Uppy + Tus/Multipart"] end subgraph "API Layer" AG["API Gateway
Rate Limiting + Auth"] US["Upload Service
.NET 10 Minimal API"] end subgraph "Storage Layer" TB["Temp Bucket
(raw uploads)"] PB["Production Bucket
(processed files)"] end subgraph "Processing Pipeline" EV["Event Notification
S3 Event / R2 Workers"] VS["Virus Scanner
ClamAV / Defender"] IP["Image Processor
Resize, WebP, AVIF"] MI["Metadata Indexer
DB + Search Index"] end subgraph "Delivery Layer" CDN["CDN Edge
CloudFront / Cloudflare"] TF["Transform on-the-fly
Image Resize URL"] end CL -->|"1. Init Upload"| AG AG --> US US -->|"2. Presigned URL"| CL CL -->|"3. Direct Upload"| TB TB -->|"4. Event Trigger"| EV EV --> VS VS -->|"Clean"| IP IP -->|"Processed"| PB IP --> MI VS -->|"Infected → Delete"| TB PB --> CDN CDN --> TF TF -->|"Optimized delivery"| CL style CL fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50 style AG fill:#2c3e50,stroke:#fff,color:#fff style US fill:#2c3e50,stroke:#fff,color:#fff style TB fill:#ff9800,stroke:#fff,color:#fff style PB fill:#4CAF50,stroke:#fff,color:#fff style EV fill:#e94560,stroke:#fff,color:#fff style VS fill:#e94560,stroke:#fff,color:#fff style IP fill:#e94560,stroke:#fff,color:#fff style MI fill:#e94560,stroke:#fff,color:#fff style CDN fill:#4CAF50,stroke:#fff,color:#fff style TF fill:#4CAF50,stroke:#fff,color:#fff

Hình 5: Kiến trúc File Upload System hoàn chỉnh cho production

6.1. Temp Bucket vs Production Bucket Pattern

Đây là pattern quan trọng nhất trong file upload system design. Thay vì upload thẳng vào production bucket, client upload vào temp bucket trước. Sau khi qua virus scan + processing, file mới được move sang production bucket. Lợi ích:

  • Cách ly an toàn: File chưa scan không bao giờ được serve qua CDN
  • Cleanup dễ: Lifecycle policy tự xóa file trong temp bucket sau 24h — xử lý orphaned uploads tự động
  • Permission tách biệt: Temp bucket cho phép write-only (presigned PUT), production bucket chỉ read-only cho CDN

6.2. Server-side Upload Service trên .NET 10

// .NET 10 Minimal API — Upload Service
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IAmazonS3>(sp =>
{
    // Hỗ trợ cả S3 và R2 qua config
    var config = sp.GetRequiredService<IConfiguration>();
    var s3Config = new AmazonS3Config
    {
        ServiceURL = config["Storage:Endpoint"],
        ForcePathStyle = true
    };
    return new AmazonS3Client(
        config["Storage:AccessKey"],
        config["Storage:SecretKey"],
        s3Config
    );
});

var app = builder.Build();

// Init multipart upload
app.MapPost("/api/upload/init", async (
    InitUploadRequest request,
    IAmazonS3 s3,
    HttpContext ctx) =>
{
    // Validate
    var allowedTypes = new[] { "image/jpeg", "image/png", "image/webp",
                               "application/pdf", "video/mp4" };
    if (!allowedTypes.Contains(request.ContentType))
        return Results.BadRequest("File type not allowed");

    if (request.Size > 5L * 1024 * 1024 * 1024) // 5GB max
        return Results.BadRequest("File too large");

    var objectKey = $"uploads/{ctx.User.FindFirst("sub")?.Value}" +
                    $"/{DateTime.UtcNow:yyyy/MM/dd}" +
                    $"/{Guid.NewGuid()}{Path.GetExtension(request.FileName)}";

    if (request.Size > 100 * 1024 * 1024) // > 100MB → multipart
    {
        var initResponse = await s3.InitiateMultipartUploadAsync(
            new InitiateMultipartUploadRequest
            {
                BucketName = "temp-uploads",
                Key = objectKey,
                ContentType = request.ContentType,
            });

        var partSize = request.Size > 1024 * 1024 * 1024
            ? 64 * 1024 * 1024   // 64MB cho file > 1GB
            : 8 * 1024 * 1024;   // 8MB cho file < 1GB

        return Results.Ok(new
        {
            Mode = "multipart",
            UploadId = initResponse.UploadId,
            ObjectKey = objectKey,
            PartSize = partSize,
            TotalParts = (int)Math.Ceiling((double)request.Size / partSize),
        });
    }

    // File nhỏ → single presigned URL
    var presignedUrl = s3.GetPreSignedURL(new GetPreSignedUrlRequest
    {
        BucketName = "temp-uploads",
        Key = objectKey,
        Verb = HttpVerb.PUT,
        Expires = DateTime.UtcNow.AddMinutes(15),
        ContentType = request.ContentType,
    });

    return Results.Ok(new
    {
        Mode = "single",
        UploadUrl = presignedUrl,
        ObjectKey = objectKey,
    });
});

// Presign từng part cho multipart
app.MapGet("/api/upload/presign-part", async (
    string uploadId, string objectKey, int partNumber,
    IAmazonS3 s3) =>
{
    var presignedUrl = s3.GetPreSignedURL(new GetPreSignedUrlRequest
    {
        BucketName = "temp-uploads",
        Key = objectKey,
        Verb = HttpVerb.PUT,
        Expires = DateTime.UtcNow.AddMinutes(30),
    });
    // Thêm uploadId và partNumber vào URL query
    var uri = new UriBuilder(presignedUrl);
    uri.Query += $"&uploadId={uploadId}&partNumber={partNumber}";

    return Results.Ok(new { PartUrl = uri.ToString() });
});

// Complete multipart
app.MapPost("/api/upload/complete", async (
    CompleteUploadRequest request,
    IAmazonS3 s3,
    AppDbContext db) =>
{
    if (request.Mode == "multipart")
    {
        await s3.CompleteMultipartUploadAsync(
            new CompleteMultipartUploadRequest
            {
                BucketName = "temp-uploads",
                Key = request.ObjectKey,
                UploadId = request.UploadId,
                PartETags = request.Parts
                    .Select(p => new PartETag(p.PartNumber, p.ETag))
                    .ToList(),
            });
    }

    // Ghi metadata vào DB — file sẽ được processing pipeline xử lý
    var fileRecord = new FileUpload
    {
        ObjectKey = request.ObjectKey,
        Status = FileStatus.Pending,
        UploadedAt = DateTime.UtcNow,
    };
    db.FileUploads.Add(fileRecord);
    await db.SaveChangesAsync();

    return Results.Ok(new { FileId = fileRecord.Id, Status = "processing" });
});

app.Run();

7. Post-Upload Processing Pipeline

File upload thành công chỉ là bước đầu. Một pipeline xử lý sau upload điển hình bao gồm:

graph LR
    A["File uploaded
→ Temp Bucket"] --> B["Virus Scan
ClamAV"] B -->|"Clean"| C["Content Validation
Magic bytes check"] B -->|"Infected"| X["🗑️ Delete +
Alert"] C -->|"Valid"| D["Image Processing
Resize + WebP/AVIF"] C -->|"Invalid"| X D --> E["Move to
Production Bucket"] E --> F["Update DB
Status: Active"] F --> G["Invalidate CDN
Cache nếu cần"] style A fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50 style B fill:#ff9800,stroke:#fff,color:#fff style C fill:#ff9800,stroke:#fff,color:#fff style D fill:#2c3e50,stroke:#fff,color:#fff style E fill:#4CAF50,stroke:#fff,color:#fff style F fill:#4CAF50,stroke:#fff,color:#fff style G fill:#4CAF50,stroke:#fff,color:#fff style X fill:#e94560,stroke:#fff,color:#fff

Hình 6: Post-upload processing pipeline

7.1. Virus Scanning — Không thể bỏ qua

Bất kỳ file nào user upload đều có thể chứa malware. Các giải pháp virus scan cho object storage:

Giải pháp Loại Tích hợp Chi phí
ClamAV Open-source S3 Event → Lambda → ClamAV container Chỉ phí compute
Amazon GuardDuty Malware Protection Managed Native S3 integration $0.60/GB scanned
Microsoft Defender for Storage Managed Native Azure Blob integration $0.15/10K transactions
Cloudflare Workers + ClamAV API Hybrid R2 Event Notification → Worker Workers pricing + ClamAV hosting

7.2. Content Validation — Không tin Content-Type header

Client có thể gửi Content-Type: image/jpeg nhưng thực tế là file .exe. Luôn kiểm tra magic bytes (file signature) của file thực tế:

public static class FileSignatureValidator
{
    private static readonly Dictionary<string, byte[][]> FileSignatures = new()
    {
        ["image/jpeg"] = [new byte[] { 0xFF, 0xD8, 0xFF }],
        ["image/png"] = [new byte[] { 0x89, 0x50, 0x4E, 0x47 }],
        ["application/pdf"] = [new byte[] { 0x25, 0x50, 0x44, 0x46 }],
        ["video/mp4"] = [
            new byte[] { 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70 },
            new byte[] { 0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70 },
            new byte[] { 0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70 },
        ],
    };

    public static bool IsValid(Stream fileStream, string declaredContentType)
    {
        if (!FileSignatures.TryGetValue(declaredContentType, out var signatures))
            return false;

        var headerBytes = new byte[8];
        fileStream.Read(headerBytes, 0, 8);
        fileStream.Position = 0;

        return signatures.Any(sig =>
            headerBytes.Take(sig.Length).SequenceEqual(sig));
    }
}

8. CDN Integration và Delivery Optimization

Sau khi file đã qua processing và nằm trong production bucket, bước cuối là serve hiệu quả qua CDN. Mỗi nền tảng có cách tiếp cận riêng:

8.1. Cloudflare R2 + Custom Domain

R2 tích hợp sẵn với Cloudflare CDN — chỉ cần connect custom domain là file được cache tại 300+ edge location, không tốn egress fee. Đây là lợi thế cạnh tranh lớn nhất của R2.

# Cấu hình R2 public bucket với custom domain
# Trong Cloudflare Dashboard: R2 → Bucket Settings → Public Access
# Hoặc qua Wrangler CLI:
npx wrangler r2 bucket create production-assets
npx wrangler r2 bucket domain add production-assets --domain assets.example.com

8.2. S3 + CloudFront

# CloudFront distribution cho S3 bucket
# Origin Access Control (OAC) — thay thế Origin Access Identity (OAI)
aws cloudfront create-distribution \
  --origin-domain-name my-bucket.s3.amazonaws.com \
  --default-root-object index.html \
  --cache-policy-id "658327ea-f89d-4fab-a63d-7e88639e58f6" \
  --origin-access-control-id "E2..."

8.3. Image Optimization on-the-fly

Thay vì pre-generate nhiều kích thước ảnh, dùng transform URL để resize + convert format tại edge:

Dịch vụ URL Pattern Chi phí
Cloudflare Images /cdn-cgi/image/width=400,format=auto/path $0.50/1000 unique transforms
AWS Lambda@Edge Custom (via CloudFront Function) Lambda pricing + CF pricing
Azure CDN Rules Engine Custom (via Azure Functions) Functions pricing + CDN pricing
imgproxy (self-hosted) /resize:fit:400/plain/s3://bucket/path Chỉ phí hosting (open-source)

Cache-Control Header cho static assets

Khi object key chứa content hash (ví dụ avatar-a1b2c3d4.webp), set Cache-Control: public, max-age=31536000, immutable — cache vĩnh viễn tại CDN edge, tránh revalidation request. Khi object key là mutable (ví dụ profile/123/avatar.jpg), dùng Cache-Control: public, max-age=3600, s-maxage=86400 kết hợp CDN cache invalidation khi user update.

9. Security và Access Control nâng cao

9.1. Signed URL cho Private Content

Không phải file nào cũng public. Với private content (tài liệu nội bộ, hóa đơn, báo cáo), dùng signed download URL có thời hạn ngắn:

// Generate signed download URL — chỉ khi user có quyền
app.MapGet("/api/files/{fileId}/download", async (
    int fileId,
    IAmazonS3 s3,
    AppDbContext db,
    HttpContext ctx) =>
{
    var file = await db.FileUploads.FindAsync(fileId);
    if (file is null) return Results.NotFound();

    // Kiểm tra quyền truy cập
    var userId = ctx.User.FindFirst("sub")?.Value;
    if (file.OwnerId != userId && !ctx.User.IsInRole("Admin"))
        return Results.Forbid();

    var presignedUrl = s3.GetPreSignedURL(new GetPreSignedUrlRequest
    {
        BucketName = "production-assets",
        Key = file.ObjectKey,
        Verb = HttpVerb.GET,
        Expires = DateTime.UtcNow.AddMinutes(5),
        ResponseHeaderOverrides = new ResponseHeaderOverrides
        {
            ContentDisposition = $"attachment; filename=\"{file.OriginalName}\"",
        },
    });

    return Results.Redirect(presignedUrl);
});

9.2. CORS Configuration

Để browser upload trực tiếp tới S3/R2, bucket cần cấu hình CORS đúng:

{
    "CORSRules": [
        {
            "AllowedOrigins": ["https://example.com"],
            "AllowedMethods": ["PUT", "POST"],
            "AllowedHeaders": ["Content-Type", "x-amz-*"],
            "ExposeHeaders": ["ETag", "x-amz-request-id"],
            "MaxAgeSeconds": 3600
        }
    ]
}

Không dùng AllowedOrigins: ["*"] trong production

Wildcard CORS cho phép bất kỳ domain nào upload file vào bucket của bạn thông qua presigned URL bị leak. Luôn whitelist chính xác domain của ứng dụng. Nếu có nhiều domain (staging, production), liệt kê từng domain riêng.

10. Lifecycle Policy và Cleanup Strategy

Một trong những chi phí ẩn lớn nhất của object storage là orphaned files — file được upload nhưng không bao giờ được confirm, incomplete multipart upload, hay file bị xóa trong app nhưng chưa xóa trên storage.

10.1. S3 Lifecycle Rules

{
    "Rules": [
        {
            "ID": "cleanup-temp-uploads",
            "Filter": { "Prefix": "uploads/" },
            "Status": "Enabled",
            "Expiration": { "Days": 1 },
            "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
        },
        {
            "ID": "archive-old-files",
            "Filter": { "Prefix": "production/" },
            "Status": "Enabled",
            "Transitions": [
                { "Days": 90, "StorageClass": "STANDARD_IA" },
                { "Days": 365, "StorageClass": "GLACIER_IR" }
            ]
        }
    ]
}

10.2. Background Job dọn orphaned files

// Background job chạy hàng ngày — xóa file không có record trong DB
public class OrphanedFileCleanupJob(
    IAmazonS3 s3,
    AppDbContext db,
    ILogger<OrphanedFileCleanupJob> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromHours(6));
        while (await timer.WaitForNextTickAsync(ct))
        {
            var request = new ListObjectsV2Request
            {
                BucketName = "production-assets",
                Prefix = "production/",
            };

            ListObjectsV2Response response;
            do
            {
                response = await s3.ListObjectsV2Async(request, ct);
                var keys = response.S3Objects.Select(o => o.Key).ToList();

                var existingKeys = await db.FileUploads
                    .Where(f => keys.Contains(f.ObjectKey))
                    .Select(f => f.ObjectKey)
                    .ToListAsync(ct);

                var orphaned = keys.Except(existingKeys).ToList();
                foreach (var key in orphaned)
                {
                    await s3.DeleteObjectAsync("production-assets", key, ct);
                    logger.LogInformation("Deleted orphaned file: {Key}", key);
                }

                request.ContinuationToken = response.NextContinuationToken;
            } while (response.IsTruncated);
        }
    }
}

11. Performance Monitoring và Metrics

P95 < 3sUpload latency cho file < 10MB
99.9%Upload success rate mục tiêu
< 5minProcessing pipeline SLA
< 50msCDN cache hit latency

Các metric quan trọng cần monitor:

  • Upload success rate: Tỷ lệ upload hoàn thành / upload khởi tạo. Nếu < 95%, kiểm tra network timeout, presigned URL expiry quá ngắn, hoặc CORS misconfiguration.
  • Upload latency P50/P95/P99: Theo dõi theo file size bucket. Latency cao bất thường có thể do region mismatch giữa user và bucket.
  • Processing pipeline duration: Thời gian từ upload hoàn thành → file sẵn sàng serve. Bottleneck thường ở virus scanning.
  • Storage cost trend: Theo dõi tổng storage size + egress bandwidth. Set alert khi chi phí tăng > 20% so với tháng trước.
  • Orphaned file count: Số file trên storage không có record trong DB. Cleanup job nên giữ con số này gần 0.

12. Tổng kết Best Practices

Checklist thiết kế File Upload System

  1. Luôn dùng Direct Upload (Presigned URL) — không proxy file qua application server
  2. Multipart cho file > 100MB — chia thành 8-64MB parts, upload song song
  3. Temp → Production bucket pattern — cách ly file chưa scan/process
  4. Virus scan bắt buộc — ClamAV (free) hoặc managed service
  5. Validate magic bytes — không tin Content-Type header từ client
  6. Lifecycle policy — auto-delete temp files, archive old files, abort incomplete multipart
  7. CORS whitelist — không dùng wildcard origin trong production
  8. CDN với immutable cache — content-hash trong object key, Cache-Control: immutable
  9. Monitor upload success rate — alert khi < 95%
  10. Xem xét Tus Protocol — khi cần resumable upload cross-session (mobile app, file rất lớn)

Thiết kế File Upload System không chỉ là "upload file lên cloud" — nó là bài toán tổng hợp của system design (scalability, reliability), security (virus scan, access control), cost optimization (egress fee, storage tiering), và user experience (resumable, progress tracking). Với kiến trúc Presigned URL + Temp Bucket + Processing Pipeline + CDN Delivery được trình bày trong bài, bạn có một nền tảng vững chắc để xây dựng hệ thống xử lý file quy mô từ startup đến enterprise.

Nguồn tham khảo