Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Performance Testing in CI/CD pipeline

Performance Testing with JMeter and k6 Bài 27/60

Mở đầu — vì sao bài này quan trọng

Hãy tưởng tượng bạn là kỹ sư QA tại một công ty fintech ở TP.HCM. Sau ba tuần cày cuốc, bạn đã xây được một bộ performance test rất đẹp: mô phỏng 5.000 người dùng đăng nhập, chuyển tiền, kiểm tra số dư. Bạn chạy nó thủ công mỗi khi có bản build lớn. Rồi một ngày, một developer merge một đoạn code tưởng như vô hại — thêm một câu query vào vòng lặp — và không ai chạy performance test. Ba tuần sau, lên production, hệ thống sập trong giờ cao điểm vì mỗi request giờ chậm gấp 8 lần.

Bài học đau đớn: một bộ performance test chỉ chạy thủ công, thỉnh thoảng mới chạy, thì gần như vô dụng để ngăn regression. Giá trị thật của performance testing chỉ được giải phóng khi nó trở thành một phần tự động của quy trình phát triển — tức là khi nó sống trong CI/CD pipeline.

Nhưng đây là điểm hóc búa mà bài này sẽ giải quyết: performance test khác hoàn toàn unit test. Unit test chạy trong vài giây, còn một load test nghiêm túc có thể tốn từ 30 phút đến vài giờ. Bạn không thể chạy một soak test 8 tiếng cho mỗi pull request — pipeline sẽ tắc nghẽn, developer sẽ nổi loạn. Vậy làm sao đưa performance test vào CI/CD một cách khôn ngoan, để nó bắt được vấn đề sớm mà không làm chậm cả đội? Đó chính xác là chủ đề của bài này.

Khái niệm cốt lõi

Vấn đề nền tảng: chi phí thời gian

Trong CI/CD, mỗi giây chờ đợi đều tốn tiền và tốn sự kiên nhẫn. Một pipeline lý tưởng cho code thông thường chạy xong trong dưới 10 phút. Performance test đi ngược lại triết lý đó: nó cần thời gian để "làm nóng" hệ thống (warm-up), cần chạy đủ lâu để số liệu ổn định, và cần tải đủ lớn để lộ ra điểm nghẽn.

Giải pháp không phải là "chạy tất cả mọi lúc" cũng không phải "không chạy gì cả", mà là phân tầng: chạy loại test nhẹ thường xuyên, và loại test nặng thưa hơn. Đây là tư tưởng trung tâm của toàn bài.

Ba cấp độ performance test trong pipeline

Hãy ghi nhớ bảng phân tầng này — nó là xương sống của mọi chiến lược performance trong CI/CD:

CấpKhi nào chạyThời lượngMục đíchVí dụ tải
Smoke perf testMỗi commit / mỗi PR1–3 phútBắt regression thô, sập sớm1–5 VU, vài chục request
Load testMỗi lần merge vào main / hàng đêm (nightly)10–30 phútXác nhận đạt SLA ở tải bình thườngTải thực tế mong đợi (vài trăm–vài nghìn VU)
Stress / Soak testTrước release / hàng tuần / on-demand1–8 giờTìm giới hạn, memory leak, độ bềnVượt tải hoặc kéo dài
VU = Virtual User, người dùng ảo.

Nguyên tắc vàng: càng chạy thường xuyên thì càng phải nhẹ và nhanh. Smoke test không nhằm đo hiệu năng chính xác — nó chỉ để phát hiện "có cái gì đó vừa chậm đi rõ rệt". Load test mới là nơi bạn kiểm tra SLA thật. Còn stress/soak test là thứ bạn không muốn chặn PR của đồng nghiệp, nên đẩy sang chạy nền hoặc lịch riêng.

Vì sao k6 hợp với CI/CD hơn JMeter (thường là vậy)

Trong ngữ cảnh CI/CD, k6 có lợi thế lớn:

  • Script là code JavaScript, versioned cùng repo, dễ review qua Git.
  • Chạy từ command line dễ dàng, exit code rõ ràng (0 = pass, 99 = threshold fail).
  • Thresholds (ngưỡng SLA) được định nghĩa ngay trong test và tự động quyết định pass/fail — đúng thứ CI cần.
