Lambda Chạy Code Của Bạn Ra Sao Bên Trong
Dòng REPORT ở bài trước có một trường ta lướt qua: Init Duration. Nó là cửa sổ nhìn vào cách Lambda thực sự chạy code, và hiểu nó giải thích được phần lớn những điều khó hiểu về hiệu năng lẫn chi phí của serverless. Bài này đi xuống tầng đó: môi trường thực thi của Lambda sống và chết ra sao, vì sao có cold start, và vì sao một hàm với nhiều bộ nhớ hơn lại có thể vừa nhanh hơn vừa rẻ hơn.
Mục tiêu
Hiểu ba pha trong vòng đời một môi trường thực thi (Init, Invoke, Shutdown), thấy được code static chạy một lần còn handler chạy mỗi request, đo cold start thật, và đo quan hệ giữa bộ nhớ và CPU bằng cùng một phép tính ở hai mức memory. Các phép đo trong bài chạy trên một hàm thật rồi xóa ngay, chi phí không đáng kể.
Môi trường thực thi: nơi code thật sự chạy
Khi một sự kiện tới, Lambda không chạy thẳng code của bạn trên một cái máy chung. Nó dựng một execution environment, một môi trường cô lập và an toàn chứa runtime ngôn ngữ, code của bạn, và mọi extension đi kèm. Tài liệu mô tả: "Lambda invokes your function in an execution environment, which provides a secure and isolated runtime environment. The execution environment manages the resources required to run your function."
Một môi trường thực thi xử lý một request tại một thời điểm. Nếu mười request đến cùng lúc, Lambda dựng tới mười môi trường chạy song song. Mỗi môi trường có vòng đời ba pha, và phần lớn hành vi về hiệu năng nằm ở đó.
Ba pha: Init, Invoke, Shutdown
Tài liệu chia vòng đời thành ba pha. Init là lúc dựng môi trường: Lambda khởi động các extension, bootstrap runtime, rồi chạy code static của hàm (phần code nằm ngoài handler, chạy khi module được nạp). Pha Init bị giới hạn 10 giây; nếu không xong trong khoảng đó, Lambda thử lại vào lần invoke đầu tiên với timeout của hàm.
Invoke là lúc handler của bạn chạy để xử lý một sự kiện. Một môi trường có thể trải qua pha Invoke nhiều lần, mỗi lần cho một request.
Shutdown xảy ra khi Lambda quyết định dẹp môi trường (thường sau một thời gian không có request). Lambda gửi tín hiệu shutdown cho runtime và extension để chúng dọn dẹp, rồi hủy môi trường.
Điểm mấu chốt nằm giữa các lần Invoke: tài liệu viết "Lambda freezes the execution environment when the runtime and each extension have completed and there are no pending events". Môi trường không bị hủy ngay sau một request; nó bị đóng băng và có thể được rã đông để phục vụ request kế tiếp. Khi đó pha Init không chạy lại, code static không chạy lại, và request đi thẳng vào handler.
Cold start (môi trường mới) Warm (tái dùng môi trường đã đóng băng)
┌──── Init phase ────┐
│ extension init │
│ runtime init │ chạy 1 lần
│ FUNCTION init │ (code static)
└────────┬───────────┘
▼
┌── Invoke ──┐ ┌── Invoke ──┐ ┌── Invoke ──┐ ...
│ handler │ │ handler │ │ handler │
└────────────┘ └────────────┘ └────────────┘
▲ cold ▲ warm ▲ warm
(có Init Duration) (không Init) (không Init)
│ hết request, một lúc sau
▼
┌── Shutdown ──┐
└──────────────┘
Cold start, nhìn bằng số thật
Cold start là khi một request rơi vào lúc Lambda phải dựng môi trường mới: request đó phải chờ pha Init xong trước khi handler chạy. Để thấy rõ, ta deploy một hàm có code static cố tình làm một ít việc, rồi gọi nó ba lần liên tiếp.
Code static (ngoài handler) in một dòng log và làm một vòng lặp nhỏ, còn handler chỉ in một dòng rồi trả về:
// Code static: chạy MỘT LẦN khi môi trường được khởi tạo (Init phase),
// KHÔNG chạy lại ở các lần invoke warm.
console.log("STATIC INIT: module dang duoc nap");
let acc = 0;
for (let i = 0; i < 8_000_000; i++) acc += i;
const loadedAt = new Date().toISOString();
export const handler = async () => {
console.log("HANDLER: dang xu ly mot invoke");
return { ok: true, loadedAt, acc };
};
Gọi ba lần bằng aws lambda invoke --log-type Tail (cờ này trả về phần log cuối của lần chạy, gồm dòng REPORT):
=== INVOKE #1 (cold — môi trường mới) ===
STATIC INIT: module dang duoc nap
HANDLER: dang xu ly mot invoke
REPORT Duration: 17.80 ms Billed Duration: 205 ms Memory Size: 128 MB Max Memory Used: 82 MB Init Duration: 187.16 ms
=== INVOKE #2 (warm — tái dùng môi trường) ===
HANDLER: dang xu ly mot invoke
REPORT Duration: 1.84 ms Billed Duration: 2 ms Memory Size: 128 MB Max Memory Used: 82 MB
=== INVOKE #3 (warm tiếp) ===
HANDLER: dang xu ly mot invoke
REPORT Duration: 2.62 ms Billed Duration: 3 ms Memory Size: 128 MB Max Memory Used: 82 MB
Ba điều đọc được từ đây, đều khớp với lý thuyết ở trên. Lần đầu có dòng STATIC INIT, lần hai và ba không; tức code static chỉ chạy một lần khi dựng môi trường, rồi môi trường được tái dùng. Lần đầu có Init Duration: 187.16 ms trong REPORT, hai lần sau không có trường đó; đó chính là chi phí cold start, thời gian dựng môi trường. Và thời gian xử lý thực sự khác hẳn: lần cold mất 17.80 ms còn lần warm chỉ 1.84 ms, vì lần warm không phải nạp lại gì.
Lưu ý ở dòng cold: Billed Duration là 205 ms trong khi Duration chỉ 17.80 ms. Phần chênh đúng bằng Init Duration, nghĩa là lần cold này thời gian khởi tạo cũng bị tính vào hóa đơn. Đó là một lý do nữa để quan tâm tới cold start ngoài chuyện độ trễ.
Hệ quả thực tế: việc nặng đặt ở đâu
Vì code static chạy một lần rồi được tái dùng qua nhiều invoke warm, đây là chỗ đặt những việc khởi tạo tốn kém mà nhiều request dùng chung: tạo client SDK của AWS, mở kết nối, đọc cấu hình, nạp thư viện. Đặt chúng ngoài handler nghĩa là chúng trả giá một lần cho mỗi môi trường, không phải mỗi request. Tài liệu gọi đây là optimizing static initialization. Ta sẽ áp dụng nguyên tắc này ngay từ bài viết API: client DynamoDB được tạo ở tầng module, không phải trong handler.
Ngược lại, đừng đặt ở tầng static những thứ phụ thuộc vào từng request, vì chúng sẽ bị "kẹt" lại từ lần cold đầu và dùng chung cho mọi request warm sau đó, sinh lỗi khó tìm.
Memory và CPU: một cái cần gạt, không phải hai
Cấu hình bộ nhớ của Lambda không chỉ là bộ nhớ. Tài liệu nói thẳng: "Lambda allocates CPU power in proportion to the amount of memory configured... At 1,769 MB, a function has the equivalent of one vCPU." Tăng bộ nhớ là tăng cả CPU theo tỉ lệ. Mặc định 128 MB là mức thấp nhất, và tài liệu khuyến nghị chỉ dùng 128 MB cho hàm đơn giản kiểu chuyển tiếp sự kiện.
Để thấy điều này bằng số, ta đặt một phép tính tốn CPU vào handler (50 triệu lần lấy căn bậc hai), rồi đo ở hai mức bộ nhớ. Ở 128 MB:
CPU work xong trong 2594 ms
REPORT Duration: 2655.47 ms Billed Duration: 2799 ms Memory Size: 128 MB Max Memory Used: 82 MB
Nâng hàm lên 1769 MB (mức tương đương một vCPU đầy đủ) rồi chạy lại đúng phép tính đó:
CPU work xong trong 88 ms
REPORT Duration: 89.70 ms Billed Duration: 90 ms Memory Size: 1769 MB Max Memory Used: 84 MB
Cùng một việc, từ 2594 ms xuống 88 ms, nhanh hơn khoảng 29 lần. Đáng chú ý là Max Memory Used ở cả hai lần đều quanh 82–84 MB; hàm này không thiếu bộ nhớ, nó thiếu CPU. Cái ta mua khi tăng memory ở đây hoàn toàn là sức tính toán.
Phần phản trực giác là chi phí. Lambda tính tiền theo bộ-nhớ nhân thời-gian, nên thử nhân ra:
128 MB: 128 × 2799 ms = 357.872 (MB·ms)
1769 MB: 1769 × 90 ms = 159.210 (MB·ms)
Mức 1769 MB không chỉ nhanh hơn 29 lần mà còn rẻ hơn khoảng 2,25 lần cho cùng công việc. Với hàm CPU-bound, để bộ nhớ thấp vừa chậm vừa đắt. Đây là lý do bài 15 sẽ dùng công cụ đo (AWS Lambda Power Tuning mà tài liệu giới thiệu) để tìm mức memory tối ưu thay vì đoán. Với hàm chỉ chuyển tiếp sự kiện như phần lớn hàm trong series này, 128 MB là hợp lý; với hàm tính toán nặng, mức đó là một cái bẫy chi phí.
arm64: vì sao ta chọn Graviton
Trong template ở bài trước có dòng Architectures: [arm64]. Lambda cho chạy trên CPU Graviton (arm64) của AWS bên cạnh x86. Với phần lớn workload Node, arm64 rẻ hơn cho mỗi đơn vị thời gian và thường nhanh ngang hoặc hơn x86. Vì code của ta là TypeScript biên dịch ra JavaScript chạy trên runtime Node có sẵn cho cả hai kiến trúc, chọn arm64 gần như là lựa chọn mặc định không mất gì. Khi nào cần x86 thì thường là vì một thư viện native chỉ build cho x86, điều ta sẽ không gặp trong series này.
🧹 Dọn dẹp
Hàm dùng để đo trong bài nằm trong một stack riêng (coldstart-demo) và đã được xóa ngay sau khi đo xong:
$ sam delete --stack-name coldstart-demo --no-prompts --region ap-southeast-1
Deleted successfully
Không giữ lại tài nguyên nào từ bài này.
Tổng kết
Lambda chạy code trong một môi trường thực thi có ba pha. Code static chạy một lần ở pha Init và được tái dùng qua các invoke warm, nên cold start là cái giá phải trả khi dựng môi trường mới, đo được qua Init Duration. Bộ nhớ và CPU là một cần gạt chung: với việc nặng, tăng memory có thể vừa nhanh hơn vừa rẻ hơn. Và arm64 là lựa chọn mặc định hợp lý cho code Node.
Phần nền tảng tới đây là đủ. Bài sau bước vào lõi sản phẩm: dựng API thật bằng API Gateway. Ta sẽ so HTTP API với REST API và chọn loại phù hợp, định nghĩa route cho việc tạo và resolve link, rồi xử lý CORS cho đàng hoàng để bài về dashboard sau này không vỡ.