Đừng để Web Server hứng File Upload: Kỹ thuật Direct-to-Storage bằng Presigned URL
Mô hình tải tệp truyền thống thường đi theo đường vòng: Client đẩy file từ 50MB-200MB lên web server (Node.js/Python), Server tạm lưu vào RAM/ổ cứng đệm, rồi mới đẩy tiếp lên Object Storage (S3, Cloudflare R2).
Khi có nhiều người dùng upload video hoặc file nén dung lượng lớn, RAM của máy chủ sẽ chạm trần ngay lập tức (OMM Crash), trong khi toàn bộ băng thông bị giữ cứng khiến các Request API thông thường bị treo.
Dưới đây là kỹ thuật Direct-to-Storage (Tải thẳng từ trình duyệt lên Storage) qua Presigned URL, biến Web Server thành “người cấp vé” thay vì làm “phu khuân vác”.
Luồng xử lý: Truyền thống vs. Direct Upload
Thay vì để dữ liệu nhị phân (binary) chạy qua ứng dụng, hãy để trình duyệt giao tiếp trực tiếp với Object Storage:
[Mô hình truyền thống (Server nghẽn nặng)]
Client ──► [File 100MB qua Web Server] ──► [Object Storage] (Server tốn gấp đôi băng thông)
[Mô hình Direct Upload (Server thảnh thơi)]
1. Client ──► Xin Presigned URL (< 20ms) ──► Web Server (Chỉ ký vé, không nhận file)
2. Client ──► Đẩy thẳng File 100MB ──────────► S3 / Cloudflare R2 (Băng thông do Storage gánh)
3. Client ──► Báo hoàn tất (Metadata) ─────► Web Server ghi DB
1. Backend sinh Presigned URL an toàn (Dưới 30ms)
Web Server chỉ làm một nhiệm vụ duy nhất: kiểm tra quyền người dùng, sinh ra một đường dẫn có chữ ký số tạm thời (giới hạn thời gian khoảng 5-15 phút), và chỉ định rõ loại file được phép tải lên.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({
region: "auto",
endpoint: process.env.R2_ENDPOINT,
credentials: {
accessKeyId: process.env.STORAGE_ACCESS_KEY!,
secretAccessKey: process.env.STORAGE_SECRET_KEY!,
},
});
export async function createUploadUrl(userId: string, filename: string, mimeType: string) {
const key = `uploads/${userId}/${Date.now()}-${filename}`;
const command = new PutObjectCommand({
Bucket: process.env.STORAGE_BUCKET_NAME,
Key: key,
ContentType: mimeType,
});
const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 300 });
return { uploadUrl, key };
}
2. Client tải trực tiếp từ dữ liệu lên Storage.
Ở phía trình duyệt, dùng lệnh fetch hoặc XMLHttpRequest thông thường để đẩy trực tiếp file lên link vừa xin được bằng phương thức PUT:
async function uploadFileDirectly(file) {
const res = await fetch("/api/upload-ticket", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, mimeType: file.type })
});
const { uploadUrl, key } = await res.json();
const uploadRes = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file
});
if (!uploadRes.ok) throw new Error("Upload lên storage thất bại");
await fetch("/api/save-file-metadata", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileKey: key, size: file.size })
});
}
3. Ba chốt chặn bảo mật không thể bỏ qua
- Cấu hình CORS nghiệm ngặt chặn trên Bucket: Chỉ cho phép domain của bạn gửi request
PUTlên bucket. Tuyệt đối không đểAllowedOrigins: ["*"]. - Khóa Content-Length ở phía Storage Policy: Dùng
createPresignedPost(POST Policy) để khóa cứng kích thước file tối đa (ví dụ: từ 1MB đến 50MB). - Quét mã độc ngầm (Async Antivirus Scan): Dùng Event Notification kích hoạt Worker ngầm kiểm tra định dạng nhị phân và quét virus trước khi gắn trạng thái an toàn trong DB.
Đẩy việc vận chuyển file nặng sang đúng hạ tầng chuyên dụng giúp Web Server luôn nhẹ tênh, giữ vững tốc độ phản hồi sub-100ms ngay cả khi người dùng đồng loạt tải lên hàng chục gigabyte dữ liệu.


