Ngày 12/10/2026 AWS ra mắt Lambda Web Functions — một kiểu Lambda mới mà code của mình chỉ việc listen trên một port như một HTTP server bình thường, không cần adapter, không cần handler event/context gì cả. Đọc announcement xong thì ngứa tay 😆, và nạn nhân đầu tiên là chính cái blog này.

Bài này ghi lại quá trình chuyển blog.hungran.cloud từ Cloudflare Pages sang CloudFront + Lambda Web Functions: kiến trúc, code, cách test local và mấy cái hố đã lỡ bước vào.

🤔 Lambda Web Functions là gì?⌗

Tóm tắt nhanh từ docs:

  • Code là một HTTP server Node.js bình thường (Express, Hono, Fastify, Next.js… hay node:http thuần đều được). Lambda báo cho mình host:port qua biến môi trường AWS_LAMBDA_HTTP_ENDPOINT.
  • Một execution environment phục vụ nhiều request đồng thời, giống web server thật → ít cold start hơn hẳn so với mô hình 1 request / 1 environment.
  • Mỗi endpoint có sẵn domain HTTPS dạng xxxx.lambda-web.<region>.on.aws, hỗ trợ streaming response.
  • Tính tiền theo request, CPU thực sự dùng, memory trong suốt vòng đời environment và data transfer — thời gian ngồi chờ I/O gần như không tốn CPU.
  • Có revision (snapshot bất biến của code + config) và endpoint route traffic tới 1–2 revision → canary / blue-green có sẵn (đúng bài Zero-Downtime Deployment hôm trước 😎).

Một vài giới hạn cần biết lúc mới ra mắt:

  • Hiện chỉ có runtime nodejs24.x, chạy trên Arm64.
  • Region: N. Virginia, Ohio, Oregon, Tokyo, Ireland → gần Việt Nam nhất là ap-northeast-1.
  • Endpoint không gắn custom domain trực tiếp được — muốn dùng domain riêng thì phải đặt CloudFront phía trước.
  • Server phải bắt đầu listen trong 10 giây kể từ lúc khởi động.

🏗️ Kiến trúc: trước và sau⌗

Trước:

git push ─▶ Cloudflare Pages (build Hugo) ─▶ Cloudflare CDN ─▶ blog.hungran.cloud

Sau:

git push ─▶ GitHub Actions (Hugo build → zip → aws lambda-web deploy)
                                     │
browser ─▶ Cloudflare DNS (grey cloud) ─▶ CloudFront (ACM cert, cache)
                                                 │  cache miss
                                                 ▼
                         Lambda Web Function endpoint (Tokyo, nodejs24.x)
                         └─ node index.js  ──  serve ./public

Hugo vẫn là static site generator, không đổi gì ở content/ hay theme. Thứ thay đổi là ai serve cái thư mục public/.

🧑‍💻 Server: ~120 dòng, không dependency⌗

Vì blog là static site nên không cần Express. Một file index.js dùng node:http thuần là đủ. Phần quan trọng nhất chỉ có vài dòng:

const [HOST, PORT] = (process.env.AWS_LAMBDA_HTTP_ENDPOINT || 'localhost:3000').split(':');

server.listen(Number(PORT), HOST, () => console.log(`serving on ${HOST}:${PORT}`));
process.on('SIGTERM', () => server.close(() => process.exit(0)));

Phần còn lại là những thứ mà Cloudflare Pages trước đây làm hộ mình, giờ phải tự làm:

  • Pretty URL kiểu Hugo: /about → 301 sang /about/ → trả about/index.html
  • Custom 404 dùng luôn 404.html của theme
  • ETag / 304 để browser và CloudFront revalidate rẻ
  • Brotli / gzip cho text (HTML, CSS, XML, SVG)
  • Cache-Control theo loại file: HTML s-maxage=300, asset max-age=86400, s-maxage=604800
  • Chặn path traversal (/../../etc/passwd → 404)
  • Redirect URL viết hoa (menu cũ có /About, Hugo lại xuất ra /about/)
  • /healthz cho smoke test

📦 Static content nằm ở đâu?⌗

Không có S3 bucket riêng cho website. Toàn bộ public/ được đóng gói chung với index.js thành một file zip:

  1. hugo --minify → public/
  2. Copy index.js + package.json + public/ vào một thư mục build → zip
  3. aws lambda-web deploy upload zip lên S3 bucket (có versioning) mà CLI tự tạo
  4. Lambda tạo revision từ zip đó; mỗi environment có một bản copy trên disk local và serve trực tiếp

Ưu điểm: code và content deploy và rollback cùng nhau — quay về revision cũ là quay về đúng bài viết cũ.

Nhược điểm: package hiện tại 26 MB (120 file), mà ảnh chiếm khoảng 96% (PNG ~17 MB, JPEG ~8 MB), còn HTML/CSS/XML chưa tới 1 MB 😅. Sửa một dấu phẩy cũng phải upload lại cả 26 MB. Bước tiếp theo chắc sẽ là convert ảnh sang WebP, hoặc tách posts/images/ ra S3 sau cùng một CloudFront.

🚀 Deploy⌗

Lần đầu deploy từ máy local, một lệnh là đủ:

aws lambda-web deploy \
  --name blog-hungran-cloud \
  --code .lambda-web-build \
  --endpoint-name prod \
  --create

