Cold Start: Đo Thật Rồi Tối Ưu Cái Tối Ưu Được

K
Kai··6 min read

Alarm throttle ở bài trước nhắc lại hai điểm đau đi kèm Lambda: giới hạn concurrency và cold start. Bài này quay lại cold start một cách nghiêm túc: đo nó bằng số thật trên đúng handler resolve đã gắn Powertools, rồi thử từng cách giảm để thấy cái nào hiệu quả, cái nào không áp dụng được, và cái nào bị chặn bởi chính tài khoản này.

Mục tiêu

Đo cold start thật của resolve, rồi so các cách giảm: tăng bộ nhớ, cắt kích thước gói, SnapStart, và provisioned concurrency. Mỗi cách có một đánh đổi, và trên tài khoản test có cái không dùng được, ta sẽ thấy lý do thật. Chi phí phần đo không đáng kể.

Đo cold start

Để buộc một cold start, cập nhật cấu hình hàm (việc này hủy mọi môi trường đang ấm), rồi gọi ngay. Lần đầu là cold, lần sau là warm. Handler resolve giờ đã nặng hơn nhiều so với bài 02: nó nạp Powertools, các client SDK, và bật X-Ray. Số đo ở mức bộ nhớ mặc định 128 MB:

REPORT Duration: 1513.37 ms  Billed Duration: 1817 ms  Memory Size: 128 MB  Max Memory Used: 96 MB  Init Duration: 303.63 ms   (cold)
REPORT Duration: 214.33 ms   Billed Duration: 215 ms   Memory Size: 128 MB  Max Memory Used: 96 MB                              (warm)

Có vài điều đáng đọc kỹ. Init Duration 304 ms là thời gian dựng môi trường và chạy code static (tạo client, nạp thư viện). Nhưng Duration của lần cold lên tới 1513 ms, gấp bảy lần lần warm 214 ms, dù init chỉ thêm 304 ms. Phần chênh còn lại nằm ở chính lần chạy đầu: ở 128 MB, CPU rất ít, nên việc thực thi lần đầu (nạp nốt module theo kiểu lazy, lời gọi SDK đầu tiên) bò chậm. Và Max Memory Used 96 MB đã sát trần 128 MB; Powertools cộng SDK ăn kha khá bộ nhớ.

Cách 1: tăng bộ nhớ, vì bộ nhớ là CPU

Bài 02 đã chỉ ra bộ nhớ và CPU là một cần gạt chung. Cold start là chỗ điều đó hiện rõ nhất. Tăng resolve từ 128 lên 512 MB rồi đo lại một cold start:

REPORT Duration: 294.91 ms  Billed Duration: 579 ms  Memory Size: 512 MB  Max Memory Used: 95 MB  Init Duration: 283.42 ms

Init Duration gần như không đổi (283 so với 304 ms), nhưng Duration của lần cold rơi từ 1513 ms xuống 295 ms, nhanh hơn khoảng năm lần. Lý do là CPU: ở 512 MB hàm có gấp bốn lần CPU so với 128 MB, nên lần chạy đầu vốn nặng CPU chạy nhanh hơn hẳn. Max Memory Used vẫn quanh 95 MB, tức hàm không thiếu bộ nhớ, nó thiếu CPU lúc khởi động. Và Billed Duration giảm từ 1817 xuống 579 ms; như bài 02 đã thấy, với việc nặng CPU thì nhiều bộ nhớ hơn có thể vừa nhanh hơn vừa không đắt hơn bao nhiêu. Đây là cách giảm cold start đơn giản và hiệu quả nhất ở đây, chỉ là đổi một con số.

Cách 2: cắt kích thước gói

Gói càng to thì nạp càng lâu. Bundle của resolve sau khi esbuild minify là khoảng 840 KB:

$ ls -la .aws-sam/build/ResolveLinkFunction/resolve-link.js
840262 bytes  resolve-link.js

Phần lớn kích thước đó là Powertools và các client SDK được gói kèm. Cách giảm gồm: chỉ import đúng client SDK cần (đã làm, ta import từng @aws-sdk/client-* riêng chứ không cả khối), không gói những thứ runtime Lambda đã có sẵn, và để esbuild minify (đã bật). Với code này lợi ích từ việc cắt thêm là nhỏ so với việc tăng bộ nhớ, nhưng với gói rất lớn hoặc nhiều dependency nặng thì nó đáng làm.