JMeter vẫn dùng được trong CI/CD nhưng phải chạy ở non-GUI mode (jmeter -n), và bạn cần thêm công cụ như Taurus hoặc phân tích file .jtl để quyết định pass/fail. Nói cách khác, k6 "CI-native" hơn. Bài này sẽ dùng k6 làm ví dụ chính vì tính pass/fail bằng exit code của nó ánh xạ trực tiếp vào cơ chế CI.

Cơ chế pass/fail: điều làm nên sự khác biệt

Một performance test trong CI phải trả lời được một câu hỏi nhị phân: pass hay fail? Nếu test chỉ in ra một đống số rồi luôn "thành công", CI sẽ không bao giờ chặn được code xấu. Do đó phần cốt lõi nhất khi đưa perf test vào pipeline là định nghĩa ngưỡng (threshold) rõ ràng để công cụ tự thoát với mã lỗi khi vi phạm. Ví dụ trong k6:

export const options = {
  thresholds: {
    http_req_duration: ['p(95)<500'],   // 95% request phải dưới 500ms
    http_req_failed: ['rate<0.01'],     // tỉ lệ lỗi dưới 1%
  },
};

Khi p95 vượt 500ms, k6 thoát với exit code khác 0, và CI đánh dấu job là failed — đúng như ta muốn.

Tình huống thực tế

Tình huống 1 — Tiki và "cái query trong vòng lặp"

Một đội backend giả định tại một sàn TMĐT lớn kiểu Tiki đưa smoke performance test vào GitHub Actions cho mỗi pull request. Test chỉ nhẹ nhàng: 3 VU gọi API danh sách sản phẩm trong 60 giây, với ngưỡng p(95)<300ms.

Một hôm, một developer sửa hàm lấy danh sách sản phẩm để thêm thông tin tồn kho, nhưng vô tình đặt câu query tồn kho bên trong vòng lặp duyệt từng sản phẩm (kinh điển N+1 query). Với 20 sản phẩm mỗi trang, mỗi request giờ bắn 21 câu query thay vì 2.

Trên máy dev, anh ta không nhận ra vì dữ liệu test chỉ có vài dòng, response vẫn "nhanh". Nhưng smoke test trong CI đo được p95 nhảy từ 180ms lên 640ms. Pipeline fail ngay trên PR, comment tự động dán vào: "Performance regression: p95 640ms > ngưỡng 300ms". Developer sửa lại thành eager-loading trước khi merge.

Bài học rút ra: smoke perf test không cần chính xác tuyệt đối, chỉ cần bắt được sự thay đổi bất thường ngay tại nguồn. Chi phí 60 giây trong pipeline đã ngăn một sự cố production có thể tốn hàng giờ điều tra.

Tình huống 2 — Startup logistics và nightly load test

Một startup logistics ở Hà Nội có API tính cước vận chuyển. Họ không thể chạy load test 500 VU cho mỗi PR — quá tốn thời gian và tốn tài nguyên môi trường staging dùng chung. Thay vào đó, họ cấu hình một nightly job trên GitLab CI: 2 giờ sáng mỗi ngày, deploy bản mới nhất của nhánh main lên staging, chạy k6 load test mô phỏng tải giờ cao điểm (400 VU trong 20 phút), rồi gửi kết quả về Slack.

Trong một sprint, kết quả nightly cho thấy throughput giảm dần suốt ba đêm liên tiếp: từ 1.200 req/s xuống 1.150, rồi 1.050, rồi 920. Không đêm nào fail hẳn ngưỡng, nhưng xu hướng đi xuống báo động. Đội truy ra một index database bị drop nhầm trong một migration. Vì có nightly test, họ phát hiện trong ba ngày thay vì đợi đến khi khách hàng phàn nàn.

Bài học rút ra: không phải mọi performance test đều chặn merge. Có những test chạy theo lịch (scheduled) để theo dõi xu hướng theo thời gian. Kết hợp "chặn ở PR" (smoke) với "quan sát hàng đêm" (load) cho bạn cả tốc độ lẫn chiều sâu.

