Observability 6 — Cảnh báo & Alertmanager
Vì sao cảnh báo là phần khó nhất của observability
Trong các bài trước ta đã scrape metric (Kiến trúc Prometheus), viết truy vấn (PromQL), gắn instrumentation vào ứng dụng (Instrumentation) và dựng dashboard (Grafana). Nhưng dashboard chỉ hữu ích khi có người đang nhìn. Lúc 3 giờ sáng, khi cổng thanh toán bắt đầu trả lỗi 500 cho 5% giao dịch thẻ, không ai ngồi nhìn Grafana cả. Cảnh báo (alerting) là cơ chế biến metric thành hành động: nó đánh thức đúng người, đúng lúc, với đủ thông tin để xử lý.
Cảnh báo là phần khó nhất và dễ làm sai nhất của observability. Dashboard sai chỉ làm bạn nhìn nhầm. Alert sai gây ra hai loại thiệt hại nghiêm trọng hơn: hoặc bỏ sót sự cố (hệ thống thanh toán chết mà không ai được báo — mất tiền, mất uy tín, có thể vi phạm SLA với đối tác), hoặc alert fatigue — báo động giả nhiều đến mức đội on-call bắt đầu phớt lờ, và rồi phớt lờ luôn cả cái báo động thật. Một hệ thống báo động mà người trực đã tắt tiếng vì quá ồn thì tệ hơn là không có báo động, vì nó tạo cảm giác an toàn giả.
Kiến trúc cảnh báo trong hệ Prometheus tách làm hai phần rõ ràng, và hiểu sự tách biệt này là chìa khoá:
- Prometheus đánh giá alerting rule — quyết định khi nào một điều kiện trở thành alert (đang "firing").
- Alertmanager nhận các alert đang firing và quyết định phải làm gì với chúng — gom nhóm, khử trùng lặp, định tuyến tới đúng người, chặn alert phụ, tạm tắt khi bảo trì.
Prometheus trả lời "cái gì đang sai". Alertmanager trả lời "báo cho ai, như thế nào".
Alerting rule trong Prometheus
Alerting rule sống trong file rule của Prometheus, cùng chỗ với recording rule nhưng dùng từ khoá alert thay vì record. Prometheus đánh giá các rule này định kỳ theo evaluation_interval (thường 15–30s). Một rule có bốn thành phần:
groups:
- name: payment-gateway
rules:
- alert: PaymentHighErrorRate
expr: |
sum(rate(http_requests_total{job="payment-api", code=~"5.."}[5m]))
/
sum(rate(http_requests_total{job="payment-api"}[5m]))
> 0.05
for: 5m
labels:
severity: critical
team: payments
annotations:
summary: "Cổng thanh toán lỗi 5xx > 5% ({{ $value | humanizePercentage }})"
description: "Tỷ lệ HTTP 5xx của payment-api vượt 5% trong 5 phút liên tục. Kiểm tra upstream ngân hàng liên kết và pool DB."
runbook: "https://wiki.ncb.internal/runbooks/payment-high-error-rate"
expr là biểu thức PromQL. Điểm mấu chốt: alert firing khi expr trả về bất kỳ series nào (kết quả khác rỗng). Ví dụ trên trả về một giá trị duy nhất khi tỷ lệ lỗi vượt 5%, và trả về rỗng khi dưới ngưỡng — nên nó "kêu" đúng khi vi phạm. Đừng viết ... < 0.95 khi ý bạn là "lỗi cao"; hãy viết điều kiện sao cho có series = có vấn đề.
for là thời gian giữ điều kiện trước khi thực sự firing. Đây là vũ khí quan trọng nhất chống nhiễu. Không có for, một gai latency 2 giây do GC sẽ bắn alert ngay lập tức. Với for: 5m, Prometheus chuyển alert sang trạng thái pending khi expr đúng, và chỉ chuyển sang firing (gửi cho Alertmanager) nếu expr vẫn đúng liên tục suốt 5 phút. Nếu điều kiện tự hết trong thời gian đó, alert im lặng biến mất. Quy tắc thực tế: for càng dài thì càng ít nhiễu nhưng phát hiện càng chậm — với sự cố thanh toán khẩn cấp dùng for: 2m, với cảnh báo dung lượng đĩa dùng for: 30m.
labels gắn nhãn tĩnh vào alert — quan trọng nhất là severity. Alertmanager định tuyến dựa trên label, nên severity và team ở đây quyết định alert này đi về đâu. Nhãn từ expr (như instance, job) được giữ nguyên và cộng thêm nhãn tĩnh này.
annotations là văn bản cho con người, hỗ trợ template Go. {{ $value }} là giá trị hiện tại của expr, {{ $labels.instance }} là nhãn của series. Quy ước tốt: summary một dòng ngắn (hiện trên tiêu đề tin nhắn), description chi tiết hơn, và luôn kèm runbook — link tới quy trình xử lý. Alert không có runbook là alert bắt người trực phải tự nghĩ lúc 3 giờ sáng.
Vòng đời một alert: inactive → (expr đúng) → pending → (giữ đủ for) → firing → gửi tới Alertmanager. Prometheus tiếp tục gửi lại alert firing đều đặn (mặc định mỗi lần đánh giá) để Alertmanager biết nó vẫn còn sống; khi expr hết đúng, Prometheus gửi một tín hiệu "resolved".
Alertmanager: bộ não định tuyến
Alertmanager là một tiến trình riêng. Prometheus chỉ đẩy alert qua HTTP tới nó; mọi logic "làm gì với alert" nằm ở đây, cấu hình trong alertmanager.yml. Bốn khả năng cốt lõi: routing, grouping, inhibition, silence.
Routing tree — định tuyến theo label
route là một cây. Alert đi vào gốc, rồi "chảy" xuống nhánh con đầu tiên khớp label của nó (theo match/matchers). Mỗi node có thể định receiver (gửi tới đâu) và các tham số grouping. Mặc định alert dừng ở node khớp đầu tiên; đặt continue: true nếu muốn nó tiếp tục khớp các nhánh anh em (ví dụ vừa gửi Slack vừa gửi PagerDuty).
route:
receiver: default-email # fallback nếu không nhánh nào khớp
group_by: ['alertname', 'cluster']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes:
- matchers: [ severity = "critical", team = "payments" ]
receiver: pagerduty-payments
group_wait: 10s # sự cố thanh toán: gom nhanh, báo gấp
continue: true # vẫn chảy tiếp xuống dưới
- matchers: [ team = "payments" ]
receiver: slack-payments
- matchers: [ severity = "warning" ]
receiver: email-tickets
repeat_interval: 24h # cảnh báo không khẩn: nhắc lại thưa hơn
Cây trên có nghĩa: alert critical của đội payments đi thẳng PagerDuty (đánh thức người), rồi vì continue: true nó tiếp tục và cũng vào Slack payments; alert payments không critical chỉ vào Slack; mọi warning còn lại vào hàng đợi email ticket; phần còn lại rơi về default-email.
Grouping — gom alert liên quan
Khi một switch mạng chết, có thể 200 instance cùng lúc bắn InstanceDown. Bạn không muốn 200 tin nhắn. Grouping gom các alert có cùng giá trị của các label trong group_by thành một thông báo. Ở ví dụ trên, group_by: ['alertname', 'cluster'] gom mọi InstanceDown cùng cluster thành một tin: "50 instance down ở cluster payment-prod".
Ba tham số thời gian điều tiết grouping:
| Tham số | Ý nghĩa | Giá trị điển hình |
|---|---|---|
group_wait | Chờ bao lâu sau alert đầu tiên của một nhóm mới, để gom thêm các alert đến ngay sau | 10–30s |
group_interval | Khoảng nghỉ tối thiểu trước khi gửi cập nhật cho một nhóm đã báo (khi có alert mới thêm vào nhóm) | 5m |
repeat_interval | Bao lâu nhắc lại nếu nhóm vẫn đang firing và chưa được xử lý | 4h (critical) → 24h (warning) |
Dùng group_by: ['...'] với danh sách rỗng [] để gom tất cả thành một tin; hoặc dùng group_by: [alertname] để mỗi loại alert một nhóm. Chọn label gom sao cho một tin nhắn tương ứng với một nguyên nhân gốc cần một hành động.
Inhibition — chặn alert phụ khi alert gốc đang kêu
Khi cả một datacenter mất điện, alert DatacenterDown là thứ bạn cần thấy. Hàng trăm alert ServiceUnreachable, DBConnectionFailed bên trong datacenter đó chỉ là hệ quả — chúng che lấp tín hiệu gốc. Inhibition cho phép một alert (source) chặn các alert khác (target) khi cả hai cùng firing và khớp trên các label chung.
inhibit_rules:
- source_matchers: [ severity = "critical", alertname = "DatacenterDown" ]
target_matchers: [ severity = "warning" ]
equal: ['datacenter'] # chỉ chặn alert cùng datacenter
Nghĩa là: khi DatacenterDown critical đang kêu cho datacenter DC1, mọi alert warning khác cùng datacenter=DC1 bị nén lại, không gửi. Người trực thấy đúng một tín hiệu: "DC1 down", thay vì bị chôn vùi dưới trăm cảnh báo hệ quả. Đây là công cụ mạnh nhất để giữ tín hiệu sạch trong sự cố lớn.
Silence — tắt tạm khi bảo trì
Khi bạn biết trước sẽ có nhiễu — nâng cấp core banking lúc 2 giờ sáng, restart cụm DB — bạn tạo silence: một bộ matcher tạm thời (có thời hạn hết) khiến Alertmanager nuốt mọi alert khớp mà không gửi. Silence tạo qua UI Alertmanager hoặc amtool, ví dụ: im lặng mọi alert job=core-banking trong 2 giờ trong khung bảo trì. Khác với inhibition (tự động, dựa trên quan hệ nhân-quả), silence là chủ động, có thời hạn — luôn ghi lý do và người tạo để audit.
Receivers — kênh gửi
receivers định nghĩa đích cụ thể. Nguyên tắc: kênh phải khớp mức khẩn.
- PagerDuty / Opsgenie — cho
critical. Chúng có leo thang (escalation): nếu on-call bậc 1 không xác nhận trong X phút, tự gọi bậc 2, rồi quản lý. Dùng cho cái phải đánh thức người. - Slack — cho cảnh báo nhóm cần thấy nhưng không cần đánh thức. Dễ đọc, có ngữ cảnh, nhưng thông báo Slack dễ bị bỏ lỡ ban đêm — đừng dùng làm kênh duy nhất cho critical.
- Email — cho
warning/ ticket không khẩn, hàng đợi xử lý trong giờ hành chính. - Webhook — cho tích hợp tuỳ ý: tự mở ticket Jira, gọi API tự động khắc phục, ghi vào hệ thống nội bộ.
receivers:
- name: pagerduty-payments
pagerduty_configs:
- service_key: <secret>
description: '{{ .CommonAnnotations.summary }}'
- name: slack-payments
slack_configs:
- channel: '#payments-alerts'
title: '{{ .CommonAnnotations.summary }}'
text: '{{ .CommonAnnotations.description }}'
Nguyên tắc alert TỐT
Cấu hình chỉ là công cụ. Điều quyết định hệ thống báo động có dùng được không là triết lý đằng sau việc chọn cảnh báo gì.
1. Cảnh báo trên triệu chứng (symptom), không phải nguyên nhân (cause). Người dùng không quan tâm CPU đạt 90%; họ quan tâm giao dịch của họ có bị lỗi hay chậm không. Alert đúng là "tỷ lệ giao dịch thất bại > 1%" hoặc "latency p99 > 2s" — những thứ người dùng cảm nhận được. CPU cao mà mọi giao dịch vẫn nhanh và thành công thì không phải sự cố, chỉ là thông tin cho dashboard. Cảnh báo theo triệu chứng còn tự động bắt được cả những nguyên nhân bạn chưa nghĩ ra: dù lỗi do DB, do mạng, hay do bug mới, triệu chứng "giao dịch lỗi" đều kêu.
2. Actionable — có việc để làm. Mỗi alert phải trả lời được câu: "nhận cái này xong tôi làm gì?". Nếu câu trả lời là "chả làm gì, chờ nó tự hết" thì đó không nên là alert (page). Alert không hành động được là nguồn chính của fatigue. Kiểm định nhanh: alert này có runbook không? Nếu không viết nổi runbook, có lẽ nó không đáng page.
3. Tránh alert fatigue. Mỗi báo động giả làm giảm độ tin của toàn hệ thống. Đo tỷ lệ alert được xử lý thực sự so với tổng alert; nếu đội bắt đầu tự động ack-and-ignore một alert nào đó, hãy sửa hoặc xoá nó chứ đừng để nó ở đó. Ít alert nhưng mỗi cái đều thật và đều hành động được — đó là mục tiêu.
4. Phân tầng paging vs ticket. Không phải mọi vấn đề đều đáng đánh thức người:
| Paging (khẩn) | Ticket (không khẩn) | |
|---|---|---|
| Khi nào | Đang / sắp ảnh hưởng người dùng | Cần sửa nhưng chưa cấp bách |
| Kênh | PagerDuty → điện thoại | Email / Jira / Slack |
| Thời gian | Đánh thức 3h sáng | Giờ hành chính |
| Ví dụ | Cổng thanh toán lỗi > 1% | Đĩa còn 20%, chứng chỉ hết hạn 15 ngày nữa |
Ánh xạ này thể hiện trực tiếp qua label severity (critical → paging, warning → ticket) và routing tree ở trên.
Cảnh báo theo SLO & burn rate (kiểu Google)
Cách cũ đặt ngưỡng tĩnh — "báo khi lỗi > 5%" — có hai vấn đề. Ngưỡng thấp thì nhiễu (một gai lỗi vô hại cũng kêu). Ngưỡng cao thì phát hiện chậm và bỏ sót suy giảm chậm nhưng kéo dài. Google, trong sách SRE, đề xuất cách hay hơn: cảnh báo theo tốc độ đốt error budget (error budget burn rate).
SLO (Service Level Objective) là mục tiêu, ví dụ "99.9% giao dịch thanh toán thành công trong 30 ngày". Error budget là phần được phép hỏng: 100% − 99.9% = 0.1% giao dịch. Trong 30 ngày, đó là ngân sách lỗi cụ thể. Burn rate là tốc độ tiêu ngân sách đó so với tốc độ "đều": burn rate = 1 nghĩa là đang tiêu vừa đủ để hết ngân sách đúng cuối 30 ngày; burn rate = 14.4 nghĩa là đang tiêu nhanh gấp 14.4 lần — sẽ đốt sạch ngân sách 30 ngày chỉ trong ~2 ngày.
Ý tưởng multi-window, multi-burn-rate: kết hợp nhiều alert với các cặp (burn rate, cửa sổ thời gian) khác nhau để vừa bắt nhanh sự cố nặng, vừa bắt sự cố nhẹ kéo dài, mà vẫn ít báo giả:
| Mức | Burn rate | Cửa sổ dài | Cửa sổ ngắn (chống nhiễu) | Ý nghĩa | Hành động |
|---|---|---|---|---|---|
| Nhanh & nặng | 14.4 | 1h | 5m | Đốt 2% budget trong 1h | Page (critical) |
| Vừa | 6 | 6h | 30m | Đốt 5% budget trong 6h | Page (critical) |
| Chậm & nhẹ | 1 | 3d | 6h | Rò rỉ chậm dài ngày | Ticket (warning) |
Mỗi mức yêu cầu cả cửa sổ dài và cửa sổ ngắn cùng vượt ngưỡng burn rate — cửa sổ ngắn đảm bảo vấn đề vẫn đang xảy ra (chống báo trễ sau khi sự cố đã hết), cửa sổ dài đảm bảo nó đủ nghiêm trọng (chống nhiễu từ gai nhất thời). Rule PromQL minh hoạ cho mức nhanh & nặng:
- alert: PaymentSLOBurnFast
expr: |
(
job:slo_errors:ratio_rate1h{job="payment-api"} > (14.4 * 0.001)
and
job:slo_errors:ratio_rate5m{job="payment-api"} > (14.4 * 0.001)
)
for: 2m
labels: { severity: critical, team: payments }
annotations:
summary: "Đốt error budget nhanh: hết budget 30 ngày trong ~2 ngày"
runbook: "https://wiki.ncb.internal/runbooks/slo-burn"
(0.001 = 0.1% ngân sách của SLO 99.9%; job:slo_errors:ratio_rate1h là recording rule tính tỷ lệ lỗi 1h — tính trước để rẻ.) Lợi ích lớn: số lượng alert theo SLO ít hơn hẳn ngưỡng tĩnh, và mỗi cái đều gắn trực tiếp với trải nghiệm người dùng và mức khẩn thực sự. Cách tiếp cận này bổ trợ cho phần SLO/SRE bàn ở DevOps — Bảo mật & SRE.
Use case thực tế
Bối cảnh: Cổng thanh toán thẻ của NCB cam kết SLO 99.9% giao dịch thành công/tháng và latency p99 < 2s. Đội on-call 4 người xoay ca tuần. Trước đây họ đặt 40 alert ngưỡng tĩnh; kết quả: ~15 page/tuần, phần lớn là báo giả (gai CPU, một node lẻ restart), đội bắt đầu ack mà không đọc — và một lần thật sự bỏ sót cổng lỗi 8 phút vì nghĩ là báo giả.
Cải tổ theo bài này:
- Cắt về triệu chứng. Xoá hết alert theo CPU/RAM/đĩa khỏi kênh page (chuyển thành ticket email). Chỉ giữ page cho: tỷ lệ giao dịch lỗi, latency p99, và cổng không phản hồi.
- Chuyển sang burn-rate SLO. 3 mức như bảng trên. Định nghĩa recording rule
job:slo_errors:ratio_rate*cho 5m/30m/1h/6h/6h/3d. - Routing: burn nhanh (14.4) và vừa (6) → PagerDuty + Slack
#payments-alerts(continue: true); burn chậm (1) → email ticket.group_by: [alertname],group_wait: 10scho critical. - Inhibition: khi
PaymentGatewayDown(cổng chết hẳn) firing, nén mọi alert latency/error-rate cùngservice— vì cổng chết thì đương nhiên latency và lỗi vô nghĩa. - Silence quy trình: mọi lần triển khai hoặc bảo trì core banking, người trực tạo silence có thời hạn kèm mã change-ticket trước khi bắt đầu.
Kết quả sau 2 tháng: page giảm từ ~15 xuống ~2/tuần, cả hai đều là sự cố thật cần xử lý. Thời gian phát hiện sự cố nặng giảm còn dưới 3 phút (burn rate 14.4 + cửa sổ ngắn 5m). Quan trọng nhất: đội lấy lại niềm tin — khi điện thoại reo, họ biết đó là thật.
Ghi nhớ
- Kiến trúc hai lớp: Prometheus quyết định khi nào alert firing (rule); Alertmanager quyết định làm gì với alert (route/group/inhibit/silence). Đừng nhét logic định tuyến vào rule.
- Alerting rule:
expr(có series = có vấn đề),forđể chống nhiễu (chuyển pending→firing),labels.severityđể routing,annotationscho người + luôn có runbook. - Alertmanager: routing tree match theo label (
continueđể đa kênh), grouping (group_by,group_wait,repeat_interval) gom một-tin-một-nguyên-nhân, inhibition chặn alert hệ quả khi alert gốc kêu, silence tắt tạm chủ động khi bảo trì. - Receiver khớp mức khẩn: PagerDuty cho critical (đánh thức, có escalation), Slack cho awareness, email/webhook cho ticket không khẩn.
- Alert tốt: cảnh báo trên triệu chứng người dùng cảm nhận (không phải nguyên nhân/tài nguyên), actionable (viết được runbook), tối thiểu hoá để tránh alert fatigue.
- Phân biệt paging (khẩn, điện thoại, 3h sáng) vs ticket (không khẩn, giờ hành chính) — thể hiện qua
severityvà routing. - Ưu tiên cảnh báo theo SLO / error budget burn rate với multi-window multi-burn-rate (kiểu Google) thay vì ngưỡng tĩnh: ít báo giả, gắn trực tiếp với trải nghiệm người dùng, tự phân mức khẩn.
Bài viết liên quan
Continuous Integration/Delivery/Deployment, cấu trúc pipeline, ví dụ GitHub Actions và chiến lược release.
Container vs máy ảo, image/layer, Dockerfile, volume, network, Docker Compose và best practices.
Vì sao cần orchestration; Pod, Deployment, Service, Ingress; scaling, self-healing và cấu hình.
Bản chất DevOps: phá bỏ rào cản Dev–Ops, vòng lặp vô tận, CALMS, và vì sao tự động hoá.