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. Object Storage là gì và tại sao không nên lưu file trên application server?
- 2. Presigned URL — Bí mật của Direct Upload an toàn
- 3. Multipart Upload — Xử lý file lớn hàng GB
- 4. Tus Protocol — Chuẩn mở cho Resumable Upload
- 5. So sánh S3 vs Cloudflare R2 vs Azure Blob Storage
- 6. Kiến trúc File Upload System cho Production
- 7. Post-Upload Processing Pipeline
- 8. CDN Integration và Delivery Optimization
- 9. Security và Access Control nâng cao
- 10. Lifecycle Policy và Cleanup Strategy
- 11. Performance Monitoring và Metrics
- 12. Tổng kết Best Practices
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.
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-type và kí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
- Minimum: 5MB (giới hạn của S3/R2, trừ part cuối)
- Recommended: 8-16MB cho file < 1GB, 32-64MB cho file > 1GB
- Maximum: 5GB mỗi part, tối đa 10,000 parts
- Concurrency: 3-6 part song song trên mạng tốt, 1-2 part trên mạng yếu
- 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 | Có | Không (API riêng) |
| Object Lock (WORM) | Có | Chưa có | Có (Immutable Blob Storage) |
| Versioning | Có | Chưa có (2026) | Có |
| 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
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
- Luôn dùng Direct Upload (Presigned URL) — không proxy file qua application server
- Multipart cho file > 100MB — chia thành 8-64MB parts, upload song song
- Temp → Production bucket pattern — cách ly file chưa scan/process
- Virus scan bắt buộc — ClamAV (free) hoặc managed service
- Validate magic bytes — không tin Content-Type header từ client
- Lifecycle policy — auto-delete temp files, archive old files, abort incomplete multipart
- CORS whitelist — không dùng wildcard origin trong production
- CDN với immutable cache — content-hash trong object key, Cache-Control: immutable
- Monitor upload success rate — alert khi < 95%
- 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
- AWS Documentation — Uploading objects with presigned URLs
- Cloudflare — R2 vs AWS S3 comparison
- Tus.io — Resumable Upload Protocol 1.0
- Microsoft Learn — Grant limited access with SAS
- fourTheorem — The illustrated guide to S3 pre-signed URLs
- Uppy — Tus Plugin Documentation
- Bright Inventions — Improving S3 Large File Uploads
SQLite Edge Database 2026 — Khi database nhỏ gọn nhất chinh phục production
Bun Runtime 2026: Tại Sao JavaScript Runtime Này Đang Thay Đổi Cuộc Chơi?
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.