Prometheus & Grafana: metric types, PromQL, dashboard
Tiếp đi: Prometheus và Grafana. Mình đã có /actuator/prometheus rồi, giờ dựng phần còn lại trên Kubernetes thế nào?
Phần app (Micrometer, Counter, Timer, endpoint) đã ở Actuator. Bài này đi phía bên kia: Prometheus lấy số về thế nào, bốn loại metric khác nhau ra sao, viết PromQL đúng, và dashboard cho từng service.
1. Bài toán
order, payment, inventory mỗi service 4 Pod, Pod sinh ra chết đi liên tục theo HPA. Cần:
- Thu metric từ mọi Pod mà không phải khai báo IP.
- Một dashboard cho mỗi service: request/s, lỗi, p99, JVM, connection pool.
- Recording rule cho SLO ở bài 19.2.
2. Vấn đề
- IP Pod thay đổi liên tục: cấu hình tĩnh không được.
- Counter tăng mãi rồi reset về 0 khi Pod restart: vẽ trực tiếp vô nghĩa.
- Percentile tính sẵn trong từng Pod không cộng được giữa các Pod.
- Thêm một label tưởng vô hại (
userId) có thể làm Prometheus OOM.
3. Giải pháp: pull model + 4 loại metric
Prometheus kéo (pull) số liệu: định kỳ 15–30s gọi HTTP /actuator/prometheus của từng target. Target tìm qua Kubernetes service discovery (với Prometheus Operator: ServiceMonitor/PodMonitor). Mỗi chuỗi số duy nhất theo tên metric + bộ label là một time series.
| Loại | Đặc điểm | Ví dụ shop-app | Đọc bằng |
|---|---|---|---|
| Counter | Chỉ tăng, reset khi restart | orders_created_total, http_server_requests_seconds_count | rate(), increase() |
| Gauge | Lên xuống tự do, giá trị tức thời | hikaricp_connections_active, jvm_memory_used_bytes | Giá trị trực tiếp, avg_over_time |
| Histogram | Đếm số quan sát rơi vào từng bucket le + _sum + _count | http_server_requests_seconds_bucket | histogram_quantile() trên server |
| Summary | Percentile tính sẵn trong app | ..._seconds{quantile="0.99"} | Đọc trực tiếp, không gộp được |
4. Code từng bước
Bước 1: Cài stack
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm install monitoring prometheus-community/kube-prometheus-stack \
-n monitoring --create-namespace \
--set grafana.adminPassword="${GRAFANA_ADMIN_PASSWORD}"
Stack gồm Prometheus Operator, Prometheus, Alertmanager, Grafana, node-exporter, kube-state-metrics.
Bước 2: Cho Prometheus tìm order/payment/inventory
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: shop-services
namespace: shop
labels: { release: monitoring } # để Prometheus của stack chọn
spec:
selector:
matchLabels: { part-of: shop-app } # Service của 3 app đều có label này
endpoints:
- port: http
path: /actuator/prometheus
interval: 15s
# application.yml (mỗi service)
management:
endpoints.web.exposure.include: health,prometheus
metrics:
tags:
app: ${spring.application.name} # label chung, cardinality thấp
distribution:
percentiles-histogram:
http.server.requests: true # histogram, không phải summary
Bước 3: Metric nghiệp vụ có label đúng cách
@Component
public class PaymentMetrics {
private final MeterRegistry registry;
public PaymentMetrics(MeterRegistry registry) { this.registry = registry; }
public void recordCharge(String provider, ChargeResult result) {
Counter.builder("payment.charges")
.tag("provider", provider) // vnpay, momo, stripe: ~5 giá trị
.tag("result", result.name()) // SUCCESS, DECLINED, TIMEOUT
// KHÔNG .tag("customerId", ...): mỗi khách 1 series
.register(registry)
.increment();
}
}
Số series = tích số giá trị của mỗi label: 5 provider × 3 result × 4 Pod = 60. Thêm customerId với 1 triệu khách = 60 triệu series.
Bước 4: PromQL cơ bản và kiểm tra
# Request/s theo service (counter → luôn qua rate)
sum by (app) (rate(http_server_requests_seconds_count{namespace="shop"}[5m]))
# Tỷ lệ lỗi 5xx
sum by (app) (rate(http_server_requests_seconds_count{outcome="SERVER_ERROR"}[5m]))
/ sum by (app) (rate(http_server_requests_seconds_count[5m]))
# p99 latency: gộp bucket của mọi Pod TRƯỚC, rồi mới tính quantile
histogram_quantile(0.99,
sum by (app, le) (rate(http_server_requests_seconds_bucket{namespace="shop"}[5m])))
# Số đơn tạo trong 1 giờ qua
increase(orders_created_total[1h])
# Connection pool sắp cạn (USE: saturation)
max by (pod) (hikaricp_connections_pending{app="payment-service"})
# Kiểm tra target có UP không
kubectl port-forward svc/monitoring-kube-prometheus-prometheus 9090 -n monitoring
curl -s 'localhost:9090/api/v1/query?query=up{namespace="shop"}' | jq '.data.result[] | [.metric.pod, .value[1]]'
5. Cách hoạt động
┌──────── Kubernetes API (danh sách Pod/Endpoint) ────────┐
▼ │
Prometheus ──scrape 15s──► order-service Pod :8080/actuator/prometheus
│ ──scrape──────► payment-service Pod ...
│ ──scrape──────► node-exporter, kube-state-metrics
│ TSDB (2h block trên disk, giữ ~15 ngày)
├──► rule: recording + alert ──► Alertmanager ──► Slack / PagerDuty
├──► remote_write ──► Thanos / Mimir (lưu dài hạn, HA)
▼
Grafana (datasource Prometheus, Loki, Tempo)
rate() xử lý counter reset:
giá trị: 100 150 200 | 10 60 (Pod restart ở giữa)
rate thấy giảm → coi như reset, cộng bù → tốc độ vẫn đúng
histogram_quantile tính từ bucket:
le=0.1: 600 le=0.3: 900 le=0.8: 985 le=2: 998 le=+Inf: 1000
p99 = request thứ 990 → nằm giữa bucket 0.8 và 2 → nội suy ≈ 1.25s
(độ chính xác phụ thuộc cách chia bucket)
Dashboard mỗi service nên theo một bố cục cố định để on-call nhìn quen mắt:
┌── order-service ─────────────── $env $pod ─┐
│ Row 1 RED: req/s │ error % │ p50/p95/p99 │
│ Row 2 SLO: budget còn lại │ burn rate │
│ Row 3 Phụ thuộc: gọi payment/inventory (RED) │
│ Row 4 USE JVM: heap, GC pause, threads, CPU │
│ Row 5 USE pool: Hikari active/pending, Tomcat│
└───────────── deploy annotation trên đồ thị ──┘
6. So sánh: histogram vs summary
| Histogram | Summary | |
|---|---|---|
| Percentile tính ở | Prometheus (query time) | App (client) |
| Gộp nhiều Pod | Được (sum by (le)) | Không (trung bình p99 ≠ p99) |
| Đổi percentile sau này | Được | Phải deploy lại |
| Độ chính xác | Phụ thuộc bucket | Chính xác trong một Pod |
| Chi phí | Nhiều series (mỗi bucket 1 series) | Ít series |
| Khuyến nghị | Mặc định cho microservices | Chỉ khi một instance duy nhất |
7. Khi nào nên dùng / không nên dùng
- Counter: luôn đọc qua
rate/increase;ratetrước,sumsau. - Cửa sổ
rate≥ 4 lần scrape interval (15s → tối thiểu 1m, thường 5m). - Label chỉ dùng giá trị có giới hạn. URI phải là template (
/orders/{id}), Spring tự làm nếu không tự build URL. - Query nặng dùng nhiều lần (SLO, dashboard) → recording rule.
- Prometheus không phải nơi lưu sự kiện từng request hay dữ liệu cần chính xác 100% (tính tiền): dùng DB/log.
- Job batch chạy xong tắt ngay, không kịp bị scrape: Pushgateway hoặc OTLP push.
Mình thấy có người viết avg(http_server_requests_seconds{quantile="0.99"}) cho p99 toàn service. Sai ở đâu nhỉ?
Sai về toán. Percentile không trung bình được:
Pod A: 1000 request, p99 = 100ms
Pod B: 10 request, p99 = 5s (Pod mới, JIT chưa warm)
avg(p99) = 2.55s ← không phải p99 của 1010 request
p99 thật ≈ 100ms (10 request chậm của B chỉ ~1%)
Kết quả có thể lệch theo cả hai chiều, nên không tin được. Cách đúng: xuất histogram, cộng bucket của mọi Pod rồi mới tính quantile:
histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket{app="order-service"}[5m])))
Lưu ý thứ tự: rate trên từng series trước, sum by (le) sau, phải giữ label le. Nếu bỏ le khỏi by, histogram_quantile trả về rỗng.
Prometheus của mình tự nhiên dùng 30GB RAM rồi bị OOMKilled. Tìm thủ phạm kiểu gì?
Gần như chắc chắn là cardinality nổ. Memory Prometheus tỷ lệ với số series đang hoạt động. Tìm như sau:
# 1. Tổng số series
prometheus_tsdb_head_series
# 2. Metric nào nhiều series nhất
topk(10, count by (__name__) ({__name__=~".+"}))
# 3. Label nào của metric đó có nhiều giá trị
count(count by (uri) (http_server_requests_seconds_count{app="order-service"}))
# Hoặc xem trang Status → TSDB Status trong UI Prometheus
Thủ phạm hay gặp ở Spring Boot:
urichứa ID thật (/orders/1234) do tự gọiRestTemplatevới URL đã ghép chuỗi thay vì URI template, hoặc request 404 từ bot quét.- Tag tự thêm bằng
userId,orderId,email, exception message. - Histogram với quá nhiều bucket nhân với nhiều label.
Chặn từ app bằng MeterFilter, và chặn ở Prometheus bằng sample_limit/metricRelabelings:
@Bean
MeterFilter limitUriCardinality() {
return MeterFilter.maximumAllowableTags("http.server.requests", "uri", 100,
MeterFilter.deny());
}
Phỏng vấn hỏi về Prometheus thì trả lời sao?
Q1. Pull model của Prometheus có ưu nhược điểm gì?
🇻🇳 Tiếng Việt:
Prometheus chủ động scrape endpoint của từng target theo chu kỳ. Ưu điểm: biết ngay target chết qua metric
up, app không cần biết địa chỉ Prometheus, dễ kiểm soát tải, và có thể curl endpoint để debug. Nhược điểm: job ngắn có thể kết thúc trước khi bị scrape, phải dùng Pushgateway hoặc push OTLP; và Prometheus phải với tới được target qua mạng. Trên Kubernetes em dùng ServiceMonitor để tự tìm Pod.
🇬🇧 English:
Prometheus actively scrapes each target's endpoint on an interval. Pros: a dead target shows up immediately through the
upmetric, apps don't need to know where Prometheus is, load is easy to control, and you can curl the endpoint to debug. Cons: short-lived jobs may finish before being scraped, so you need a Pushgateway or OTLP push, and Prometheus must be able to reach targets over the network. On Kubernetes I use ServiceMonitors for automatic Pod discovery.
Q2. Phân biệt counter, gauge, histogram, summary.
🇻🇳 Tiếng Việt:
Counter chỉ tăng, như tổng request, đọc qua
ratehayincrease. Gauge lên xuống, như số connection đang dùng hay heap. Histogram đếm quan sát theo bucket, percentile tính ở server bằnghistogram_quantilevà gộp được giữa các instance. Summary tính percentile sẵn trong app nên chính xác nhưng không gộp được giữa các Pod. Với microservices nhiều replica em dùng histogram.
🇬🇧 English:
A counter only goes up, like total requests, and you read it with
rateorincrease. A gauge goes up and down, like active connections or heap usage. A histogram counts observations into buckets and computes percentiles on the server withhistogram_quantile, so it aggregates across instances. A summary computes percentiles inside the app, which is precise but can't be aggregated across Pods. For replicated microservices I use histograms.
Q3. Vì sao không đặt userId làm label?
🇻🇳 Tiếng Việt:
Mỗi tổ hợp label là một time series riêng, Prometheus giữ series đang hoạt động trong memory. userId làm số series tăng theo số người dùng, dẫn tới memory tăng, query chậm, có thể OOM. Label chỉ nên có giá trị giới hạn như method, status, route template, provider. Thông tin theo từng user để trong log hoặc span attribute.
🇬🇧 English:
Every label combination is a separate time series, and Prometheus keeps active series in memory. A userId label makes the series count grow with your user base, which means more memory, slow queries, and possibly OOM. Labels should have bounded values like method, status, route template or provider. Per-user details belong in logs or span attributes.
Q4. Tính p99 latency của một service nhiều Pod thế nào?
🇻🇳 Tiếng Việt:
Bật histogram trong Micrometer, rồi dùng
histogram_quantile(0.99, sum by (le) (rate(..._bucket[5m]))): rate từng series, cộng bucket của mọi Pod giữ labelle, rồi tính quantile. Không lấy trung bình p99 của từng Pod vì percentile không cộng được. Độ chính xác phụ thuộc bucket, nên em thêm bucket đúng ngưỡng SLO.
🇬🇧 English:
Enable histograms in Micrometer, then use
histogram_quantile(0.99, sum by (le) (rate(..._bucket[5m]))): rate each series, sum buckets across Pods while keeping thelelabel, then compute the quantile. Never average per-Pod p99s because percentiles don't aggregate. Accuracy depends on bucket boundaries, so I add buckets at the SLO thresholds.
Cách nhớ ngắn gọn
- Pull: Prometheus đi scrape;
up= 0 là target chết. - Counter → rate. Gauge → đọc thẳng. Histogram → histogram_quantile. Summary → không gộp.
ratetrước,sum by (le)sau.- Series = tích số giá trị label. Không label ID.
- Dashboard mỗi service: RED → SLO → phụ thuộc → USE.
Tự kiểm tra
Vì sao vẽ trực tiếp giá trị counter là vô nghĩa?
Nó chỉ tăng và reset khi restart; cần rate/increase để ra tốc độ.
scrape interval 30s, cửa sổ rate(...[30s]) có ổn không?
Không: cần ít nhất 2 mẫu, nên dùng ≥ 4× interval (2m).
Prometheus tìm Pod mới của payment-service thế nào?
Qua Kubernetes service discovery; với Operator là ServiceMonitor chọn Service theo label.
Bỏ le khỏi sum by trước histogram_quantile thì sao?
Mất thông tin bucket, kết quả rỗng/sai.
Lưu metric 1 năm thì dùng gì?
remote_write sang Thanos/Mimir/Cortex hoặc dịch vụ managed.