CDN tĩnh chỉ serve file. Lambda + CloudFront serve từng phiên bản của file đó. Bài này đi sâu vào bài toán resize và chuyển đổi format ảnh theo yêu cầu: kiến trúc tổng thể, Lambda handler, CDK stack, và năm cái bẫy kinh điển mà ai cũng dính ít nhất một lần.
Vấn Đề Của File Tĩnh

CDN truyền thống về bản chất là một cái cache byte-perfect. Upload file lên, nó serve file đó — không hơn không kém. Và nó làm điều đó rất tốt. Nhưng khi nói đến việc phân phối ảnh, yêu cầu thực tế phức tạp hơn nhiều:
- Ảnh hero 1200px trên desktop nên chỉ nặng khoảng 120KB và ở định dạng WebP
- Một cái thumbnail avatar 40px không có lý do gì phải kéo theo 800px pixel thừa
- Cùng một asset lại cần kích thước khác nhau tùy từng ngữ cảnh hiển thị
Cách làm phổ biến nhất là pre-generate sẵn các biến thể: image-800w.jpg, image-400w.jpg, image-200w.jpg. Với vài ba cái ảnh thì còn tạm ổn. Nhưng khi site có hàng chục bài viết, mỗi bài lại nhiều ảnh, việc duy trì đống file này nhanh chóng trở thành cơn ác mộng — chưa kể bạn vẫn không thể đáp ứng các kích thước tùy ý mà layout tương lai có thể cần đến.
Thứ lý tưởng hơn là một hệ thống tự tạo ra đúng biến thể cần thiết ngay lần đầu được request, rồi cache kết quả đó vĩnh viễn. Đó chính xác là thứ Lambda cho phép bạn xây dựng.
Kiến Trúc Tổng Thể

Pattern này gồm ba thành phần phối hợp với nhau:
Browser
│
▼
CloudFront (CDN cache)
│ cache MISS
▼
API Gateway (HTTP proxy)
│
▼
Lambda (resize + convert)
│
▼
S3 (ảnh gốc)Khi xảy ra cache miss, CloudFront forward request sang API Gateway, rồi API Gateway invoke Lambda. Lambda kéo ảnh gốc từ S3, transform nó bằng sharp, và trả về binary response được encode dưới dạng base64. CloudFront cache kết quả đó — với key là URL path kết hợp với query parameter — trong tối đa một năm. Từ lần thứ hai trở đi, mọi request cho cùng ảnh + kích thước đó đều hit cache, Lambda không cần chạy nữa.
Nói ngắn gọn: Lambda chỉ chạy đúng một lần cho mỗi biến thể. Mọi thứ sau đó là cache hit.