Tình huống 3 — Ngân hàng số và cổng chất lượng trước release

Một ngân hàng số giả định tên FastBank có quy trình release hai tuần một lần. Trước mỗi release, pipeline bắt buộc chạy một release-gate performance suite: một stress test đẩy hệ thống lên 150% tải dự kiến, cộng với một soak test 2 giờ để kiểm tra memory leak. Suite này không chạy tự động trên mỗi PR (quá nặng), mà được kích hoạt thủ công (manual approval) khi tạo release candidate.

Ở một release, soak test phát hiện bộ nhớ JVM của service xử lý giao dịch tăng tuyến tính không giảm — dấu hiệu memory leak do connection không được đóng. Vì đây là quality gate cứng, release bị chặn cho đến khi vá xong. Không có gate này, bug sẽ chỉ lộ ra sau vài ngày chạy production khi service OOM (hết bộ nhớ) giữa đêm.

Bài học rút ra: những test nặng và chậm nhất nên trở thành quality gate có chủ đích ở giai đoạn release, không phải gánh nặng trên mỗi commit. Đặt đúng test vào đúng tầng của pipeline là cả một nghệ thuật cân bằng.

Hướng dẫn từng bước

Dưới đây là cách thiết lập performance test trong pipeline từ đầu, dùng k6 và GitHub Actions làm ví dụ. Logic tương tự áp dụng cho GitLab CI, Jenkins hay Azure DevOps.

Bước 1 — Viết smoke test có threshold rõ ràng. Tạo file smoke.js:

import http from 'k6/http';
import { check } from 'k6';

export const options = { vus: 3, duration: '1m', thresholds: { http_req_duration: ['p(95)<300'], http_req_failed: ['rate<0.01'], }, };

export default function () { const res = http.get('https://staging.myapp.vn/api/products'); check(res, { 'status 200': (r) => r.status === 200 }); }

Bước 2 — Chọn môi trường chạy test. Đừng bao giờ chạy load test vào production thật. Hãy trỏ test vào môi trường staging giống production nhất có thể. Nếu môi trường quá khác, kết quả sẽ gây hiểu lầm.

Bước 3 — Thêm job vào pipeline. Trong .github/workflows/perf.yml:

name: Performance Smoke Test
on: [pull_request]

jobs: k6_smoke: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run k6 smoke test uses: grafana/k6-action@v0.3.1 with: filename: smoke.js

Vì k6 tự thoát với exit code khác 0 khi threshold fail, GitHub Actions tự động đánh dấu job đỏ — không cần script phân tích thủ công.

Bước 4 — Tách các tầng bằng trigger khác nhau.

  • Smoke test: trigger on: pull_request.
  • Load test: trigger on: schedule (cron hàng đêm) hoặc chạy sau khi merge vào main.
  • Stress/soak: trigger workflow_dispatch (chạy thủ công) hoặc gắn vào workflow release.
Bước 5 — Lưu và báo cáo kết quả. Xuất kết quả ra artifact (JSON/HTML) để lưu trữ, và gửi tóm tắt về Slack/Telegram. Điều này giúp bạn theo dõi xu hướng — như đội logistics ở tình huống 2 đã làm.

Bước 6 — Với JMeter, dùng non-GUI mode. Nếu bạn buộc phải dùng JMeter, lệnh trong CI sẽ là:

jmeter -n -t test.jmx -l result.jtl -e -o report/

Sau đó cần một bước phân tích result.jtl (ví dụ dùng Taurus với pass/fail criteria) để CI biết pass hay fail, vì bản thân jmeter -n luôn thoát với exit code 0.

Lỗi thường gặp & mẹo

Lỗi 1 — Chạy load test nặng cho mỗi PR. Đây là sai lầm phổ biến nhất. Kết quả: pipeline mất 40 phút, developer bắt đầu bỏ qua hoặc tắt test. Mẹo: chỉ smoke test mới chặn PR; đẩy test nặng sang nightly hoặc release gate.

Lỗi 2 — Test không có threshold nên không bao giờ fail. Một perf test "luôn xanh" là vô nghĩa trong CI. Luôn định nghĩa ngưỡng cụ thể (p95, error rate) để CI có thể chặn code xấu.