Cách 3: SnapStart, nhưng không phải cho Node

SnapStart chụp lại ảnh môi trường đã khởi tạo lúc publish version, rồi phục hồi từ ảnh đó thay vì init lại, cắt phần lớn cold start. Nhưng nó không áp dụng được ở đây, và lý do là một chi tiết phải kiểm tài liệu chứ không đoán: SnapStart hỗ trợ "Java 11+, Python 3.12+, and .NET 8+ runtimes". Runtime của ta là Node.js, không nằm trong danh sách. Nếu series này viết bằng Python hay Java thì SnapStart sẽ là lựa chọn hàng đầu; với Node thì không có cửa đó, và phải dựa vào các cách khác. Đây đúng là loại chi tiết mà viết theo trí nhớ dễ sai, vì danh sách runtime được hỗ trợ có mở rộng theo thời gian.

Cách 4: provisioned concurrency, bị tài khoản chặn

Cách chắc chắn nhất để xóa cold start cho Node là provisioned concurrency: giữ sẵn một số môi trường đã khởi tạo, luôn ấm, nên request không bao giờ phải chờ init. Nhưng nó cần dành riêng (reserve) một phần concurrency, và trên tài khoản test điều đó bị chặn thẳng:

$ aws lambda put-function-concurrency --function-name "$RESOLVE" --reserved-concurrent-executions 5
An error occurred (InvalidParameterValueException): Specified ReservedConcurrentExecutions
for function decreases account's UnreservedConcurrentExecution below its minimum value of [10].

Tài khoản này có giới hạn concurrency tổng là 10 (gặp ở bài 06), và AWS không cho phép dành riêng phần nào nếu việc đó kéo phần unreserved xuống dưới mức tối thiểu 10. Nói cách khác, muốn dùng provisioned concurrency thì phải xin nâng quota concurrency qua Service Quotas trước. Đây là một ràng buộc thật của môi trường, không phải lỗi cấu hình, và nó cho thấy vì sao "chỉ cần bật provisioned concurrency" đôi khi không làm được ngay.

Cân nhắc thực tế

Gộp lại, thứ tự ưu tiên cho cold start của một hàm Node trên tài khoản như thế này là: chỉnh bộ nhớ cho đúng (lợi nhất, gần như miễn phí công sức), giữ code static gọn và client tạo một lần ở tầng module (đã làm từ bài 06), cắt bundle nếu nó phình. Provisioned concurrency để dành cho khi đã xin nâng quota và thực sự cần độ trễ ổn định ở mọi request, còn SnapStart thì cân nhắc nếu ngôn ngữ là Java, Python hay .NET. Với một URL shortener nơi cold start chỉ chạm số ít request rơi vào lúc dựng môi trường mới, tăng bộ nhớ lên một mức hợp lý thường là đủ.

🧹 Dọn dẹp

Bộ nhớ resolve đã trả về 128 MB cho khớp template; lệnh reserved concurrency thất bại nên không để lại gì:

aws lambda update-function-configuration --function-name "$RESOLVE" --memory-size 128

Giữ stack cho bài sau.

Tổng kết

Cold start đo được, và mỗi cách giảm có chỗ đứng riêng. Tăng bộ nhớ cắt cold start của resolve khoảng năm lần vì nó tăng CPU lúc khởi động, và đây là cách đơn giản nhất trên tài khoản này. Cắt bundle giúp khi gói lớn. SnapStart mạnh nhưng không cho Node. Provisioned concurrency là cách chắc chắn nhất nhưng bị giới hạn concurrency của tài khoản chặn cho tới khi nâng quota. Bài học chung là đo trước, rồi chọn cách phù hợp với ràng buộc thật, thay vì áp một công thức.

Phần còn lại của vận hành là bảo mật. Bài sau siết quyền: thu IAM của từng hàm về đúng việc nó cần (least-privilege), chuyển bí mật sang nơi quản lý đàng hoàng, đặt throttle để chống lạm dụng, và bàn cách gắn WAF cho một HTTP API, vốn cần đi vòng qua CloudFront.