Lambda Function
Handler nhận vào một API Gateway proxy event. Path của ảnh lấy từ URL, còn các tham số transform lấy từ query string.
import sharp from 'sharp';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
const s3 = new S3Client({});
const SOURCE_BUCKET = process.env.SOURCE_BUCKET!;
const AUTO_WEBP = process.env.AUTO_WEBP === 'Yes';
const MAX_DIMENSION = 3000;
// ...Một vài quyết định thiết kế đáng chú ý:
withoutEnlargement: true — sharp sẽ không upscale ảnh vượt quá kích thước gốc. Gửi request ?w=5000 lên một ảnh chỉ có 400px? Bạn nhận lại đúng cái 400px gốc, thay vì một bản phóng to mờ nhòe.
MAX_DIMENSION = 3000 — một giới hạn an toàn cần thiết. Không có nó, một request độc hại kiểu ?w=99999&h=99999 có thể khiến Lambda cấp phát buffer khổng lồ rồi timeout hoặc OOM ngay lập tức.
Cache-Control: public, max-age=31536000, immutable — một khi CloudFront đã cache response này, nó sẽ serve mà không cần revalidation trong vòng một năm. Điều này hoàn toàn hợp lý vì cache key đã bao gồm tất cả tham số transform — kích thước khác đồng nghĩa URL khác.
isBase64Encoded: true — API Gateway yêu cầu binary response phải được base64-encode. CloudFront nhận về rồi decode lại, gửi raw byte tới browser.
Hạ Tầng: CDK Stack
Lambda function chỉ phát huy tác dụng khi có hạ tầng phù hợp bao quanh. CDK stack kết nối ba tài nguyên: Lambda function, API Gateway proxy, và CloudFront distribution đứng phía trước để cache mọi thứ.
export class ImageResizerStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: cdk.StackProps = {}) {
// Lambda: 1GB memory cho sharp, timeout 29s
// (giới hạn cứng của API Gateway là 30s)
// API Gateway: bắt buộc khai báo binaryMediaTypes
// để truyền image bytes qua được
// Cache policy: key theo tất cả query params, TTL dài
}
}Tại sao chọn API Gateway thay vì Function URL?
Lambda Function URL đơn giản hơn, nhưng API Gateway có một tính năng cốt lõi: binaryMediaTypes: ['*/*']. Thiếu khai báo này, API Gateway sẽ base64-decode cái body trước khi forward, rồi re-encode lại kết quả Lambda trả về — và thế là dữ liệu ảnh nhị phân bị hỏng hoàn toàn. Request ảnh vẫn trả về 200, nhưng là 200 với byte rác bên trong.
Function URL xử lý binary đúng mà không cần cấu hình thêm, nhưng lại không thể đặt sau CloudFront làm standard origin một cách dễ dàng. API Gateway thì có thể.
CloudFront Function: Lọc Query Parameter
Đây là một vấn đề tinh tế cần xử lý: nếu tất cả query parameter đều được đưa vào cache key, người dùng chỉ cần thêm ?utm_source=twitter vào URL ảnh là đã tạo ra một cache miss — dù biến thể đó thực ra đã được cache rồi, chỉ thiếu cái UTM tag vô nghĩa đó. Kết quả là bạn cache cùng một ảnh dưới hàng trăm key khác nhau.
Giải pháp là một CloudFront Function (không phải Lambda@Edge — nó chạy trong microsecond ngay tại edge, trước khi cache được tra cứu) để lọc sạch các parameter không liên quan:
function handler(event) {
var request = event.request;
var allowed = {
w: true, h: true, f: true, fit: true, q: true,
rotate: true, flip: true, flop: true, grayscale: true
};
var qs = request.querystring;
for (var key in qs) {
if (!allowed[key]) delete qs[key];
}
return request;
}Function này chạy trên mọi request tại mọi edge location, trước khi CloudFront tra cache. Parameter lạ bị loại bỏ; cache key luôn sạch và nhất quán.
Cách Request Ảnh
Sau khi deploy xong, ảnh được request đơn giản qua URL parameter:
| Trường hợp | URL |
|---|---|
| Bản gốc | https://cdn.your-domain.com/blogs/post-slug/hero.jpg |
| Rộng 800px | ...hero.jpg?w=800 |
| 400px WebP | ...hero.jpg?w=400&f=webp |
| Thumbnail, crop vuông | ...hero.jpg?w=120&h=120&fit=cover&f=webp |
| Grayscale | ...hero.jpg?grayscale=true&f=webp&q=75 |
Trong React, bạn có thể dựng responsive image với srcset như sau:
function BlogImage({ slug, file, alt }: { slug: string; file: string; alt: string }) {
const cdn = 'https://cdn.your-domain.com';
const base = `${cdn}/blogs/${slug}/${file}`;
return (
<img
src={`${base}?w=800&f=webp`}
srcSet={`
${base}?w=400&f=webp 400w,
${base}?w=800&f=webp 800w,
${base}?w=1200&f=webp 1200w
`}
sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
alt={alt}
loading="lazy"
decoding="async"
/>
);
}Browser tự chọn biến thể phù hợp theo viewport. Mỗi biến thể chỉ được generate một lần, sau đó cache vĩnh viễn.
Năm Cái Bẫy
1. Quên đặt binaryMediaTypes trên API Gateway
Lỗi phổ biến nhất, và cũng khó debug nhất vì nó không báo lỗi rõ ràng. Thiếu binaryMediaTypes: ['*/*'], API Gateway xử lý response của Lambda như text, double-encode cái base64 body, và browser nhận về một file bị hỏng. Request ảnh vẫn trả về status 200 — nhưng nội dung là byte rác.
Fix: Luôn khai báo binaryMediaTypes khi Lambda trả về binary content qua API Gateway.
2. Lambda timeout quá ngắn so với thời gian fetch S3
Với ảnh gốc nặng trên cold start, riêng bước fetch S3 đã có thể ngốn vài giây. API Gateway có giới hạn cứng 30 giây — vì vậy nên đặt Lambda timeout là 29 giây để an toàn. Tuy nhiên, response 29 giây rõ ràng là không thể chấp nhận về trải nghiệm người dùng. Cách giảm thiểu là pre-warming: chủ động request các biến thể phổ biến trước khi chúng xuất hiện trên production URL.
Fix: Đặt Lambda timeout là 29s, memory là 1024MB — sharp tốn CPU đáng kể, và CPU của Lambda tăng tỉ lệ thuận với memory.
3. Không giới hạn kích thước đầu vào
Không có cap cho ?w=, nghĩa là bất kỳ ai cũng có thể gửi request với kích thước tùy ý. Một cái ?w=10000&h=10000 buộc sharp cấp phát buffer ~300MB rồi xử lý nó. Với Lambda 1GB memory, kết quả gần như chắc chắn là timeout hoặc crash.
Fix: Enforce MAX_DIMENSION trong handler. 3000px là đủ cho mọi use case thực tế.
4. Cache luôn cả error response
Nếu Lambda trả về 500 và CloudFront vô tình cache nó, mọi request tiếp theo cho URL đó đều nhận về lỗi — có thể kéo dài nhiều ngày. Mặc định CloudFront không cache 5xx, nhưng một số cấu hình có thể thay đổi hành vi này.
Fix: Đặt minimum TTL cho error response trong cache policy về 0. Hoặc, thay vì trả về 5xx, trả về 200 kèm một ảnh placeholder — response luôn hợp lệ để cache.
5. Serve biến thể chưa được cache trên production
Nếu HTML của bạn có <img src="...?w=800&f=webp"> mà biến thể đó chưa từng được request, người dùng đầu tiên load trang sẽ phải ngồi đợi Lambda cold start trong khi ảnh được generate. Đây không phải bug — đó là cơ chế thiết kế — nhưng hậu quả là visitor đầu tiên sau mỗi lần deploy sẽ thấy trang load chậm hơn hẳn.
Fix: Thêm một bước vào quy trình deploy để pre-warm các biến thể quan trọng: sau khi deploy xong, tự động fetch các biến thể WebP 400w, 800w, 1200w của từng ảnh trước khi traffic thật sự đổ vào.
Pattern Này Phù Hợp Với Ai?
On-demand transform qua Lambda + CloudFront là lựa chọn tốt khi:
- Thư viện ảnh thay đổi thường xuyên và việc pre-generate biến thể là không thực tế
- Bạn cần hỗ trợ kích thước tùy ý — ví dụ
srcsettrên nhiều breakpoint - Bạn muốn format negotiation (WebP cho browser hiện đại, JPEG cho browser cũ hơn)
- Chi phí storage là mối quan tâm: lưu một bản gốc thay vì hàng chục file dẫn xuất
Ngược lại, nó kém phù hợp khi:
- Ảnh luôn được request ở cùng vài kích thước cố định — pre-generate lúc upload là đủ
- Bạn cần transform trong dưới 10ms — Lambda vẫn thêm latency khi cache miss dù đã warm
- Traffic thấp đến mức chi phí S3 + Lambda + API Gateway còn đắt hơn một CDN đơn giản
Chi Phí Thực Tế
Với pattern này, chi phí Lambda thấp theo cách có thể tính toán trước được. Giả sử CDN đạt cache hit rate 95% sau vài ngày đầu — Lambda chỉ xử lý 5% request ảnh. Với 1024MB memory và execution time trung bình 2 giây, 1 triệu image request = 50.000 Lambda invocation = khoảng $0.10 tiền compute. API Gateway thêm $0.035 mỗi 1.000 lần gọi.
Chi phí đáng kể nhất vẫn là CloudFront — nhưng dù có Lambda hay không, bạn cũng đã cần CloudFront rồi.
Pattern này khấu hao qua tiết kiệm bandwidth. Một ảnh JPEG 3.2MB được giao dưới dạng WebP 80KB đúng kích thước tiết kiệm 97.5% data transfer. Với egress rate $0.085/GB của CloudFront, ở quy mô đủ lớn thì đây là khoản tiết kiệm thực sự đáng kể.
Tóm Lại
CDN tĩnh serve file. Lambda + CloudFront serve từng phiên bản của file đó.
Kiến trúc không phức tạp: Lambda đứng sau API Gateway, lấy ảnh gốc từ S3, transform bằng sharp, rồi trả về binary response với cache header dài hạn. CloudFront cache từng biến thể theo path và query parameter, sau đó serve từ edge cho tất cả các request tiếp theo. Lambda chỉ chạy đúng một lần cho mỗi biến thể.
Kết quả cuối cùng là một CDN hoạt động như thể bạn đã pre-generate mọi tổ hợp kích thước và format có thể — mà không cần thực sự làm vậy. Bạn lưu bản gốc, request cái bạn cần, và phần còn lại thì cứ để cache lo.
