7 cảnh báo đầu tiên trong Prometheus — cơ chế, công thức, vì sao phải viết như vậy
Bài này bóc chi tiết 7 alert rule ở
mục 2.2 của bài "Bộ công cụ để vận hành":
từng hàm PromQL dùng để làm gì, vì sao chọn ngưỡng/khoảng thời gian đó, và tại sao có alert
được gắn severity: page còn có alert thì không. Đây là bài đi sâu cơ chế —
không theo khung 9 mục chuẩn của repo, vì phạm vi chỉ là một đoạn code, không phải một công nghệ.
Nhắc lại nguyên tắc gốc: cảnh báo trên triệu chứng khách nhìn thấy, ở tầng cao nhất có thể. 7 rule dưới đây được sắp theo đúng thứ tự ưu tiên: từ "khách có vào được không" xuống tới "hệ thống giám sát có đang sống không".
flowchart LR A["Exporters: blackbox, nginx, php-fpm, node, app"] --> B["Prometheus: scrape + evaluate rules.yml"] B -->|"severity: page"| C["Alertmanager → gọi điện / SMS ngay, 24/7"] B -->|"không gắn severity: page"| D["Alertmanager → Slack / email, xem giờ hành chính"] B -->|"Watchdog luôn FIRING"| E["Heartbeat ngoài (healthchecks.io) — báo khi NGỪNG nhận"]
1. SiteDown — triệu chứng cao nhất, đo từ ngoài vào
- alert: SiteDown
expr: probe_success == 0
for: 2m
labels: { severity: page }
flowchart LR P["Prometheus (job: blackbox)"] -->|"scrape /probe"| B["Blackbox Exporter"] B -->|"GET https://shop.example.com/health"| S["shop.example.com/health"] S -->|"200 OK / timeout / lỗi"| B B -->|"expose probe_success = 1 hoặc 0"| P P -->|"expr probe_success==0, for 2m → FIRING"| AM["Alertmanager severity=page"] AM -->|"gọi điện / SMS ngay"| ON["Người trực (on-call)"]
Cơ chế: probe_success không phải số có sẵn trong nginx hay app —
nó do Blackbox Exporter tạo ra. Job blackbox trong
prometheus.yml (metrics_path: /probe, module: http_2xx)
khiến Prometheus, mỗi vòng scrape, bảo Blackbox Exporter tự đi gửi một HTTP GET thật
tới https://shop.example.com/health. Thành công (2xx, đúng SSL, đúng timeout) →
probe_success = 1. Timeout, connection refused, SSL lỗi, trả 5xx → probe_success = 0.
Vì sao đo kiểu này chứ không hỏi app/DB bên trong: đây là phép đo duy nhất trong 7 cái mô phỏng đúng trải nghiệm khách — không quan tâm lỗi ở DNS, LB, nginx, PHP-FPM hay PostgreSQL, tất cả quy về một con số nhị phân "khách vào được hay không".
Vì sao for: 2m: một lần probe fail đơn lẻ rất có thể do mạng
giữa Blackbox Exporter và server chập chờn tức thời, không phải site thật sự chết. Bắt điều
kiện sai liên tục 2 phút mới chuyển FIRING để loại nhiễu, nhưng vẫn đủ ngắn để không
trễ phản ứng với một outage thật.
Vì sao severity: page: đây là nhãn do mình tự định
nghĩa, không phải giá trị cố định trong cú pháp Prometheus. Prometheus chỉ quy định cơ
chế: mỗi rule có trường labels dạng key-value tự do, gắn gì vào cũng được
(severity: critical, team: backend...). Cái biến severity: page
thành "gọi điện lúc 2h sáng" là do Alertmanager đọc nhãn đó và áp routing rule
mình cấu hình riêng, ví dụ:
route:
routes:
- match: { severity: page }
receiver: pagerduty-call # route sang receiver gọi điện/SMS
- match: { severity: none }
receiver: null # Watchdog, không cần báo ai
Nói cách khác: Prometheus/rule chỉ gắn nhãn khi alert FIRING (thuần metadata); Alertmanager mới
là chỗ quyết định nhãn đó dẫn tới hành động gì. Tên page là quy ước phổ biến trong
giới SRE (từ thời dùng pager vật lý), hoàn toàn có thể đặt tên khác miễn route đúng theo tên đó.
Site chết nghĩa là mất tiền ngay từng giây, nên đây là 1 trong 2–3 alert được phép đánh thức
người trực lúc 2h sáng.
Cách nói khi phỏng vấn (không cần đọc công thức): "Em dùng một exporter riêng để giả làm khách hàng — nó tự gửi một request HTTP thật tới trang health-check của site, đều đặn mỗi vài giây. Nếu request đó thất bại liên tục trong khoảng 2 phút, em coi là site đang down thật (không phải do mạng chập chờn tức thời), và báo động ngay lập tức vì đây là lỗi khách hàng nhìn thấy trực tiếp."
2. HighErrorRate — tỉ lệ, không phải số tuyệt đối
- alert: HighErrorRate
expr: |
sum(rate(nginx_http_response_count_total{status=~"5.."}[5m]))
/ sum(rate(nginx_http_response_count_total[5m])) > 0.02
for: 5m
labels: { severity: page }
flowchart LR C["Counter nginx_http_response_count_total"] -->|"filter status=~5.."| F["chỉ series status 5xx"] F -->|"rate(...[5m])"| R1["tốc độ lỗi (req/s)"] C -->|"rate(...[5m]) toàn bộ status"| R2["tốc độ tổng (req/s)"] R1 -->|"sum(...)"| S1["tổng lỗi mọi instance"] R2 -->|"sum(...)"| S2["tổng mọi response"] S1 -->|"chia"| D["tỉ lệ lỗi hiện tại"] S2 -->|"chia"| D D -->|"> 0.02, for 5m → FIRING"| AM["Alertmanager severity=page"]
Cơ chế từng bước:
1. nginx_http_response_count_total — đây là một
Counter: con số chỉ tăng dần (tổng số response từ lúc nginx-exporter khởi
động), không bao giờ giảm trừ khi service restart (counter reset về 0).
2. {status=~"5.."} — filter bằng regex, chỉ giữ lại các time
series có label status khớp mẫu 5.. (tức bắt đầu bằng 5: 500, 502,
503, 504...). Dấu =~ là so khớp regex, khác với = là so khớp
chính xác — viết nhầm thành = thì chỉ bắt đúng status code
"5.." theo nghĩa đen (không tồn tại), không bắt được 500/502/503 nào cả.
3. rate(...[5m]) — đây là phần hay gây nhầm nhất. Vì counter
chỉ tăng và tổng số từ lúc khởi động không nói lên gì cả (10 phút trước có thể đã là 5 triệu),
rate() tính tốc độ tăng trung bình mỗi giây trong khung
[5m] gần nhất (requests/giây), và tự xử lý luôn trường hợp counter bị reset
(service restart) mà không cho ra số âm vô lý.
Ví dụ: nếu trong 5 phút qua có 60 request lỗi 5xx → rate(...[5m]) ≈ 60/300s = 0.2 req/s.
4. sum(...) quanh mỗi rate(...) — nếu có nhiều
instance/pod nginx đang chạy, mỗi instance là một time series riêng. sum() cộng tất
cả lại thành một số tổng toàn hệ thống, tránh tình trạng 1 instance lỗi 100%
nhưng bị "pha loãng" khi nhìn riêng từng cái.
5. Phép chia — tử số là tốc độ lỗi 5xx, mẫu số là tốc độ tổng mọi response (không filter status). Chia ra được tỉ lệ lỗi hiện tại, nằm trong khoảng 0–1.
Ví dụ tiếp: tổng mọi response trong 5 phút là 3000 → rate tổng ≈ 10 req/s. Tỉ lệ
lỗi = 0.2 / 10 = 0.02 = 2%, đúng ngưỡng.
6. > 0.02 — ngưỡng: tỉ lệ lỗi vượt 2% thì coi là bất thường.
7. for: 5m — giống SiteDown, phải đúng
liên tục 5 phút mới FIRING, để lọc các đợt tăng lỗi ngắn hạn (vài request lỗi do 1
request chậm nhất thời) không đáng page.
Vì sao dùng tỉ lệ % chứ không phải số tuyệt đối ("> 50 lỗi/phút"): traffic không cố định. Cao điểm 1000 req/s thì 50 lỗi/phút chỉ là 0.08% — bình thường. Đêm vắng 5 req/s thì 50 lỗi/phút là >100% — sập thật. Tỉ lệ co giãn theo traffic thực tế nên phản ánh đúng mức nghiêm trọng bất kể đang cao điểm hay vắng khách.
Cách nói khi phỏng vấn: "Em không đếm số request lỗi tuyệt đối, vì lưu lượng truy cập thay đổi theo giờ — đếm tuyệt đối sẽ sai lệch giữa giờ cao điểm và giờ vắng khách. Em tính tỉ lệ phần trăm giữa số request lỗi 5xx và tổng số request, theo tốc độ trung bình trong 5 phút gần nhất. Nếu tỉ lệ đó vượt 2% và giữ nguyên liên tục 5 phút, em coi là hệ thống đang lỗi bất thường, không phải một vài lỗi lẻ tẻ."
3. SlowP95 — đo đuôi chậm, không đo trung bình
- alert: SlowP95
expr: histogram_quantile(0.95,
sum by (le) (rate(nginx_http_request_duration_seconds_bucket[5m]))) > 1
for: 10m
flowchart LR H["Histogram buckets le=0.1, le=0.5, le=1..."] -->|"rate(...[5m]) mỗi bucket"| R["tốc độ rơi vào mỗi bucket"] R -->|"sum by (le)"| G["cộng theo instance, giữ nhãn le"] G -->|"histogram_quantile(0.95,...)"| Q["ước lượng p95"] Q -->|"> 1, for 10m → FIRING"| AL["Alertmanager (không severity:page)"]
Cơ chế: nginx_http_request_duration_seconds_bucket là một
Histogram — không phải 1 số mà là nhiều counter con, mỗi counter gắn nhãn
le ("less than or equal", ví dụ le="0.1", le="0.5",
le="1"…) đếm số request có thời gian xử lý ≤ ngưỡng đó (tích lũy, bucket
sau luôn ≥ bucket trước).
1. rate(...[5m]) trên từng bucket — mỗi bucket (le="0.1",
le="0.5", le="1"...) là một counter riêng biệt, nên phải
rate() từng cái để ra tốc độ request rơi vào mỗi ngưỡng, mỗi giây — cùng lý do
rate() cần cho Counter như ở HighErrorRate.
2. sum by (le) (...) — cộng các instance lại nhưng
giữ nguyên nhãn le (khác với sum() trần ở
HighErrorRate là cộng mất hết nhãn). Nếu cộng mất le, bước sau không
còn phân biệt được bucket nào là bucket nào, histogram_quantile sẽ không chạy được.
3. histogram_quantile(0.95, ...) — không đọc được p95 chính
xác tuyệt đối, mà nội suy tuyến tính giữa 2 bucket liền kề chứa ngưỡng 95%.
Ví dụ: tổng 1000 req/s, bucket le="0.5" có 900 req/s rơi vào (≤0.5s), bucket
le="1" có 960 req/s (≤1s). 95% của 1000 là 950 — nằm giữa 900 (ở mốc 0.5s) và 960
(ở mốc 1s), nội suy ra p95 ≈ 0.5 + (950−900)/(960−900) × (1−0.5) ≈ 0.92s.
Giới hạn cần biết: độ chính xác của histogram_quantile phụ thuộc
hoàn toàn vào việc chọn ranh giới bucket (le) có khớp với phân phối latency thật hay
không. Bucket quá thưa quanh giá trị p95 thật (ví dụ chỉ có le="0.5" và
le="5", không có mốc nào ở giữa) thì nội suy sai lệch rất lớn — phải khai báo
buckets dày hơn ở gần ngưỡng SLA mình quan tâm (ở đây là 1s).
Vì sao p95 chứ không phải trung bình (avg): trung bình bị che bởi số đông request nhanh — 990 request 50ms và 10 request 10s vẫn ra avg < 150ms, nhìn không thấy vấn đề. p95 lộ ra đúng nhóm khách đang chịu trải nghiệm tệ nhất, và nhóm đó thường tương quan với một nguyên nhân cụ thể (query chậm, GC pause, cold cache) đáng điều tra.
Vì sao không có severity: page: p95 vượt 1s là khó chịu nhưng
khách vẫn mua được hàng — không khẩn như site chết hẳn hay lỗi 5xx tràn lan. Đây là alert mức
"xem trong giờ hành chính", không đáng đánh thức 2h sáng.
Vì sao for: 10m dài hơn SiteDown: độ trễ dao động tự nhiên hơn
tỉ lệ lỗi hay uptime (một vài request chậm do GC/cold cache là bình thường), nên cần cửa sổ dài
hơn để chắc đây là xu hướng thật, không phải nhiễu tạm thời.
Cách nói khi phỏng vấn: "Em không nhìn thời gian phản hồi trung bình, vì số đông request nhanh sẽ che mất phần nhỏ request rất chậm — trung bình có thể vẫn đẹp trong khi 5% khách đang chịu trải nghiệm rất tệ. Em đo mốc thời gian mà 95% request nhanh hơn hoặc bằng — gọi là p95. Nếu mốc đó vượt 1 giây và duy trì 10 phút, nghĩa là một phần không nhỏ khách hàng đang thực sự chậm, chứ không phải một request ngẫu nhiên bị chậm."
4. FpmNoFreeWorker — bắt nghẽn đúng tầng, trước khi thành lỗi
- alert: FpmNoFreeWorker
expr: phpfpm_listen_queue > 0
for: 5m
flowchart LR PM["php-fpm pool (pm.max_children)"] -->|"tất cả worker đang bận"| LQ["request mới xếp vào listen_queue"] LQ -->|"phpfpm_listen_queue > 0, for 5m → FIRING"| AL["Alertmanager (không severity:page)"]
Cơ chế: PHP-FPM chạy một pool worker cố định (pm.max_children).
Khi tất cả worker đang bận, request mới không bị từ chối ngay — nó xếp vào
listen_queue (hàng đợi ở tầng socket, trước khi chạm được tới một worker rỗi).
Metric phpfpm_listen_queue cần bật pm.status_path của php-fpm và có
exporter đọc được số đó.
Vì sao ngưỡng là > 0 chứ không phải một con số lớn hơn: khác
với CPU/RAM (có một khoảng "bận nhưng vẫn ổn"), ở tầng này hễ có backlog > 0 là request đã
đang chờ, không còn chạy ngay lập tức nữa — một chỉ báo sớm (leading indicator) sẽ biến
thành SlowP95 tăng và cuối cùng là 502/504 nếu không xử lý.
Vì sao cần cảnh báo riêng tầng này: SlowP95/HighErrorRate chỉ nói "đang chậm/lỗi"
nói chung — còn FpmNoFreeWorker chỉ đích danh "nghẽn ở PHP-FPM worker", giúp người
trực không phải SSH vào rồi đoán mò giữa nginx, PHP-FPM, DB hay network.
Cách nói khi phỏng vấn: "PHP-FPM chỉ có một số lượng worker cố định để xử lý request. Khi hết worker rảnh, request mới phải xếp hàng chờ. Em theo dõi độ dài hàng chờ đó — hễ có bất kỳ request nào phải chờ, liên tục trong 5 phút, là dấu hiệu sớm server đang quá tải đúng ở tầng PHP, trước khi nó biến thành lỗi 502 hay chậm hẳn mà khách nhìn thấy."
5. QueueBacklog — job dồn, worker không theo kịp
- alert: QueueBacklog
expr: laravel_queue_pending > 500
for: 10m
flowchart LR J["Job mới được dispatch"] -->|"đẩy vào queue"| Q["laravel_queue_pending tăng"] W["Queue worker xử lý job"] -->|"lấy job ra khỏi queue"| Q Q -->|"pending > 500, for 10m → FIRING"| AL["Alertmanager (không severity:page)"]
Cơ chế: laravel_queue_pending đếm số job đang chờ trong queue
(Redis/database driver) mà chưa có worker nào nhận xử lý. Số này tăng đều khi:
queue worker chết/bị kill, job chạy vào lỗi lặp retry chiếm chỗ, hoặc tốc độ tạo job vượt tốc độ
xử lý (traffic tăng mà số worker không tăng theo).
Vì sao ngưỡng 500 là số ví dụ, không phải luật cố định: ngưỡng này phải hiệu chỉnh theo throughput thật của hệ thống — 500 job pending có thể là bình thường với hệ thống xử lý 1000 job/phút, nhưng là báo động với hệ thống chỉ xử lý 50 job/phút. Phải đo baseline thật rồi mới chốt số.
Vì sao for: 10m và không có severity: page: một đợt
job dồn ngắn (ví dụ sau một lần import dữ liệu lớn) là bình thường và tự rã trong vài phút — cần
cửa sổ dài để phân biệt với tình trạng dồn thật sự không tự giảm. Hậu quả của queue chậm (email
trễ, thông báo trễ) thường chịu được tới sáng, không cần page ngay.
Cách nói khi phỏng vấn: "Em đếm số job đang chờ xử lý trong hàng đợi nền (background queue) mà chưa có worker nào nhận. Ngưỡng báo động em đặt dựa theo throughput thực tế của hệ thống, không phải một số cố định. Nếu số job tồn đọng vượt ngưỡng đó và không giảm trong 10 phút, nghĩa là worker xử lý job đang không theo kịp hoặc đã chết, cần can thiệp."
6. DiskWillFillIn4h — cảnh báo dự báo, không phải phản ứng
- alert: DiskWillFillIn4h
expr: predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 4*3600) < 0
for: 15m
labels: { severity: page }
flowchart LR D6["node_filesystem_avail_bytes, 6h gần nhất"] -->|"hồi quy tuyến tính"| P4["predict_linear(...,4*3600)"] P4 -->|"dự đoán byte trống sau 4h < 0"| C["đúng liên tục 15m"] C -->|"FIRING"| AM["Alertmanager severity=page"]
Cơ chế: predict_linear(v, t) là hàm PromQL lấy một range vector
v (ở đây là node_filesystem_avail_bytes — số byte còn trống, trong
6 giờ gần nhất), chạy hồi quy tuyến tính đơn giản trên các điểm đó, rồi ngoại suy
t giây về phía trước để dự đoán giá trị tại thời điểm "bây giờ + t".
Ở đây t = 4*3600 = 4 giờ. Nếu kết quả dự đoán < 0, nghĩa là với tốc
độ ghi đĩa hiện tại, đĩa sẽ về 0 byte trống trước khi 4 giờ nữa trôi qua.
Ví dụ: 6 giờ trước đĩa còn 40GB trống, hiện tại còn 20GB trống → tốc độ giảm trung bình ≈ (40−20)/6 ≈ 3.33 GB/giờ. Dự đoán sau 4h nữa: 20 − 4×3.33 ≈ 6.7GB — vẫn dương, chưa FIRING. Nhưng nếu tốc độ giảm là 6GB/giờ (ví dụ do một tiến trình ghi log lỗi tràn), dự đoán sau 4h: 20 − 4×6 = −4GB — âm, FIRING ngay từ bây giờ dù đĩa hiện tại vẫn còn 20GB trống. Đây chính là điểm khác biệt với việc chỉ cảnh báo "đĩa còn < X%".
Vì sao đây là alert "dự báo" khác hẳn 6 cái còn lại: 6 alert khác đều phản ứng với tình trạng hiện tại (đang sai, đang chậm, đang đầy...). Cái này nhìn vào xu hướng rồi báo trước khi sự cố xảy ra — cho đủ thời gian dọn log, tăng dung lượng, hoặc di dữ liệu, thay vì phát hiện lúc đĩa đã 100% và service đã crash.
Vì sao for: 15m dài nhất trong các alert có severity: page:
kết quả hồi quy tuyến tính rất nhạy với nhiễu ngắn hạn (một lần ghi log đột biến có thể làm độ
dốc tăng vọt tạm thời). Giữ điều kiện "dự đoán âm" liên tục 15 phút mới coi là xu hướng thật, để
không page nhầm vì một lần spike I/O.
Vì sao vẫn severity: page dù "chỉ là dự báo": hậu quả khi đĩa đầy
thật là nặng — ghi file lỗi, DB có thể crash, log ngừng ghi, hiệu lực tương đương site down. Mức
độ nghiêm trọng của hậu quả quyết định severity, không phải việc nó đang "chắc chắn" hay "dự báo".
Cách nói khi phỏng vấn: "Thay vì chờ đĩa gần đầy mới báo, em nhìn vào xu hướng dung lượng trống trong 6 giờ gần nhất, rồi ngoại suy tuyến tính xem 4 giờ nữa sẽ còn bao nhiêu. Nếu kết quả dự đoán ra số âm — tức đĩa sẽ hết trước khi đủ 4 giờ — em báo động ngay từ bây giờ, để có đủ thời gian dọn dẹp hoặc tăng dung lượng trước khi đĩa thực sự đầy."
Một câu hỏi thường gặp: tại sao không có thêm một alert tĩnh kiểu "disk used >= 80%" chạy
song song với DiskWillFillIn4h? Đây không phải thiếu sót, mà bài này theo đúng
triết lý gốc của chính rule này (robustperception.io, "Reduce Noise From Disk Space Alerts"): ngưỡng
tĩnh theo % gây ra đúng hai loại lỗi mà predict_linear sinh ra để giải quyết. Thứ nhất là
false positive: một đĩa chạy ổn định ở 85% hàng tuần (bình thường với nhiều hệ thống
có log xoay vòng, DB nén định kỳ) vẫn kêu liên tục dù không có gì nguy cấp — gây mệt người trực. Thứ
hai là false negative: một đĩa đang ở 50%, còn xa ngưỡng 80%, nhưng đang tụt rất
nhanh (ví dụ một tiến trình ghi log lỗi tràn) thì ngưỡng tĩnh không phát hiện kịp, chỉ báo được khi đã
quá muộn — đúng ví dụ 20GB/6GB-giờ ở trên. predict_linear nhìn vào xu hướng nên giải
quyết được cả hai cùng lúc; vì vậy rule gốc đề xuất thay thế ngưỡng tĩnh, không phải thêm vào
cho đủ bộ.
flowchart TD S["Trạng thái đĩa hiện tại"] -->|">=80%, ổn định nhiều ngày (vd 85% không đổi)"| A["reactive used>=80%: FIRING
predict_linear: KHÔNG fire (dốc gần 0)"] S -->|"<80% (vd 50%) nhưng đang tụt rất nhanh"| D["reactive used>=80%: KHÔNG fire
predict_linear: FIRING sớm"] S -->|">=80% và đang tụt nhanh"| B["cả hai: FIRING"] S -->|"<80%, ổn định"| C["cả hai: im lặng"]
Nhưng trong production thật, nhiều rulebook phổ biến (ví dụ bộ awesome-prometheus-alerts của node_exporter) vẫn giữ thêm một ngưỡng sàn rất thấp — thường <10% dung lượng trống, không phải 80% — chạy song song với một rule predict_linear khác (thường dự báo xa hơn, 24h). Đây là một trường phái khác, không phải quay lại cách làm cũ bị chê nhiễu ở trên: mục đích của ngưỡng sàn này không phải sửa lỗi nhiễu của ngưỡng 80% (predict_linear đã giải quyết việc đó), mà là một lưới an toàn cuối (defense-in-depth) cho đúng các trường hợp mà chính mô hình hồi quy cũng có thể bỏ lỡ: một file ghi đột ngột chiếm hết dung lượng trong vài phút — nhanh hơn cả window 6 giờ mà predict_linear dùng để tính độ dốc, nên hồi quy chưa kịp phản ứng; hoặc một khoảng gap trong metric (exporter down tạm, scrape lỗi) khiến predict_linear không có đủ điểm dữ liệu để tính, trả về rỗng đúng lúc cần nhất. Ngưỡng sàn không quan tâm xu hướng, chỉ hỏi một câu đơn giản: "còn bao nhiêu byte trống NGAY BÂY GIỜ" — chậm hơn về mặt cảnh báo sớm, nhưng không thể bị đánh lừa bởi một mô hình toán học.
Chọn trường phái nào phụ thuộc vào đặc thù ghi đĩa của hệ thống, không có câu trả lời đúng tuyệt đối cho mọi trường hợp: nếu workload ghi đĩa luôn tăng dần từ từ và liên tục (log xoay vòng, DB tăng trưởng tự nhiên theo traffic), chỉ cần predict_linear là đủ — thêm ngưỡng tĩnh 80% chỉ tạo nhiễu không cần thiết, đúng như lý do rule gốc cảnh báo. Nhưng nếu hệ thống có khả năng ghi dồn cục bất thường trong thời gian ngắn (batch job đổ dữ liệu lớn, user upload file không kiểm soát size, core dump khi crash), nên giữ thêm một ngưỡng sàn — không đặt ở 80% (sẽ lại gây nhiễu như ban đầu), mà đặt thật sát đáy (ví dụ <5-10% hoặc vài GB tuyệt đối) để nó chỉ kêu khi tình huống đã thực sự khẩn cấp, bất kể mô hình dự báo đang nói gì.
Cách nói khi phỏng vấn: "Em không đặt thêm một ngưỡng tĩnh song song với predict_linear, vì ngưỡng tĩnh theo % vừa gây báo nhầm khi đĩa ổn định lâu ở mức cao, vừa báo trễ khi đĩa tụt nhanh từ mức thấp — predict_linear giải quyết được cả hai cùng lúc. Nhưng nếu hệ thống có rủi ro ghi dồn cục đột ngột mà hồi quy tuyến tính có thể phản ứng không kịp, em vẫn cân nhắc thêm một ngưỡng sàn rất thấp, sát đáy, làm lưới an toàn cuối — không phải quay lại cách làm cũ, mà là một lớp bảo vệ khác cho một loại rủi ro khác."
7. Watchdog — dead man's switch, đảo ngược logic cảnh báo
- alert: Watchdog
expr: vector(1)
labels: { severity: none }
flowchart LR V["vector(1) luôn trả về 1"] -->|"không điều kiện nào, luôn đúng"| AM["Watchdog luôn FIRING"] AM -->|"route riêng, severity=none"| HB["ping định kỳ ra ngoài"] HB -->|"nhận đều đặn"| OK["healthchecks.io: im lặng, không báo"] HB -.->|"NGỪNG nhận ping"| ALM["healthchecks.io tự báo động"] ALM --> ON["Người trực: Prometheus/Alertmanager đang chết"]
Cơ chế: vector(1) là một hàm PromQL không đọc metric nào cả —
nó luôn trả về một vector có đúng 1 sample, giá trị 1. Vì expr không có điều kiện so
sánh nào (không có == 0, không có > x), biểu thức luôn "đúng" ở mọi
vòng evaluate. Kết quả: Watchdog luôn ở trạng thái FIRING, vô thời hạn, miễn là
Prometheus còn sống và còn evaluate rule.
Vì sao một alert luôn kêu lại hữu dụng — "dead man's switch": Alertmanager
route riêng thông báo của Watchdog sang một dịch vụ heartbeat bên ngoài (ví dụ
healthchecks.io, hoặc một cron kiểm tra ở máy khác). Dịch vụ đó mong đợi
nhận được ping định kỳ; nếu ping ngừng đến (không phải "có cảnh báo mới"), chính
dịch vụ ngoài đó sẽ tự báo động. Logic bị đảo ngược: 6 alert khác báo khi có chuyện; Watchdog báo
(gián tiếp, qua sự im lặng) khi hệ thống báo động chính nó chết — Prometheus bị OOM,
container crash, mất mạng tới Alertmanager…
Vì sao severity: none: nhãn này để route KHÔNG đưa Watchdog vào
kênh gọi điện/Slack bình thường — nó không phải sự cố cho người xem, chỉ dùng cho tích hợp
heartbeat. Nếu thiếu bước này, Watchdog "luôn FIRING" sẽ spam kênh thông báo liên tục, vô dụng.
Vì sao đây là cái quan trọng nhất trong 7 cái: 6 alert còn lại đều giả định ngầm rằng Prometheus đang sống và đang evaluate đúng. Watchdog là alert duy nhất kiểm chứng lại chính giả định đó — không có nó, hệ giám sát có thể chết trong im lặng, mọi đồ thị đứng hình, mà không ai biết vì không còn gì gửi cảnh báo cả.
Cách nói khi phỏng vấn: "Đây là một cảnh báo luôn luôn ở trạng thái bật, không gắn với điều kiện lỗi nào cả — em dùng nó như một tín hiệu 'hệ thống giám sát vẫn còn sống', gửi liên tục ra một dịch vụ theo dõi bên ngoài. Nếu dịch vụ đó ngừng nhận được tín hiệu này, chính nó sẽ tự báo động — vì lúc đó nghĩa là Prometheus hoặc Alertmanager đã chết, không phải do hệ thống chính vẫn ổn mà không ai biết."
Bổ sung: 4 cảnh báo riêng cho backend Node.js
7 alert trên trung lập với công nghệ ở tầng app — nginx, php-fpm,
laravel_queue_pending chỉ là ví dụ cụ thể của bài gốc. Nếu backend là
Node.js (Express/Fastify/NestJS...), SiteDown, HighErrorRate, SlowP95 (đo ở
nginx phía trước hoặc exporter HTTP tương đương), DiskWillFillIn4h và Watchdog vẫn áp dụng
nguyên — chỉ có FpmNoFreeWorker là không có bản tương đương, vì lý do sâu hơn
một cái tên: nó bắt nguồn từ khác biệt mô hình xử lý request.
flowchart LR
subgraph PHP["PHP-FPM: multi-process"]
R1["Request A"] --> W1["Worker 1 (process riêng)"]
R2["Request B"] --> W2["Worker 2 (process riêng)"]
W1 -->|"Worker 1 crash/treo"| X1["Chỉ request A bị ảnh hưởng"]
end
subgraph NODE["Node.js: 1 event loop"]
R3["Request C"] --> L["Event loop (1 thread)"]
R4["Request D"] --> L
L -->|"1 tác vụ sync chặn loop / 1 exception không bắt"| X2["TẤT CẢ request đang chờ bị chặn hoặc cả process crash"]
end
PHP-FPM cấp cho mỗi request một worker process riêng (pm.max_children worker) —
một worker treo hay crash chỉ mất request đó, các worker khác không hề biết. Node.js chạy
một event loop duy nhất xử lý toàn bộ request đang mở xen kẽ nhau; không có
hàng đợi worker để đo vì không có khái niệm "worker rảnh/bận". Hậu quả: một đoạn code đồng bộ
chặn loop (JSON.parse payload lớn, bcrypt sync, regex catastrophic backtracking) làm
mọi request khác — không riêng request đang chạy — phải chờ; và một exception không
được bắt có thể giết chết toàn bộ process, kéo theo mọi connection đang mở, không riêng gì
request gây lỗi. Đây là lý do cần 4 cảnh báo riêng, đo đúng vào đặc thù runtime này — dùng
metric mặc định của prom-client (collectDefaultMetrics()), đã kiểm
tra thật bằng cách chạy một process Node.js và đọc output /metrics để lấy đúng tên
metric dưới đây.
8. EventLoopLagHigh — chỉ báo sớm quan trọng nhất của Node.js
- alert: EventLoopLagHigh
expr: nodejs_eventloop_lag_seconds > 0.1
for: 2m
labels: { severity: page }
Cơ chế: nodejs_eventloop_lag_seconds đo độ trễ của event loop
bằng cách lên lịch một callback chạy ở "tick kế tiếp" rồi so sánh thời điểm nó thực sự chạy với
thời điểm dự kiến — chênh lệch đó chính là lag. Ở trạng thái rảnh, lag chỉ vài phần nghìn giây
(dưới 5ms). Khi có code đồng bộ chiếm dụng thread chính (vòng lặp nặng, JSON.parse payload lớn,
hash đồng bộ, regex quét chuỗi dài), event loop không kịp quay lại xử lý callback đang chờ, lag
tăng vọt.
Ví dụ: một request upload parse JSON 50MB bằng JSON.parse đồng bộ tốn 300ms CPU
liên tục. Trong 300ms đó, mọi request khác đang mở trên cùng process — dù
không liên quan gì tới request upload — đều bị treo, vì event loop chỉ có một luồng và đang bận
hoàn toàn. CPU usage của container lúc này có thể vẫn hiển thị bình thường (1 core bận 100%
nhưng container có 4 core), khiến alert theo CPU% không bắt được — còn đo trực tiếp lag thì bắt
được ngay.
Vì sao là chỉ báo quan trọng nhất, không phải chỉ một chỉ báo trong nhiều cái:
khác với PHP-FPM (nghẽn worker chỉ ảnh hưởng request đang xếp hàng, các request khác vẫn chạy
song song ở worker khác), lag event loop ảnh hưởng đồng thời toàn bộ request
đang mở trên process đó — về bản chất gần với SiteDown hơn là FpmNoFreeWorker, nên xứng đáng
severity: page.
Vì sao ngưỡng > 0.1 (100ms) và for: 2m: 100ms là
ngưỡng ví dụ — ứng dụng realtime (chat, trading) cần ngưỡng thấp hơn nhiều (10-20ms), ứng dụng
chịu được độ trễ cao hơn có thể nới ra. for: 2m để loại một cú lag ngắn hạn, hợp lý
(ví dụ một request lớn bất thường, không phải xu hướng).
Cách nói khi phỏng vấn: "Node.js chạy trên một event loop duy nhất, nên em đo trực tiếp độ trễ của chính event loop đó — khác với đo CPU hay RAM, vì CPU container vẫn có thể trông bình thường trong khi một tác vụ đồng bộ đang chiếm hết loop. Nếu lag vượt ngưỡng và giữ nguyên vài phút, nghĩa là có code chặn loop, và hậu quả là toàn bộ request đang mở trên process đó bị treo cùng lúc, không riêng một request nào."
9. NodeHeapLeakTrend — dự báo rò bộ nhớ, không chỉ xem % heap hiện tại
- alert: NodeHeapLeakTrend
expr: predict_linear(nodejs_heap_size_used_bytes[1h], 2*3600)
> nodejs_heap_size_total_bytes
for: 15m
Cơ chế: nodejs_heap_size_used_bytes dao động hình răng cưa một
cách bình thường — tăng khi app tạo object, giảm khi garbage collector dọn. Chỉ nhìn một mốc %
heap hiện tại dễ báo nhầm (đúng lúc GC chưa chạy, heap đang ở đỉnh răng cưa nhưng sẽ tự giảm).
Thứ cần phát hiện là baseline sau mỗi lần GC cứ cao dần lên — dấu hiệu kinh điển
của memory leak (listener không gỡ, cache không giới hạn kích thước, closure giữ tham chiếu object
lớn).
Ví dụ: 1 giờ trước, ngay sau một lần major GC, heap used về khoảng 200MB (đáy răng cưa). Hiện
tại, đáy răng cưa gần nhất là 320MB — baseline đã tăng 120MB trong 1 giờ dù GC vẫn chạy đều.
predict_linear(...[1h], 2*3600) ngoại suy: tốc độ tăng ≈ 120MB/giờ, dự đoán sau 2
giờ nữa baseline ≈ 320 + 240 = 560MB. Nếu heap đang được cấp
(nodejs_heap_size_total_bytes) hiện tại chỉ khoảng 512MB, dự đoán 560MB vượt mức đó
→ FIRING: heap sẽ buộc phải phình to hơn nữa, và nếu xu hướng này không dừng, cuối cùng chạm
--max-old-space-size và process crash vì Out of memory.
Vì sao dùng predict_linear giống DiskWillFillIn4h chứ không
so sánh % heap tức thời: cùng lý do disk — một snapshot tại một thời điểm không phân
biệt được "đang ở đỉnh răng cưa bình thường, sắp tự giảm" với "baseline đang trôi dần lên, sẽ
không bao giờ tự giảm". Nhìn xu hướng qua nhiều chu kỳ GC (cửa sổ [1h], dài hơn một
chu kỳ GC bình thường) mới tách được hai trường hợp này.
Vì sao không severity: page: rò bộ nhớ cho runway tính bằng giờ
trước khi crash thật (khác disk đầy có thể cấp bách hơn tuỳ tốc độ), đủ thời gian xử lý trong giờ
hành chính (restart tạm, deploy fix) mà chưa cần đánh thức ai lúc 2h sáng.
Cách nói khi phỏng vấn: "Heap của Node.js vốn dao động lên xuống theo garbage collector nên em không nhìn một mốc % cố định, mà nhìn xu hướng của phần đáy sau mỗi lần GC qua một giờ gần nhất, rồi ngoại suy xem 2 giờ nữa baseline đó sẽ ở đâu. Nếu nó vượt quá dung lượng heap hiện đang được cấp, nghĩa là ứng dụng đang rò bộ nhớ thật — không phải dao động răng cưa bình thường — và cần xử lý trước khi process bị OOM kill."
10. NodeGcPauseHigh — định vị đúng nguyên nhân khi SlowP95 kêu
- alert: NodeGcPauseHigh
expr: histogram_quantile(0.95,
sum by (le) (rate(nodejs_gc_duration_seconds_bucket{kind="major"}[5m]))) > 0.1
for: 10m
Cơ chế: giống hệt công thức SlowP95 ở mục 3, chỉ đổi đối tượng
đo — nodejs_gc_duration_seconds là Histogram đếm thời gian mỗi lần garbage collector
chạy, tách theo nhãn kind (minor, major,
incremental, weakcb). major (mark-sweep-compact, dọn
"old space") là loại pause dừng toàn bộ event loop lâu nhất trong 4 loại — trong
lúc nó chạy, process không xử lý được bất kỳ request nào, bất kể request đó liên quan gì tới dữ
liệu đang bị GC quét.
Vì sao cần cảnh báo riêng cho GC, không chỉ dựa vào SlowP95 chung:
SlowP95 (mục 3) nói "đang chậm" nhưng không nói rõ chậm vì đâu — DB, network, hay
chính Node runtime. Nếu NodeGcPauseHigh FIRING đúng lúc SlowP95 cũng
FIRING, người trực biết ngay nguyên nhân là GC pause (thường do heap quá lớn hoặc tạo object
tràn lan), nên hướng xử lý là giảm allocation/tối ưu heap, không phải đi soát query DB.
Vì sao không severity: page: cùng logic với SlowP95
— độ trễ tăng nhưng khách vẫn dùng được, không khẩn bằng site chết hay lỗi tràn lan.
Cách nói khi phỏng vấn: "Khi app chậm, em cần biết chậm ở đâu để sửa đúng chỗ. Em tách riêng thời gian dừng do garbage collector loại major — loại gây dừng toàn bộ event loop lâu nhất — ra một alert riêng, dùng đúng công thức p95 như đo latency request. Nếu alert này và alert đo p95 chung cùng kêu, em biết ngay nguyên nhân chậm nằm ở GC của Node runtime, không phải ở database hay network."
11. NodeCrashLoop — dead man's switch phiên bản "process tự chết rồi tự sống lại"
- alert: NodeCrashLoop
expr: changes(process_start_time_seconds[15m]) > 2
for: 0m
labels: { severity: page }
Cơ chế: process_start_time_seconds là một Gauge giữ nguyên một
giá trị — timestamp lúc process khởi động — suốt vòng đời process, và chỉ nhảy sang một
giá trị mới khi process đó bị khởi động lại (crash rồi được PM2/systemd/Kubernetes tự
restart). Hàm changes(...) đếm số lần giá trị một series thay đổi trong khung thời
gian — áp lên process_start_time_seconds nghĩa là đếm số lần process restart.
Ví dụ: process khởi động lúc timestamp 1700000000. Một request trúng một uncaughtException
không được bắt (ví dụ gọi .someProp trên object undefined ở một code
path hiếm gặp), và vì Node.js không tự phục hồi sau exception không bắt ở top-level, toàn bộ
process thoát. PM2 tự restart, process_start_time_seconds nhảy lên 1700000050. Nếu
lỗi đó tái diễn đều — do một request pattern cụ thể cứ lặp lại và luôn trúng đúng path gây crash —
process restart liên tục 3-4 lần trong 15 phút: changes(...) trả về 3-4, vượt ngưỡng 2
→ FIRING.
Vì sao cần alert này riêng, trong khi đã có SiteDown:
SiteDown chỉ bắt được khi site đang down kéo dài — nhưng nếu process
manager restart rất nhanh (dưới vài giây), probe_success có thể vẫn toàn 1 vì
Blackbox Exporter trúng đúng khoảng process đã sống lại. Crash loop kiểu này vẫn gây mất một phần
request thật (mọi connection đang mở lúc crash bị cắt ngang) và là dấu hiệu một bug nghiêm trọng
đang lặp lại, dù "từ ngoài nhìn vào" site có vẻ vẫn chạy.
Vì sao severity: page: process crash lặp lại nhiều lần trong
thời gian ngắn thường là bug logic thật (không tự hồi phục như lag tạm thời), mỗi lần crash cắt
ngang toàn bộ connection đang mở trên process đó — mức độ ảnh hưởng tương đương một cơn
mini-outage lặp lại, cần người trực can thiệp ngay, không để tới sáng.
Cách nói khi phỏng vấn: "Node.js không tự hồi phục sau một exception không được bắt ở top-level — cả process thoát, và process manager phải khởi động lại nó. Em không đo trực tiếp số lần crash, mà đo số lần một giá trị vốn chỉ-đứng-yên — thời điểm process khởi động — bị đổi trong 15 phút gần nhất, vì giá trị đó chỉ đổi khi có restart. Nếu process restart quá 2 lần trong 15 phút, đó là crash loop thật, không phải một lần crash đơn lẻ vô hại, và cần người trực vào xem log ngay."
Bảng tổng hợp để so sánh nhanh
| Alert | Loại metric | Hàm PromQL chính | for | severity | Vì sao chọn vậy |
|---|---|---|---|---|---|
| SiteDown | Gauge (probe) | so sánh trực tiếp | 2m | page | Triệu chứng cao nhất, lọc nhiễu mạng ngắn |
| HighErrorRate | Counter | rate + sum + chia tỉ lệ | 5m | page | Tỉ lệ co giãn theo traffic, không dùng số tuyệt đối |
| SlowP95 | Histogram | histogram_quantile | 10m | (không) | Đo đuôi chậm; khó chịu nhưng chưa khẩn |
| FpmNoFreeWorker | Gauge | so sánh > 0 | 5m | (không) | Leading indicator tại đúng tầng nghẽn |
| QueueBacklog | Gauge | so sánh > ngưỡng | 10m | (không) | Chịu được tới sáng, ngưỡng cần hiệu chỉnh theo throughput |
| DiskWillFillIn4h | Gauge | predict_linear | 15m | page | Dự báo trước khi đầy; hậu quả nặng nên vẫn page |
| Watchdog | — (hằng số) | vector(1) | (không) | none | Dead man's switch — kiểm chứng hệ giám sát còn sống |
| EventLoopLagHigh (Node.js) | Gauge | so sánh trực tiếp | 2m | page | 1 tác vụ sync chặn toàn bộ request trên event loop |
| NodeHeapLeakTrend (Node.js) | Gauge | predict_linear | 15m | (không) | Tách baseline rò bộ nhớ khỏi dao động răng cưa của GC |
| NodeGcPauseHigh (Node.js) | Histogram | histogram_quantile | 10m | (không) | Định vị SlowP95 do GC runtime, không phải DB/network |
| NodeCrashLoop (Node.js) | Gauge | changes() | 0m | page | Bắt crash+restart nhanh mà SiteDown không kịp thấy |
Xem bối cảnh đầy đủ (stack, docker-compose, prometheus.yml) ở bài gốc — Bộ công cụ để vận hành.