Load Test Bằng k6: Tìm Nút Thắt Dưới Tải Thật
Chi phí ở bài trước thấp một phần vì ta chưa dội tải thật. Bài này làm đúng điều đó: dùng k6 đẩy một lượng request lớn vào đường mở link, rồi đọc cả phía client (k6) lẫn phía server (CloudWatch) để xem hệ thống co giãn ra sao và nút thắt nằm ở đâu. Kết quả xác nhận bằng số một thứ đã gặp rải rác suốt series.
Mục tiêu
Dùng k6 chạy một bài load test có ramp lên đường mở link, đọc kết quả (số request, độ trễ, tỉ lệ lỗi), rồi đối chiếu với metric CloudWatch để xác định nút thắt và cách hệ thống xử lý tải thừa. Bài test bắn nhiều request nhưng đều ngắn và rẻ, chi phí không đáng kể.
k6 và kịch bản
k6 là công cụ load test viết kịch bản bằng JavaScript. Kịch bản dưới đây ramp từ 0 lên 40 virtual user (VU) chạy đồng thời, giữ 30 giây, rồi hạ về 0. Mỗi VU liên tục gọi GET /{code} và không theo redirect (để đo đúng phản hồi của API chứ không đi tới đích thật):
import http from 'k6/http';
import { check } from 'k6';
export const options = {
stages: [
{ duration: '15s', target: 40 },
{ duration: '30s', target: 40 },
{ duration: '5s', target: 0 },
],
};
export default function () {
const res = http.get(__ENV.TARGET, { redirects: 0 });
check(res, {
'301': (r) => r.status === 301,
'503 (throttle)': (r) => r.status === 503,
});
}
Đường mở link là public nên load test nó không cần token. Mỗi VU gửi request nối tiếp nhau hết tốc lực, nên 40 VU tạo ra một lượng request rất lớn.
Kết quả từ phía client
Sau khoảng 50 giây, k6 bắn 27.741 request ở tốc độ ~554 request mỗi giây, p95 độ trễ 146 ms, và http_req_failed 99,66%. Đếm theo mã trạng thái bằng counter riêng trong kịch bản:
Tổng request: 27741 | 554 req/s | p95 146 ms | failed 99.66%
301 (thành công): 304 (1.1%)
503 (quá tải) : 25 (0.1%)
còn lại : 27412 (98.8%)
"Còn lại" 98,8% là mã gì? Một đợt bắn ngắn hơn ghi lại mã chính xác cho thấy:
$ seq 1 400 | xargs -P80 -I{} curl -s -o /dev/null -w "%{http_code}\n" "$API/$CODE" \
| sort | uniq -c
305 429
76 503
19 301
Phần lớn là 429, không phải 503. 429 là Too Many Requests của API Gateway: throttle đặt ở bài 16 (rate 5/giây, burst 2) gạt phần lớn request ngay tại gateway. Số ít lọt qua rate limit lại gặp giới hạn concurrency Lambda và nhận 503. Khoảng 1% cuối chạy thật và trả 301. Độ trễ tổng thể thấp vì 429 và 503 trả về gần như tức thì; còn các request thành công vẫn nhanh. Hệ thống không chậm đi dưới tải, nó từ chối phần lớn tải.
Đối chiếu với phía server
Một bài load test chỉ cho nửa bức tranh nếu không nhìn phía server. Hỏi CloudWatch về hàm resolve trong cửa sổ test:
Invocations (sum): 403
Throttles (sum): 242
ConcurrentExecutions (max): 10
Đây là chỗ mọi thứ khớp lại. ConcurrentExecutions đạt tối đa đúng 10, không hơn. Đó là giới hạn concurrency của tài khoản, con số đã gặp ở bài 06, bài 14, bài 15. Hàm chỉ chạy được mười bản sao cùng lúc, nên dù k6 bắn ~554 request mỗi giây, chỉ khoảng 403 request thực sự chạm tới Lambda (Invocations); phần áp đảo bị gạt từ trước. Việc gạt diễn ra ở hai lớp: API Gateway từ chối phần lớn ngay tại gateway bằng 429 (vượt rate throttle), còn số lọt qua thì Lambda từ chối khi mười chỗ đã bận, ghi vào Throttles (242 lần) và trả 503 cho client.
k6: ~554 req/s, 27.741 request
│
▼
API Gateway (throttle 5 req/s, burst 2)
├── ~99% vượt rate ──▶ 429 (gạt ngay tại gateway)
└── lọt qua ──▶ Lambda (tối đa 10 đồng thời)
├── hết chỗ ──▶ 503
└── ~1% ──▶ 301 (ConcurrentExecutions max = 10)
Nút thắt nằm ở quota và cấu hình, không ở code
Bài học quan trọng nhất: nút thắt không nằm ở code hay ở DynamoDB hay ở thiết kế, mà ở hai cái chốt cấu hình/quota. Chốt thứ nhất là throttle API Gateway đặt rất thấp ở bài 16 (5 req/giây), nên ở ~554 req/giây nó gạt gần hết bằng 429. Chốt thứ hai là giới hạn concurrency Lambda 10 của tài khoản (mặc định nhiều tài khoản là 1.000). Cách nâng sức chứa không phải tối ưu code mà là nới hai chốt đó: tăng rate throttle cho đúng nhu cầu thật, và xin nâng quota concurrency qua Service Quotas. Sau khi nới, cùng bài test này sẽ cho tỉ lệ thành công cao hơn nhiều.
Điều đáng chú ý là cách hệ thống thất bại. Nó không sập, không treo, không để request chờ vô tận rồi timeout. Tải thừa bị từ chối nhanh bằng 429 và 503, còn phần được phục vụ vẫn nhanh. Đây là graceful degradation: dưới tải vượt sức, hệ thống phục vụ hết mức có thể và từ chối phần còn lại một cách dứt khoát, thay vì cố ôm hết rồi gục. Hai lớp chặn, throttle API Gateway (bài 16) và giới hạn concurrency Lambda, đóng vai cầu chì đúng như mong đợi.
Đọc gì sau một bài load test
Một bài load test tốt không chỉ ra một con số "chịu được bao nhiêu" mà chỉ ra nút thắt kế tiếp. Ở đây hai nút thắt rõ ràng là rate throttle API Gateway rồi tới concurrency Lambda. Nếu nới cả hai, nút thắt tiếp theo có thể chuyển sang DynamoDB (nếu một link quá hot tạo hot partition), hay sang chính tốc độ phát sự kiện. Trace X-Ray (bài 13) và metric CloudWatch (bài 14) là công cụ để tìm nút thắt mới đó. Quy trình lặp lại: dội tải, tìm nút thắt, nới nó, dội lại.
🧹 Dọn dẹp
aws dynamodb scan --table-name url-shortener --query 'Items[].{PK:PK.S,SK:SK.S}' --output text | \
while read pk sk; do aws dynamodb delete-item --table-name url-shortener \
--key "{\"PK\":{\"S\":\"$pk\"},\"SK\":{\"S\":\"$sk\"}}"; done
aws cognito-idp admin-delete-user --user-pool-id "$POOL" --username lt@example.com
Tổng kết
Load test bằng k6 cho thấy hệ thống xử lý được lượng tải mà rate throttle và giới hạn concurrency cho phép, gạt phần còn lại bằng 429 rồi 503, và giữ độ trễ thấp cho phần được phục vụ. Đối chiếu k6 với CloudWatch chỉ thẳng nút thắt là cấu hình throttle và quota concurrency chứ không phải code, và cho thấy hệ thống degrade gracefully thay vì sập. Cách sửa là nới hai chốt đó, rồi đo lại để tìm nút thắt kế tiếp.
Series gần khép lại. Sản phẩm đã đủ tính năng, quan sát được, bảo mật, có CI/CD, biết chi phí, và đo được dưới tải. Bài cuối là capstone: rà lại toàn bộ theo Well-Architected, để hệ thống chạy thật cho bạn nghiệm thu, và bàn về dọn dẹp cùng những hướng mở rộng tiếp theo.