Lệnh này tự tạo S3 bucket (versioning), IAM execution role (chỉ có quyền ghi CloudWatch Logs), Web Function, revision đầu tiên và một endpoint HomeRegion với autoDeploymentMode=LatestRevision — tức là revision mới Active xong là endpoint tự chuyển traffic sang.

Tất cả được gói trong lambda-web/deploy.sh (build Hugo → đóng gói → deploy → in ra domain của endpoint). CI chạy đúng script đó trên GitHub Actions, đăng nhập AWS bằng GitHub OIDC, không lưu access key nào trong repo.

🌐 CloudFront phía trước⌗

Vì endpoint không gắn custom domain được nên cần thêm CloudFront:

  • Origin: domain của endpoint, HTTPS only
  • Cache policy: CachingOptimized — tôn trọng Cache-Control từ server
  • Origin request policy: AllViewerExceptHostHeader — ⚠️ không forward header Host, nếu không endpoint sẽ không nhận ra request
  • Certificate: ACM ở us-east-1 (bắt buộc với CloudFront), validate bằng CNAME trên Cloudflare DNS
  • Cloudflare DNS: blog → CNAME tới dxxxx.cloudfront.net, để grey cloud (DNS only) — bật proxy cam lên là chồng 2 CDN, rắc rối thêm phần certificate và header

Với cache như trên, phần lớn request được CloudFront trả về luôn, Lambda chỉ nhận cache miss → với blog cá nhân thì chi phí compute gần như không đáng kể.

🧪 Test local thế nào?⌗

Không có sam local hay emulator nào cho Web Functions cả. Nhưng vì code chỉ là một HTTP server, cách AWS khuyến nghị là chạy đúng cái package sẽ deploy với biến môi trường được set sẵn:

AWS_LAMBDA_HTTP_ENDPOINT=127.0.0.1:3000 node index.js

Mình gói lại thành lambda-web/local-test.sh:

  1. Build Hugo, đóng gói và zip y hệt deploy.sh
  2. Giải nén vào một thư mục trống — không có repo, không có dev dependency
  3. Chạy trong container node:24 linux/arm64 (cùng Node version và CPU arch với Lambda) nếu có Docker/Colima
  4. Fail nếu server không listen trong 10 giây (giống giới hạn của Lambda)
  5. Bắn 17 HTTP check (trang chủ, bài viết, phân trang, tag, RSS, sitemap, favicon, CV PDF, 404, path traversal, /healthz…) + kiểm tra favicon có trong <head>
  6. 200 request với concurrency 50 để chắc một environment chịu được nhiều request cùng lúc
==> Result: 19 passed, 0 failed

Những thứ local không test được — IAM, trạng thái revision/endpoint, scaling, CloudFront — thì để smoke test trong CI kiểm sau khi deploy.

🕳️ Những cái hố⌗

1. Hugo version. Blog được viết cho Hugo 0.74 (2020). Máy mình cài Hugo mới nhất qua Homebrew (0.158+) và build… nổ ngay:

ERROR ... rss.xml:20:50: executing "rss.xml" at <.Site.Author.email>:
can't evaluate field Author in type page.Site

Hugo mới đã bỏ .Site.Author. Tệ hơn, có một lỗi im lặng: Hugo mới không còn đọc key paginate nữa → /page/2/ biến mất mà không báo lỗi gì 😱.

Cách xử lý:

  • Pin version: lambda-web/hugo.sh tự tải đúng Hugo 0.74.0 vào .bin/ (gitignore) và mọi script đều đi qua nó → máy local, CI và site đang chạy dùng cùng một version. Hugo 0.74 chỉ có bản Intel cho macOS nên trên Apple Silicon cần Rosetta.
  • Forward-compat: override layouts/_default/rss.xml ở project (không đụng vào theme submodule) để đọc params.author thay cho .Site.Author, và thêm [pagination] pagerSize = 5 bên cạnh paginate. Giờ build được cả trên 0.74 lẫn 0.168.

2. Active chưa chắc đã chạy. Lambda không kiểm tra server có thực sự listen hay không khi tạo revision → revision và endpoint vẫn có thể hiện Active trong khi mọi request đều lỗi. Vì vậy CI luôn có bước smoke test sau deploy.

3. Package to vì ảnh. Như đã nói ở trên — 26 MB mà 96% là ảnh.

⚖️ So sánh nhanh⌗

Tiêu chíCloudflare PagesCloudFront + Lambda Web Functions
Độ đơn giảnRất đơn giản, connect repo là xongNhiều mảnh hơn: Lambda, CloudFront, ACM, IAM, CI
Custom domain & TLSCó sẵnQua CloudFront + ACM
BuildCloudflare build hộTự build trong GitHub Actions
Logic phía serverPages Functions (Workers runtime)Node.js server đầy đủ, framework tuỳ chọn
RolloutDeploy mới thay thế bản cũRevision + traffic weight (canary, blue-green)
Chi phí cho blog cá nhânFree tier rộng rãiGần như không đáng kể nhờ CloudFront cache, nhưng không phải free hoàn toàn

Nói thật lòng: với một blog tĩnh thì Cloudflare Pages vẫn là lựa chọn đơn giản hơn. Mình chuyển chủ yếu để học và thử một compute primitive mới, gom mọi thứ về AWS, và có sẵn nền tảng nếu sau này muốn thêm phần dynamic (search, API, SSR…) mà không phải đổi hosting lần nữa.

🌩️ Bonus⌗

Nhân tiện đổi luôn favicon sang 🌩️ cho hợp với tên blog — để ý tab trình duyệt nhé 😄.

📚 Tham khảo⌗