Lỗi 3 — Chạy trên môi trường không ổn định / dùng chung. Nếu staging bị đội khác dùng cùng lúc, kết quả perf dao động điên loạn, gây "flaky test" — lúc pass lúc fail vô cớ. Mẹo: dùng môi trường riêng cho perf test, hoặc ít nhất chạy vào giờ ít người dùng (nightly).

Lỗi 4 — Chạy CI runner quá yếu để tạo tải. Nếu bản thân máy chạy k6/JMeter bị nghẽn CPU, số liệu bạn đo là của load generator chứ không phải của ứng dụng. Mẹo: giữ tải trên runner ở mức nhẹ (smoke), hoặc dùng dịch vụ đám mây (k6 Cloud) cho tải lớn.

Lỗi 5 — So sánh tuyệt đối thay vì so sánh xu hướng. Con số perf luôn nhiễu (noise). Đừng fail chỉ vì p95 lệch 5ms. Mẹo: đặt ngưỡng có biên độ hợp lý và chú trọng xu hướng qua nhiều lần chạy hơn là một con số đơn lẻ.

Mẹo vàng — Fail nhanh, fail rõ. Cấu hình pipeline để khi perf fail, thông báo nói rõ chỉ số nào vượt ngưỡng bao nhiêu, và link tới report. Developer sửa nhanh hơn nhiều khi biết chính xác "p95 640ms > 300ms" thay vì chỉ thấy một dấu X đỏ.

Bài tập thực hành

  • Thiết kế ba tầng cho dự án của bạn. Chọn một API bất kỳ (thật hoặc giả định). Viết ra: smoke test chạy khi nào, tải bao nhiêu, ngưỡng gì; load test chạy khi nào; stress/soak chạy khi nào. Ghi rõ trigger cho từng tầng.
  • Viết một smoke test k6 có threshold cho một endpoint công khai (ví dụ một API demo). Đặt ngưỡng p(95), chạy local bằng k6 run smoke.js, rồi cố tình đặt ngưỡng thật khắt khe để thấy k6 thoát với exit code khác 0. Kiểm tra bằng echo $?.
  • Tạo một GitHub Actions workflow (hoặc GitLab CI) chạy smoke test đó trên mỗi pull request. Đẩy một commit làm chậm API (ví dụ thêm sleep) và xác nhận pipeline chuyển đỏ.
  • Thiết kế cơ chế báo cáo. Phác thảo cách bạn sẽ gửi kết quả nightly load test về Slack/Telegram, và bạn sẽ theo dõi xu hướng throughput qua các đêm như thế nào để phát hiện suy giảm dần (giống tình huống logistics).
  • Tình huống JMeter: viết lệnh non-GUI để chạy một file .jmx và giải thích vì sao bạn cần thêm một bước để CI biết pass/fail.

Tóm tắt

Đưa performance test vào CI/CD không phải là "chạy tất cả mọi lúc" — điều đó bất khả thi vì perf test tốn từ vài phút đến vài giờ. Bí quyết là phân tầng theo chi phí thời gian và tần suất:

  • Smoke perf test (1–3 phút, nhẹ) chạy mỗi PR để bắt regression thô ngay tại nguồn.
  • Load test (10–30 phút) chạy nightly hoặc sau merge để xác nhận SLA và theo dõi xu hướng.
  • Stress/Soak test (giờ) chạy trước release như một quality gate có chủ đích.
Chìa khóa kỹ thuật là threshold: mỗi test phải trả lời được câu hỏi nhị phân pass/fail để CI tự động chặn code xấu. k6 làm việc này rất tự nhiên qua exit code; JMeter cần thêm một bước phân tích. Tránh các bẫy kinh điển: đừng chạy test nặng trên mỗi PR, đừng để test không có ngưỡng, đừng đo trên môi trường nhiễu, và luôn báo lỗi thật rõ ràng.

Khi làm đúng, performance testing chuyển từ một việc "thỉnh thoảng mới làm, dễ quên" thành một tấm lưới an toàn tự động — bắt được điểm nghẽn khi nó chỉ mới là một dòng code, chứ không phải khi nó đã thành một sự cố production giữa đêm.