Chương 9 — Observability & Giám sát hạ tầng
Tổng quan
Mình bắt đầu để ý mảng này sau vài lần bị hỏi "server load cao từ lúc nào vậy" mà chẳng có gì để tra ngoài cảm giác — không log, không biểu đồ, chỉ đoán. Theo dõi hạ tầng nghiêm túc cần tách bạch hai thứ: log kể lại chuyện gì đã xảy ra, còn metric cho biết hệ thống có đang khỏe không tại một thời điểm. Chương này đi qua bốn công cụ trả lời cho hai câu hỏi đó — dấu vết tấn công nằm trong log, còn sự cố hạ tầng thường khởi đầu từ một chỉ số bất thường, nên cả hai đều thiết yếu với an toàn thông tin.
Nửa đầu chương là ELK Stack — Elasticsearch, Logstash, Kibana, cộng họ agent Beats (Filebeat, Metricbeat, Winlogbeat...) thu log tại nguồn. Log tự nó chỉ là bản ghi sự kiện rời rạc trên từng máy; ELK giải bài toán gom log phân tán về một chỗ rồi làm cho nó tìm được: Elasticsearch dùng inverted index để tra full-text ở quy mô tỷ bản ghi mà RDBMS không kham nổi, Logstash đứng giữa parse và chuẩn hóa log thô thành dữ liệu sạch, còn Kibana biến dữ liệu đã lập chỉ mục đó thành biểu đồ và dashboard — nhìn ra bất thường nhanh hơn đọc log tay nhiều.
Nửa sau là mảng metric, đại diện bởi hai công cụ giải cùng bài toán nhưng khác triết lý. Zabbix đo liên tục CPU, RAM, đĩa, tình trạng dịch vụ và tự cảnh báo qua cơ chế trigger khi vượt ngưỡng — kiểu monitoring truyền thống, cấu hình qua GUI/template. Prometheus là thế hệ cloud-native: server chủ động kéo (scrape) số liệu qua endpoint /metrics mà mỗi exporter phơi ra trên host, lưu vào TSDB riêng, truy vấn bằng PromQL, cảnh báo giao cho Alertmanager. Grafana đứng trên cả hai, không tự thu thập gì — chỉ vẽ dashboard từ nhiều datasource (Prometheus, Elasticsearch/OpenSearch...) lại một chỗ, thay vì mỗi công cụ một giao diện riêng.
Cuối chương có nhắc tới SIEM để phân định rạch ròi: Zabbix và Prometheus lo sức khỏe hạ tầng, còn phát hiện tấn công qua tương quan sự kiện là việc của SIEM — hai lớp bổ trợ, không thay nhau.
9.0 Bảng định vị công cụ
Phần Tổng quan ở trên đã giới thiệu từng công cụ. Mục này không lặp lại các định nghĩa đó, mà đặt các họ công cụ cạnh nhau để thấy rõ chúng khác nhau ở đâu — trước khi đi sâu vào từng phần. Cách trình bày của chương cũng đi tới mức định dạng dữ liệu trên dây (wire format), cấu trúc bản ghi trên đĩa, từng trường cấu hình và từng bước xử lý — đủ để một kỹ sư Blue Team/AppSec/DevSecOps vận hành, gỡ lỗi và đánh giá rủi ro trong thực tế.
Một điểm phân định bản chất nên ghi nhớ trước khi đọc tiếp:
| Khía cạnh | ELK Stack | Zabbix | Prometheus | SIEM (vd Wazuh, Splunk ES) |
|---|---|---|---|---|
| Đơn vị dữ liệu | Document JSON (full-text + structured) | Metric (giá trị số/chuỗi theo thời gian) | Metric time-series (tên + label, giá trị float) | Security event đã chuẩn hóa + rule correlation |
| Mô hình lưu trữ | Inverted index + doc values (Lucene) | Time-series trong RDBMS/TSDB | TSDB cục bộ (block + WAL) | Index + alert store |
| Cách thu thập | Agent (Beats) đẩy về pipeline | Agent passive/active, SNMP, proxy | Server pull (scrape) endpoint /metrics của exporter |
Agent đẩy event về manager |
| Câu hỏi điển hình | "Tìm mọi request 5xx chứa chuỗi X trong 15 phút" | "CPU host A có vượt 90% trong 5 phút không?" | "rate CPU theo mode của cả fleet 5 phút qua là bao nhiêu?" | "Có chuỗi hành vi nào khớp MITRE T1110 không?" |
| Cơ chế cảnh báo | Watcher/Kibana alerting/ElastAlert (bổ sung) | Trigger expression (lõi) | Alerting rule + Alertmanager (tách riêng) | Correlation rules + decoder (lõi) |
| Bản chất | Search engine | Monitoring system | Monitoring system (TSDB + query engine) | Detection & response |
Grafana cố tình không có mặt trong bảng: nó không phải nơi lưu dữ liệu mà là lớp hiển thị đứng trên các hệ ở trên — một dashboard Grafana có thể vẽ đồng thời panel từ Prometheus (hiệu năng) và panel từ Elasticsearch/OpenSearch (log/an ninh).
Ghi nhớ: Elasticsearch là một search engine chứ không phải RDBMS (CSDL quan hệ); Zabbix và Prometheus là monitoring system chứ không phải log store; Grafana là mặt kính chứ không phải kho dữ liệu. Phần lớn các quyết định thiết kế bên dưới đều có thể truy ngược về các bản chất này.
9.1 Elasticsearch — Lõi lưu trữ và tìm kiếm
9.1.1 Inverted index — Vì sao và cấu trúc bên trong
Elasticsearch dựng trên thư viện Apache Lucene. Cấu trúc dữ liệu trung tâm là inverted index (chỉ mục đảo). Trong RDBMS, ta đi từ hàng → cột → giá trị (forward). Inverted index đảo ngược: đi từ term (từ) → danh sách document chứa term đó. Đây là lý do tìm full-text trên hàng tỷ document vẫn ở mức mili-giây: thay vì quét tuyến tính, ta tra cứu term rồi hợp/giao các posting list.
Quy trình đánh chỉ mục một trường text:
Văn bản gốc: "The Quick Brown Fox"
│
▼ (1) Character filter — vd bỏ thẻ HTML, chuẩn hóa ký tự
│
▼ (2) Tokenizer — vd standard tokenizer tách theo ranh giới từ
│ → ["The", "Quick", "Brown", "Fox"]
│
▼ (3) Token filter — vd lowercase, stop words, stemming
│ → ["quick", "brown", "fox"] (the bị loại như stop word)
│
▼ Term được ghi vào inverted index
Cấu trúc inverted index (đơn giản hóa) cho 3 document:
doc1 = "quick brown fox"
doc2 = "quick brown dog"
doc3 = "lazy fox"
Term | Doc Freq | Posting list (docId : positions)
----------|----------|----------------------------------
brown | 2 | doc1:[1], doc2:[1]
dog | 1 | doc2:[2]
fox | 2 | doc1:[2], doc3:[1]
lazy | 1 | doc3:[0]
quick | 2 | doc1:[0], doc2:[0]
Các thành phần Lucene lưu trên đĩa cho mỗi segment (file thực tế trong thư mục index/.../<segment>):
| Thành phần | Đuôi file | Nội dung | Mục đích |
|---|---|---|---|
| Term dictionary | .tim, .tip |
Danh sách term đã sắp xếp + chỉ mục FST vào nó | Tra cứu term O(log) |
| Postings | .doc, .pos, .pay |
docId, position, payload | Trả về document, hỗ trợ phrase query |
| Stored fields | .fdt, .fdx |
Document gốc (_source) |
Trả lại document đầy đủ |
| Doc values | .dvd, .dvm |
Cột giá trị theo docId | Sort, aggregation, script |
| Norms | .nvd, .nvm |
Chuẩn hóa độ dài trường | Tính điểm relevance (BM25) |
Vì sao tách doc values? Inverted index tối ưu cho "term → docs" nhưng tệ cho "docId → giá trị" (cần cho sort/aggregation). Doc values là cấu trúc columnar lưu theo cột, nén tốt, đọc tuần tự nhanh. Đó là lý do trường text (chỉ có inverted index) không sort/aggregate được, còn keyword (có doc values) thì được.
Điểm relevance mặc định là BM25 (Best Matching 25), thay cho TF-IDF cũ. Công thức rút gọn cho mỗi term:
score(D,q) = IDF(q) · ( f(q,D) · (k1 + 1) ) / ( f(q,D) + k1 · (1 - b + b · |D|/avgdl) )
IDF(q) = ln( 1 + (N - n(q) + 0.5) / (n(q) + 0.5) )
f(q,D): tần suất term q trong document D|D|: độ dài document;avgdl: độ dài trung bìnhk1(mặc định 1.2): độ bão hòa tần suất;b(mặc định 0.75): mức ảnh hưởng của độ dàiN: tổng số document;n(q): số document chứa q
Vì sao BM25? TF-IDF tăng điểm tuyến tính theo tần suất, dễ bị thao túng bằng nhồi từ. BM25 có hệ số bão hòa k1 làm điểm hội tụ — lặp một từ 100 lần không tốt hơn nhiều so với 10 lần.
9.1.2 Document, Index, Mapping
Document là đơn vị dữ liệu cơ bản, là một đối tượng JSON. Mỗi document có metadata:
{
"_index": "logs-nginx-2026.06.19",
"_id": "kJ3xY4cBz1aQ",
"_version": 1,
"_seq_no": 42,
"_primary_term": 1,
"_source": {
"@timestamp": "2026-06-19T08:15:30.123Z",
"clientip": "203.0.113.45",
"method": "GET",
"request": "/login",
"status": 401,
"bytes": 512
}
}
| Trường meta | Kiểu | Ý nghĩa | Ví dụ |
|---|---|---|---|
_index |
string | Index chứa document | logs-nginx-2026.06.19 |
_id |
string | Định danh duy nhất trong index (tự sinh nếu không cấp) | kJ3xY4cBz1aQ |
_version |
long | Số lần document bị ghi đè | 1 |
_seq_no |
long | Số thứ tự thao tác trên shard (dùng cho optimistic concurrency) | 42 |
_primary_term |
long | "Nhiệm kỳ" của primary shard, tăng khi failover | 1 |
_source |
object | Dữ liệu gốc người dùng | (JSON) |
Optimistic concurrency control: ghi có điều kiện qua if_seq_no + if_primary_term. Nếu giá trị không khớp (ai đó đã ghi trước), thao tác trả về 409 Conflict. Cơ chế này thay cho lock — vì sao? Vì lock không khả thi trong hệ phân tán có độ trễ mạng.
Mapping định nghĩa kiểu dữ liệu mỗi trường. Tạo index với mapping tường minh (luôn nên làm trong production, tránh dynamic mapping đoán sai):
curl -X PUT "https://es:9200/logs-nginx-2026.06.19" \
-u elastic:$PASS --cacert ca.crt \
-H 'Content-Type: application/json' -d '{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"refresh_interval": "5s"
},
"mappings": {
"dynamic": "strict",
"properties": {
"@timestamp": { "type": "date" },
"clientip": { "type": "ip" },
"method": { "type": "keyword" },
"request": {
"type": "text",
"fields": { "raw": { "type": "keyword", "ignore_above": 256 } }
},
"status": { "type": "short" },
"bytes": { "type": "long" },
"user_agent": { "type": "text" },
"geo": { "type": "geo_point" }
}
}
}'
Bảng data type quan trọng:
| Type | Kích thước lưu trữ | Inverted index | Doc values | Dùng cho |
|---|---|---|---|---|
text |
thay đổi (đã analyze) | Có | Không | Full-text search |
keyword |
nguyên văn | Có (không analyze) | Có | Filter, sort, aggregate, exact match |
byte |
8-bit signed | — | Có | -128..127 |
short |
16-bit signed | — | Có | -32768..32767 |
integer |
32-bit signed | — | Có | số nguyên |
long |
64-bit signed | — | Có | số lớn, epoch ms |
float |
32-bit IEEE 754 | — | Có | số thực |
double |
64-bit IEEE 754 | — | Có | số thực chính xác cao |
date |
long (epoch ms) bên trong | — | Có | thời gian |
ip |
IPv4 (32-bit) / IPv6 (128-bit) | Có | Có | địa chỉ IP, hỗ trợ CIDR |
boolean |
true/false | — | Có | cờ |
geo_point |
lat/lon | — | Có | tọa độ, geo query |
Dấu — ở cột Inverted index nghĩa là kiểu đó không nằm trong inverted index dạng term; thay vào đó Lucene đánh chỉ mục bằng cấu trúc point/BKD-tree để tối ưu cho range query trên số, ngày, IP và tọa độ.
Vì sao trường request vừa là text vừa có sub-field raw kiểu keyword? Đây là pattern multi-field kinh điển: bản text cho phép tìm full-text ("tìm request chứa /admin"), bản keyword cho phép aggregate chính xác ("top 10 URL gọi nhiều nhất"). Một dữ liệu, hai cách dùng.
Lưu ý bảo mật: dynamic: "strict" từ chối document có trường lạ → ngăn mapping explosion (kẻ tấn công gửi log với hàng nghìn key ngẫu nhiên làm phình mapping, gây DoS bộ nhớ heap). ignore_above: 256 ngăn term keyword quá dài làm hỏng index.
9.1.3 Shard, Replica, Segment
Một index được chia thành shard. Mỗi shard là một chỉ mục Lucene độc lập hoàn chỉnh. Có hai loại:
- Primary shard: bản gốc, nhận ghi trước.
- Replica shard: bản sao của primary, phục vụ đọc và đảm bảo HA.
Index "logs" : 3 primary, 1 replica → tổng 6 shard
Node A Node B Node C
┌───────────┐ ┌───────────┐ ┌───────────┐
│ P0 │ │ P1 │ │ P2 │
│ R2 │ │ R0 │ │ R1 │
└───────────┘ └───────────┘ └───────────┘
Quy tắc: replica KHÔNG BAO GIỜ nằm cùng node với primary của nó
(mất 1 node vẫn còn đủ dữ liệu)
Luồng ghi một document:
1. Client gửi index request tới coordinating node
2. Coordinating node băm routing: shard = hash(_routing) % number_of_primary_shards
(mặc định _routing = _id) → xác định primary shard
3. Ghi vào primary shard:
a. Ghi vào in-memory buffer
b. Append vào translog (write-ahead log, fsync mặc định mỗi request)
4. Primary chuyển song song tới mọi replica
5. Khi đủ replica xác nhận → trả 200 cho client
Vòng đời từ buffer ra segment:
in-memory buffer ──refresh (mặc định 1s)──▶ segment mới (searchable, nằm trong filesystem cache)
│
nhiều segment ──merge (nền)──▶ segment lớn hơn (xóa doc đã delete)
│
translog ──flush──▶ fsync segment xuống đĩa, cắt translog
| Tham số | Mặc định | Ý nghĩa | Đánh đổi |
|---|---|---|---|
refresh_interval |
1s | Tần suất buffer thành segment tìm được | Nhỏ = "near real-time" hơn nhưng tốn CPU/IO |
number_of_shards |
(tùy version) | Số primary, cố định sau khi tạo | Quá nhiều shard nhỏ = overhead; quá ít = không scale |
number_of_replicas |
1 | Số bản sao, thay đổi động được | Nhiều replica = đọc nhanh, HA cao, tốn đĩa |
translog.durability |
request | request fsync mỗi ghi; async định kỳ |
async nhanh hơn, rủi ro mất dữ liệu khi crash |
Vì sao số primary shard cố định? Vì routing dùng % number_of_primary_shards. Đổi số shard sẽ đổi đích routing của mọi document cũ → phải reindex toàn bộ. Đây là quyết định thiết kế quan trọng nhất khi tạo index.
Vì sao có translog? Refresh tạo segment trong RAM/cache nhưng chưa fsync xuống đĩa. Nếu node crash giữa hai lần flush, dữ liệu trong segment chưa fsync sẽ mất — translog (đã fsync) cho phép replay để khôi phục.
9.1.4 Query DSL — Truy vấn tới mức từng mệnh đề
Query DSL phân biệt query context (tính điểm relevance, "khớp tốt đến đâu") và filter context (đúng/sai, có cache, "có khớp không"). Filter nhanh hơn vì không tính điểm và được cache bitset.
match (full-text, có analyze):
GET /logs-nginx-*/_search
{ "query": { "match": { "user_agent": "curl python" } } }
→ analyze "curl python" thành term curl, python; mặc định OR; trả document chứa ít nhất một term, tính điểm BM25.
term (exact, KHÔNG analyze — dùng cho keyword/số/ip):
{ "query": { "term": { "status": 401 } } }
→ Lưu ý lỗi kinh điển: dùng term trên trường text thường không khớp vì giá trị đã bị lowercase/tách khi index nhưng term query không analyze.
range:
{ "query": { "range": { "@timestamp": { "gte": "now-15m", "lte": "now" } } } }
bool (tổ hợp — xương sống mọi query thật):
GET /logs-nginx-*/_search
{
"query": {
"bool": {
"must": [ { "match": { "request": "login" } } ],
"filter": [
{ "term": { "status": 401 } },
{ "range": { "@timestamp": { "gte": "now-1h" } } }
],
"should": [ { "term": { "method": "POST" } } ],
"must_not": [ { "term": { "clientip": "10.0.0.5" } } ],
"minimum_should_match": 0
}
},
"size": 20,
"sort": [ { "@timestamp": "desc" } ],
"aggs": {
"by_ip": { "terms": { "field": "clientip", "size": 10 } }
}
}
| Mệnh đề | Context | Ảnh hưởng điểm | Ngữ nghĩa |
|---|---|---|---|
must |
query | Có | AND, đóng góp điểm |
filter |
filter | Không (cache) | AND, chỉ lọc |
should |
query | Có | OR mềm, tăng điểm |
must_not |
filter | Không | NOT |
Truy vấn trên (đọc ngữ nghĩa bảo mật): "Tìm tối đa 20 request đăng nhập (full-text 'login') có status 401, trong 1 giờ qua, không đến từ 10.0.0.5, ưu tiên hiển thị POST cao hơn, đồng thời gom top 10 IP." Đây chính là dạng săn brute-force.
Lưu ý bảo mật query: tránh script query/aggregation với input không tin cậy (Painless chạy trong sandbox nhưng vẫn có lịch sử CVE liên quan script). Đặt search.max_buckets để chặn aggregation bùng nổ gây OOM. Giới hạn size và dùng search_after/PIT thay vì deep pagination from lớn.
9.1.5 Node, Cluster, Roles và bảo mật
Một cluster gồm nhiều node, mỗi node có thể đảm nhận vai trò:
| Role | Ký hiệu | Nhiệm vụ |
|---|---|---|
| master-eligible | m |
Bầu master, quản lý cluster state (mapping, shard allocation) |
| data | d (data_hot, data_warm, data_cold, data_frozen) |
Lưu shard, xử lý CRUD/search |
| ingest | i |
Chạy ingest pipeline (tiền xử lý trước khi index) |
| coordinating | (mọi node) | Nhận request, phân tán, gom kết quả |
| ml | l |
Machine learning jobs |
Bầu master & quorum: cluster cần (số master-eligible / 2) + 1 node để bầu master (tránh split-brain). Với 3 master-eligible, quorum = 2. Vì sao luôn dùng số lẻ master-eligible (3, 5)? Để có quorum rõ ràng khi network partition.
elasticsearch.yml tối thiểu cho production có bảo mật:
cluster.name: prod-siem
node.name: es-data-01
node.roles: [ data_hot, ingest ]
network.host: 0.0.0.0
discovery.seed_hosts: ["es-master-01", "es-master-02", "es-master-03"]
cluster.initial_master_nodes: ["es-master-01", "es-master-02", "es-master-03"]
xpack.security.enabled: true
xpack.security.transport.ssl.enabled: true
xpack.security.transport.ssl.verification_mode: certificate
xpack.security.transport.ssl.keystore.path: certs/transport.p12
xpack.security.http.ssl.enabled: true
xpack.security.http.ssl.keystore.path: certs/http.p12
Lưu ý bảo mật (cực kỳ quan trọng):
- Lịch sử Elasticsearch bị lộ dữ liệu khổng lồ vì chạy không xác thực, bind 0.0.0.0, mở cổng 9200 ra Internet. Luôn bật xpack.security.enabled: true, TLS cho cả transport (9300) lẫn HTTP (9200), và RBAC.
- Tách hai lớp TLS: transport (9300) là giao tiếp nội bộ giữa node (cluster); HTTP (9200) là API client. Cả hai phải mã hóa.
- Dùng role-based access: tạo role chỉ đọc index cụ thể, gán cho API key thay vì dùng superuser elastic.
# Tạo role chỉ đọc index logs-*
curl -X POST "https://es:9200/_security/role/log_reader" -u elastic:$PASS --cacert ca.crt \
-H 'Content-Type: application/json' -d '{
"indices":[{"names":["logs-*"],"privileges":["read","view_index_metadata"]}]
}'
9.2 Logstash — Pipeline xử lý dữ liệu
9.2.1 Kiến trúc pipeline input → filter → output
Logstash xử lý dữ liệu theo pipeline 3 giai đoạn, mỗi event là một đối tượng có @timestamp, @version, @metadata và các field:
┌──────────────────────── Logstash Pipeline ────────────────────────┐
nguồn ──▶│ INPUT ──▶ [queue] ──▶ FILTER (worker threads) ──▶ OUTPUT │──▶ đích
└────────────────────────────────────────────────────────────────────┘
beats beats in-memory grok → date → mutate elasticsearch
syslog plugin hoặc → geoip → ... stdout
kafka persistent kafka
| Giai đoạn | Vai trò | Plugin ví dụ |
|---|---|---|
| input | Nhận dữ liệu | beats, tcp, udp, syslog, kafka, file, http |
| filter | Biến đổi, làm giàu, parse | grok, date, mutate, geoip, kv, json, dissect, useragent |
| output | Gửi đi | elasticsearch, stdout, kafka, file, s3 |
Persistent queue: mặc định queue trong RAM (mất event khi crash). Bật queue.type: persisted để ghi event xuống đĩa (có ACK) — quan trọng cho log bảo mật không được mất.
9.2.2 GROK — Parse log phi cấu trúc
GROK dịch chuỗi log dạng tự do thành field bằng các pattern đặt tên, bản chất là regex có alias. Cú pháp: %{PATTERN:field_name} hoặc %{PATTERN:field_name:type}.
Một số pattern dựng sẵn (định nghĩa trong grok-patterns):
| Pattern | Regex tương đương (rút gọn) | Khớp |
|---|---|---|
IPV4 |
(?:[0-9]{1,3}\.){3}[0-9]{1,3} |
203.0.113.45 |
NUMBER |
(?:-?\d+(\.\d+)?) |
512, -3.14 |
WORD |
\b\w+\b |
GET |
DATA |
.*? (lười) |
bất kỳ, ngắn nhất |
GREEDYDATA |
.* (tham) |
phần còn lại |
QS |
chuỗi trong dấu nháy | "GET /x HTTP/1.1" |
HTTPDATE |
định dạng ngày Apache | 19/Jun/2026:08:15:30 +0700 |
COMBINEDAPACHELOG |
pattern tổng hợp Nginx/Apache | cả dòng |
Ví dụ parse một dòng log Nginx (combined format):
Dòng thật:
203.0.113.45 - alice [19/Jun/2026:08:15:30 +0700] "POST /login HTTP/1.1" 401 512 "https://app/" "Mozilla/5.0"
Pattern GROK:
%{IPV4:clientip} - %{DATA:auth} \[%{HTTPDATE:timestamp}\] "%{WORD:method} %{DATA:request} HTTP/%{NUMBER:httpversion}" %{NUMBER:status:int} %{NUMBER:bytes:int} %{QS:referrer} %{QS:agent}
Kết quả field được trích:
| Field | Giá trị | Kiểu |
|---|---|---|
clientip |
203.0.113.45 |
string→ip |
auth |
alice |
string |
timestamp |
19/Jun/2026:08:15:30 +0700 |
string |
method |
POST |
string |
request |
/login |
string |
httpversion |
1.1 |
string |
status |
401 |
int |
bytes |
512 |
int |
referrer |
"https://app/" |
string |
agent |
"Mozilla/5.0" |
string |
Vì sao dùng DATA (lười) vs GREEDYDATA (tham)? DATA (.*?) khớp ngắn nhất, dừng ngay khi gặp ký tự phân định kế tiếp (dấu cách, ngoặc). GREEDYDATA (.*) nuốt tối đa — chỉ dùng ở cuối dòng. Đặt sai chỗ sẽ gây _grokparsefailure.
Ví dụ parse log SSH (auth.log) săn brute-force:
Dòng thật:
Jun 19 08:15:30 web01 sshd[2451]: Failed password for invalid user admin from 203.0.113.45 port 51324 ssh2
Pattern:
%{SYSLOGTIMESTAMP:syslog_ts} %{HOSTNAME:host} %{WORD:program}\[%{NUMBER:pid}\]: Failed password for( invalid user)? %{USERNAME:ssh_user} from %{IP:src_ip} port %{NUMBER:src_port} ssh2
9.2.3 mutate, date, geoip
date filter — chuyển chuỗi timestamp thành @timestamp chuẩn (vì sao quan trọng: nếu không, Kibana dùng thời điểm ingest chứ không phải thời điểm sự kiện, làm sai timeline điều tra):
date {
match => [ "timestamp", "dd/MMM/yyyy:HH:mm:ss Z" ]
target => "@timestamp"
timezone => "Asia/Ho_Chi_Minh"
}
mutate filter — biến đổi field:
mutate {
convert => { "status" => "integer" "bytes" => "integer" }
lowercase => [ "method" ]
rename => { "clientip" => "[source][ip]" }
remove_field => [ "timestamp", "host" ]
gsub => [ "referrer", "[\"]", "" ]
}
geoip filter — làm giàu IP thành tọa độ/quốc gia (dựa MaxMind GeoLite2 DB):
geoip {
source => "[source][ip]"
target => "[source][geo]"
}
→ thêm [source][geo][country_name], [source][geo][location] (geo_point) → vẽ map IP tấn công trong Kibana.
9.2.4 File logstash.conf đầy đủ (chạy được)
input {
beats {
port => 5044
ssl_enabled => true
ssl_certificate => "/etc/logstash/certs/logstash.crt"
ssl_key => "/etc/logstash/certs/logstash.key"
}
}
filter {
if [event][module] == "nginx" {
grok {
match => { "message" => "%{IPV4:clientip} - %{DATA:auth} \[%{HTTPDATE:timestamp}\] \"%{WORD:method} %{DATA:request} HTTP/%{NUMBER:httpversion}\" %{NUMBER:status:int} %{NUMBER:bytes:int} %{QS:referrer} %{QS:agent}" }
tag_on_failure => ["_grokparsefailure_nginx"]
}
date {
match => [ "timestamp", "dd/MMM/yyyy:HH:mm:ss Z" ]
target => "@timestamp"
}
mutate {
convert => { "status" => "integer" }
lowercase => [ "method" ]
remove_field => [ "timestamp" ]
}
geoip { source => "clientip" target => "geo" }
useragent { source => "agent" target => "ua" }
if [status] >= 400 and [status] < 500 {
mutate { add_tag => ["client_error"] }
}
}
}
output {
if "_grokparsefailure_nginx" in [tags] {
file { path => "/var/log/logstash/failed_nginx.log" }
} else {
elasticsearch {
hosts => ["https://es-01:9200"]
index => "logs-nginx-%{+YYYY.MM.dd}"
user => "logstash_writer"
password => "${LS_ES_PASS}"
ssl_enabled => true
cacert => "/etc/logstash/certs/ca.crt"
}
}
}
Giải thích các quyết định:
- index => "logs-nginx-%{+YYYY.MM.dd}": index theo ngày → dễ áp ILM (xóa/rollover) và giới hạn kích thước shard.
- Tách output cho event lỗi parse → không "nuốt im lặng" dữ liệu sai, vẫn điều tra được.
- useragent tách user-agent thành OS/browser → hữu ích phát hiện công cụ tự động (curl, sqlmap, nikto).
Lưu ý bảo mật Logstash:
- Dùng user ES riêng logstash_writer chỉ có quyền create_index/write index logs-*, không dùng elastic.
- Mật khẩu qua keystore (logstash-keystore add LS_ES_PASS), không hardcode.
- GROK với regex tham lam/backtracking trên input thù địch có thể gây ReDoS (CPU 100%). Ưu tiên dissect (không regex) cho log có cấu trúc cố định; đặt timeout_millis cho grok.
9.3 Kibana — Trực quan hóa và truy vấn
9.3.1 Index pattern / Data view
Kibana không lưu dữ liệu; nó hỏi Elasticsearch. Index pattern (tên mới: Data view) khai báo nhóm index Kibana được phép truy vấn, ví dụ logs-nginx-*, và chỉ định time field (@timestamp) để bộ chọn thời gian hoạt động.
9.3.2 Discover và KQL
Discover là màn hình duyệt log thô. Ngôn ngữ truy vấn mặc định là KQL (Kibana Query Language) — đơn giản hơn Query DSL, biên dịch ngầm sang DSL.
| Mục đích | KQL | Tương đương DSL |
|---|---|---|
| Khớp giá trị | status: 401 |
term |
| AND | status: 401 and method: post |
bool.must |
| OR | status: 401 or status: 403 |
should |
| NOT | not clientip: "10.0.0.5" |
must_not |
| Khoảng | bytes > 1000 |
range |
| Wildcard | request: *admin* |
wildcard |
| Tồn tại field | geo.country_name: * |
exists |
Ví dụ săn brute-force trong Discover:
event.module: "nginx" and status: 401 and request: "/login"
Kết hợp time picker "Last 1 hour" → đếm tần suất theo IP.
9.3.3 Visualization, Dashboard
- Visualization (Lens): vẽ một biểu đồ từ aggregation — ví dụ "Bar chart: count theo
clientip(terms agg), lọc status 401" để thấy IP brute-force. - Dashboard: ghép nhiều visualization + filter chung + time range chung. Một dashboard SOC điển hình: bản đồ geo IP, top failed-login IP, biểu đồ status code theo thời gian, bảng top user-agent bất thường.
Lưu ý bảo mật Kibana:
- Kibana có RBAC theo space và index: tạo role analyst chỉ đọc dashboard và index logs-*, không cho quản trị cluster.
- Bật TLS giữa Kibana ↔ Elasticsearch và browser ↔ Kibana.
- Cảnh giác với chức năng cho phép script/embedded — giới hạn ai được tạo visualization dùng script.
9.4 Beats — Thu thập dữ liệu tại nguồn
Beats là họ agent nhẹ (viết bằng Go, biên dịch tĩnh) đặt trên host nguồn. Mỗi beat chuyên một loại dữ liệu.
| Beat | Dữ liệu | Cơ chế |
|---|---|---|
| Filebeat | Log file, container log | Harvester đọc file, theo dõi offset |
| Metricbeat | Metric hệ thống/dịch vụ | Module poll định kỳ |
| Winlogbeat | Windows Event Log | Đọc qua Windows Event Log API |
| Packetbeat | Giao thức mạng (decode trên dây) | Sniff network |
| Auditbeat | Audit framework Linux (auditd) | Đọc audit events |
9.4.1 Filebeat — Harvester và registry
Cơ chế cốt lõi:
filebeat.inputs path: /var/log/nginx/*.log
│
▼
Một HARVESTER cho mỗi file đang mở
- đọc từ offset cuối cùng (lưu trong registry)
- mỗi dòng → một event
│
▼
REGISTRY (/var/lib/filebeat/registry)
- lưu {inode, offset, device} cho mỗi file
- vì sao theo inode? để nhận diện file dù bị đổi tên (logrotate)
│
▼
SPOOLER/QUEUE → publish tới output (Logstash/ES/Kafka)
- at-least-once delivery: chỉ cập nhật offset sau khi output ACK
Vì sao at-least-once? Filebeat chỉ advance offset trong registry sau khi nhận ACK từ đích. Crash giữa chừng → gửi lại từ offset cũ → có thể trùng nhưng không mất log. Trùng được xử lý ở ES bằng _id xác định (fingerprint).
Module: Filebeat đóng gói sẵn cấu hình parse cho dịch vụ phổ biến (nginx, system, apache, auditd). Module gồm: input path mặc định + ingest pipeline (parse) + index template + dashboard.
filebeat modules enable nginx system
filebeat setup --pipelines --modules nginx # cài ingest pipeline vào ES
filebeat setup --dashboards # nạp dashboard Kibana
Ví dụ filebeat.yml đầy đủ (gửi qua Logstash, có TLS):
filebeat.inputs:
- type: filestream
id: nginx-access
enabled: true
paths:
- /var/log/nginx/access.log
fields:
event.module: nginx
fields_under_root: true
parsers:
- multiline:
type: pattern
pattern: '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}'
negate: true
match: after
filebeat.registry.path: /var/lib/filebeat/registry
processors:
- add_host_metadata: ~
- drop_fields:
fields: ["agent.ephemeral_id", "ecs.version"]
output.logstash:
hosts: ["logstash-01:5044"]
ssl.enabled: true
ssl.certificate_authorities: ["/etc/filebeat/certs/ca.crt"]
ssl.certificate: "/etc/filebeat/certs/filebeat.crt"
ssl.key: "/etc/filebeat/certs/filebeat.key"
logging.level: info
Giải thích:
- type: filestream (thay cho log cũ): theo dõi file ổn định hơn với logrotate.
- multiline: gộp dòng tiếp nối (vd stack trace) vào một event; pattern "dòng bắt đầu bằng IP là dòng mới", các dòng khác nối vào trước.
- add_host_metadata: gắn hostname, OS, IP → định danh nguồn khi điều tra.
9.4.2 Metricbeat
Thu metric qua module poll theo period:
metricbeat.modules:
- module: system
metricsets: [cpu, memory, network, filesystem, process]
period: 10s
processes: ['.*']
- module: nginx
metricsets: [stubstatus]
period: 10s
hosts: ["http://127.0.0.1/nginx_status"]
output.elasticsearch:
hosts: ["https://es-01:9200"]
username: "metricbeat_writer"
password: "${MB_PASS}"
ssl.certificate_authorities: ["/etc/metricbeat/certs/ca.crt"]
system.cpu.utilization (Metricbeat) tương tự khái niệm system.cpu.util bên Zabbix — nhưng đây là metric phục vụ phân tích, không có trigger/threshold engine như Zabbix. Vai trò của Metricbeat cũng tương đương node_exporter bên hệ Prometheus (xem 9.6) — khác ở chiều dữ liệu: Metricbeat đẩy về Elasticsearch, còn node_exporter chỉ phơi metric ra để Prometheus kéo.
9.4.3 Winlogbeat
Đọc Windows Event Log (Security, System, Sysmon) — nguồn vàng cho Blue Team:
winlogbeat.event_logs:
- name: Security
event_id: 4624, 4625, 4672, 4688 # logon thành công/thất bại, quyền đặc biệt, tạo process
- name: Microsoft-Windows-Sysmon/Operational
output.logstash:
hosts: ["logstash-01:5044"]
ssl.enabled: true
ssl.certificate_authorities: ["C:\\ProgramData\\winlogbeat\\ca.crt"]
| Event ID | Ý nghĩa | Giá trị điều tra |
|---|---|---|
| 4624 | Logon thành công | Theo dõi logon type (10=RDP), nguồn IP |
| 4625 | Logon thất bại | Săn brute-force, password spraying |
| 4672 | Gán quyền đặc biệt | Phát hiện logon admin |
| 4688 | Tạo process mới | Săn lệnh độc hại (cần bật command-line auditing) |
9.4.4 Luồng dữ liệu tổng thể
[Host nguồn] [Pipeline] [Lưu trữ/Tìm] [Hiển thị]
Filebeat ───┐
Metricbeat ─┼──TLS:5044──▶ Logstash ──TLS:9200──▶ Elasticsearch ◀──── Kibana
Winlogbeat ─┘ (grok/date/geoip) (index, shard) (Discover,
Dashboard, KQL)
Biến thể: Beats ──▶ Elasticsearch trực tiếp (dùng ingest pipeline thay Logstash)
khi không cần biến đổi phức tạp (nhẹ hơn, ít thành phần hơn).
Khi nào bỏ Logstash? Nếu chỉ cần parse đơn giản, dùng ingest pipeline trong Elasticsearch (chạy trên ingest node) → bớt một thành phần. Giữ Logstash khi cần buffer lớn (persistent queue), nhiều nguồn không phải Beats (syslog, kafka), hoặc biến đổi nặng.
9.5 Zabbix — Giám sát hạ tầng và hiệu năng
9.5.1 Kiến trúc Server / Agent / Proxy
┌──────────────────────────────────────────────────────────────┐
│ ZABBIX SERVER │
│ - thu thập, đánh giá trigger, sinh event, chạy action │
│ - ghi vào DB (MySQL/PostgreSQL/TimescaleDB) │
└───────▲───────────────────▲────────────────────▲──────────────┘
│ TCP 10051 │ │
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴────────┐
│ Zabbix Proxy │ │ Zabbix Agent │ │ SNMP / IPMI │
│ (gom 1 vùng) │ │ (trên host) │ │ agentless │
└───────▲───────┘ └───────────────┘ └────────────────┘
│ TCP 10050
┌───────┴───────┐
│ Zabbix Agent │
└───────────────┘
Frontend (PHP) + Zabbix DB → giao diện web, cấu hình, dashboard
| Thành phần | Cổng mặc định | Vai trò |
|---|---|---|
| Zabbix Server | nghe 10051 | Lõi xử lý, đánh giá trigger, chạy action |
| Zabbix Agent (passive) | nghe 10050 | Server hỏi, agent trả về giá trị item |
| Zabbix Agent (active) | kết nối 10051 | Agent chủ động đẩy dữ liệu lên server |
| Zabbix Proxy | nghe 10051 | Thu thập thay server cho một vùng/DMZ, đệm khi mất kết nối |
| Frontend | 80/443 | Web UI (PHP) |
Passive vs Active agent — khác biệt cốt lõi:
| Passive | Active | |
|---|---|---|
| Ai khởi tạo kết nối | Server → Agent (10050) | Agent → Server (10051) |
| Phù hợp | Mạng server thấy được agent | Agent sau NAT/firewall, nhiều host |
| Tải server | Cao hơn (server poll từng item) | Thấp hơn (agent đẩy theo batch) |
| Cấu hình item | Hỏi từng lần | Agent lấy danh sách item rồi tự thu |
Vì sao có Proxy? Trong môi trường nhiều site/DMZ, proxy thu thập cục bộ rồi gửi gộp lên server (chỉ một kết nối qua firewall). Proxy còn đệm dữ liệu trong DB cục bộ khi mất kết nối tới server → không mất metric.
9.5.2 Định dạng giao thức Zabbix trên dây (Zabbix protocol)
Đây là phần "đào tới byte". Mọi giao tiếp agent/server dùng một header nhị phân cố định:
Offset Kích thước Trường Giá trị/ý nghĩa
------ ---------- -------------- --------------------------------------------
0 4 bytes Protocol magic ASCII "ZBXD" = 0x5A 0x42 0x58 0x44
4 1 byte Flags bit0=0x01 Zabbix communications protocol
bit1=0x02 compression (zlib)
bit2=0x04 large packet (dùng 8-byte length)
5 4 bytes Data length uint32 little-endian = độ dài payload (đã nén nếu có)
9 4 bytes Reserved nếu nén: uint32 LE = độ dài payload sau giải nén
nếu không nén: 0x00000000
13 N bytes Payload JSON (UTF-8)
Ghi chú: khi flag large packet (0x04) được bật, hai trường length dùng 8 byte mỗi trường (header dài hơn). Nên kiểm chứng bằng bắt gói thực tế nếu cần độ chính xác tuyệt đối theo version.
Sơ đồ ASCII một gói thật (không nén, không large packet):
+------+------+------+------+------+ +------+------+------+------+ +-------------+
| Z | B | X | D | flag | | len (4B LE) | | reserved(4B)| JSON...
| 5A | 42 | 58 | 44 | 01 | | xx xx xx xx | | 00 00 00 00 |
+------+------+------+------+------+ +------+------+------+------+ +-------------+
0 1 2 3 4 5 8 9 12 13...
Ví dụ payload JSON khi agent active gửi dữ liệu:
{
"request": "agent data",
"session": "f3...",
"data": [
{ "host": "web01", "key": "system.cpu.util", "value": "12.50",
"clock": 1750300530, "ns": 123456789 }
],
"clock": 1750300530
}
| Trường JSON | Ý nghĩa | Ví dụ |
|---|---|---|
request |
Loại request | agent data, active checks, sender data |
host |
Tên host trong Zabbix | web01 |
key |
Item key | system.cpu.util |
value |
Giá trị thu được | 12.50 |
clock |
Epoch giây thời điểm đo | 1750300530 |
ns |
Nano-giây bổ sung độ chính xác | 123456789 |
Vì sao có magic "ZBXD" + length tường minh? TCP là stream không có ranh giới message. Magic giúp nhận diện protocol, trường length cho biết đọc bao nhiêu byte payload → tách message chính xác. Flag large packet cho phép payload rất lớn bằng cách dùng length 8 byte.
Bắt gói để kiểm chứng:
tcpdump -i any -n -A 'tcp port 10051' -c 20
# hoặc test một item bằng zabbix_get (xem 9.5.4)
9.5.3 Item — Đơn vị đo
Item định nghĩa "đo cái gì, bằng cách nào". Mỗi item gắn một key.
| Thành phần item | Ý nghĩa | Ví dụ |
|---|---|---|
| Key | Định danh phép đo, có thể có tham số | system.cpu.util[,user] |
| Type | Cách thu thập | Zabbix agent / Zabbix agent (active) / SNMP / Calculated / Dependent / HTTP / Trapper |
| Value type | Kiểu giá trị | Numeric (unsigned/float), Character, Log, Text |
| Update interval | Tần suất thu | 1m, 30s |
| History | Giữ giá trị thô bao lâu | 7d |
| Trends | Giữ thống kê giờ (min/avg/max) | 365d |
| Preprocessing | Biến đổi trước khi lưu | regex, JSONPath, change-per-second, throttling |
Các item key thường gặp:
| Key | Đo gì |
|---|---|
system.cpu.util |
% CPU sử dụng |
system.cpu.load[all,avg1] |
Load average 1 phút |
vm.memory.size[available] |
RAM khả dụng (byte) |
vfs.fs.size[/,pfree] |
% dung lượng trống của / |
net.if.in[eth0] |
Byte nhận trên eth0 (counter) |
net.tcp.service[ssh,,22] |
Kiểm tra dịch vụ SSH (1=up,0=down) |
proc.num[nginx] |
Số tiến trình nginx |
agent.ping |
Agent còn sống (1) |
vfs.file.contents[/etc/passwd] |
(nguy hiểm — xem lưu ý) |
Vì sao tách History và Trends? History (giá trị thô từng lần đo) phình rất nhanh — không thể giữ lâu. Trends gộp theo giờ (min/avg/max) chiếm rất ít chỗ, giữ được hàng năm để xem xu hướng dài hạn. Đây là chiến lược chống phình DB cốt lõi.
Preprocessing với counter: net.if.in là bộ đếm tăng dần (byte tích lũy). Dùng preprocessing "Change per second" để ra throughput byte/giây — chính là cơ chế tính tốc độ từ counter (tương tự SNMP).
9.5.4 Ví dụ thực tế thu thập item
zabbix_agentd.conf (passive + active):
Server=10.0.0.10 # server được phép poll (passive), CSV
ServerActive=10.0.0.10:10051 # server để agent đẩy active checks
Hostname=web01 # phải khớp host name trong Zabbix
ListenPort=10050
TLSConnect=psk
TLSAccept=psk
TLSPSKIdentity=PSK web01
TLSPSKFile=/etc/zabbix/zabbix_agentd.psk
Tự định nghĩa item (UserParameter) — ví dụ đếm số kết nối ESTABLISHED:
UserParameter=net.tcp.established,ss -ant state established | wc -l
Lấy thử giá trị từ server bằng zabbix_get (kiểm chứng item passive):
zabbix_get -s 10.0.0.20 -p 10050 -k "system.cpu.util"
# Output mẫu:
12.5026
zabbix_get -s 10.0.0.20 -k "net.tcp.established"
47
Đẩy giá trị thủ công bằng zabbix_sender (cho item type Trapper):
zabbix_sender -z 10.0.0.10 -s "web01" -k "app.queue.depth" -o 128
# Output:
# info from server: "processed: 1; failed: 0; total: 1; seconds spent: 0.000123"
# sent: 1; skipped: 0; total: 1
9.5.5 Trigger — Đánh giá điều kiện
Trigger là biểu thức boolean trên dữ liệu item; khi đúng → chuyển trạng thái sang PROBLEM, khi sai → OK. Đây là phần thay thế cho "rule" — nhưng dựa trên ngưỡng metric chứ không phải pattern bảo mật.
Cú pháp hàm (Zabbix 5.4+): function(/host/key, parameter).
Ví dụ trigger expression thực tế:
# CPU trung bình 5 phút > 90%
avg(/web01/system.cpu.util,5m) > 90
# Dung lượng / còn dưới 10%
last(/web01/vfs.fs.size[/,pfree]) < 10
# Agent không phản hồi 5 phút (no data)
nodata(/web01/agent.ping,5m) = 1
# Quá 20 lần đăng nhập SSH thất bại trong 5 phút (item log đếm)
sum(/web01/log.ssh.failed,5m) > 20
# Trigger có recovery riêng (hysteresis tránh flapping):
# Problem: min(/web01/system.cpu.util,5m) > 90
# Recovery: max(/web01/system.cpu.util,5m) < 80
| Hàm | Ý nghĩa |
|---|---|
last() |
Giá trị mới nhất |
avg(,5m) |
Trung bình trong 5 phút |
min()/max() |
Cực tiểu/cực đại trong khoảng |
count(,5m,"gt",90) |
Số lần thỏa điều kiện |
nodata(,5m) |
=1 nếu không có dữ liệu trong 5 phút |
change() |
Chênh lệch so với giá trị trước |
Severity của trigger: Not classified, Information, Warning, Average, High, Disaster — quyết định mức cảnh báo và màu sắc.
Vì sao cần hysteresis (recovery expression riêng)? Nếu chỉ dùng >90 để vào và <=90 để ra, CPU dao động quanh 90% sẽ tạo hàng loạt sự kiện problem/ok ("flapping"). Đặt ngưỡng vào (90) cao hơn ngưỡng ra (80) → cảnh báo ổn định.
9.5.6 Template, Host, Host group
- Host: thực thể được giám sát (server, switch, ứng dụng). Có interface (Agent/SNMP/IPMI/JMX).
- Host group: nhóm host để áp quyền và áp dụng hàng loạt.
- Template: tập hợp item + trigger + graph + macro tái sử dụng. Gắn template vào host → host thừa hưởng toàn bộ item/trigger. Ví dụ template
Linux by Zabbix agentcung cấp sẵn hàng chục item CPU/RAM/disk/net.
Macro giúp tham số hóa: trong trigger dùng {$CPU.UTIL.CRIT} thay vì hardcode 90; override macro ở cấp host cho ngoại lệ. Vì sao? Một template áp cho 500 host, nhưng vài host DB cần ngưỡng khác → chỉ cần override macro trên host đó.
9.5.7 Action, Operation, Media
Khi trigger sinh event, Action quyết định phản ứng:
Event (trigger PROBLEM)
│
▼ Action conditions (vd: severity >= High AND host group = Production)
│
▼ Operations
- gửi message qua Media (Email/Telegram/Slack/Webhook)
- chạy remote command (vd restart service) — cẩn trọng
│
▼ Recovery operations (gửi thông báo đã OK)
│
▼ Escalation (lặp lại/leo thang nếu chưa ai xử lý sau X phút)
- Media type: kênh gửi (Email SMTP, Telegram bot, Slack, custom webhook script).
- User media: gán kênh cho user kèm lịch trực và severity quan tâm.
Ví dụ webhook media (JavaScript) gửi cảnh báo — Zabbix media type webhook nhận macro qua tham số, ví dụ {ALERT.MESSAGE}, {EVENT.SEVERITY}:
var params = JSON.parse(value);
var req = new HttpRequest();
req.addHeader('Content-Type: application/json');
var resp = req.post('https://hooks.example/alert',
JSON.stringify({ text: params.message, severity: params.severity }));
return resp;
Lưu ý bảo mật action: Remote command cho phép Zabbix chạy lệnh trên agent — nếu bật EnableRemoteCommands/AllowKey=system.run[*] bừa bãi, server bị chiếm = RCE toàn fleet. Mặc định nên tắt; nếu bật, giới hạn AllowKey/DenyKey và lệnh cụ thể.
9.5.8 Dashboard và Latest data
- Latest data: bảng giá trị item mới nhất theo host — dùng kiểm tra nhanh.
- Graph: vẽ chuỗi thời gian từ history/trends.
- Dashboard: widget (graph, problem list, map topology, gauge) — ví dụ NOC: bản đồ host (xanh=OK, đỏ=problem), top CPU, danh sách problem đang mở.
9.5.9 Lưu ý bảo mật Zabbix tổng thể
- TLS/PSK: mặc định kênh agent↔server không mã hóa. Bật
TLSConnect/TLSAcceptvới PSK hoặc cert. Không bật = ai sniff được sẽ thấy metric và có thể giả mạo trapper. - Item nguy hiểm:
system.run[...],vfs.file.contents[...]có thể đọc file nhạy cảm/chạy lệnh. DùngAllowKey/DenyKeytrong agent conf để whitelist.ini DenyKey=system.run[*] AllowKey=vfs.file.contents[/var/log/app/*] - Frontend PHP: từng có nhiều CVE SQLi/XSS. Đặt sau reverse proxy, HTTPS, hạn chế IP truy cập, cập nhật bản vá kịp thời.
- DB credential trong
zabbix_server.conf— phân quyền file 600, user DB tối thiểu quyền.
9.6 Prometheus — Giám sát metric theo mô hình pull
9.6.1 Prometheus là gì, giải quyết vấn đề gì
Prometheus là hệ giám sát metric mã nguồn mở, khởi nguồn từ SoundCloud và là dự án thứ hai "tốt nghiệp" CNCF (sau Kubernetes). Nó giải quyết cùng bài toán với Zabbix — "hệ thống có khỏe không, chỉ số nào vượt ngưỡng" — nhưng theo triết lý khác:
- Pull model: Prometheus server chủ động kéo (scrape) metric từ endpoint HTTP
/metricscủa từng target, theo chu kỳ cấu hình. Target không đẩy gì cả — nó chỉ phơi trạng thái hiện tại ra dạng text. - Metric là time-series có label: mỗi chuỗi được định danh bằng tên metric + tập cặp key=value (label). Cắt lát dữ liệu theo label là thao tác hạng nhất, không cần khai báo trước từng "item" như Zabbix.
- Cấu hình bằng file (YAML + rule file): toàn bộ scrape config và alert rule nằm trong file text → đưa vào Git, review như code (khớp tinh thần GitOps ở chương 7). Zabbix ngược lại: cấu hình sống trong DB, thao tác qua GUI.
- TSDB cục bộ: Prometheus tự lưu dữ liệu trên đĩa của chính nó (block 2 giờ + WAL), không cần DB ngoài. Mặc định giữ 15 ngày (
--storage.tsdb.retention.time) — lưu dài hạn cần giải pháp bổ sung (Thanos, Mimir, VictoriaMetrics — cần kiểm chứng lựa chọn theo thời điểm).
Vì sao pull thay vì push? Ba lý do thực dụng: (1) server kiểm soát nhịp thu — thêm 100 target không sợ server bị dội dữ liệu ngoài ý muốn; (2) biết ngay target chết — scrape thất bại thì metric up == 0, có ngay alert "host mất tích" mà không cần cơ chế heartbeat riêng (tương đương nodata() của Zabbix nhưng tự nhiên hơn); (3) debug dễ — mở trình duyệt vào http://target:9100/metrics là thấy đúng thứ server thấy. Nhược điểm: server phải tới được target (mạng NAT/firewall chiều vào là bài toán); job ngắn hạn (batch/cron) chạy xong đã chết trước khi bị scrape — giải bằng Pushgateway (job đẩy kết quả lên đó, Prometheus scrape Pushgateway).
9.6.2 Kiến trúc: server, exporter, scrape
┌─────────────────────────────────────────────────────────┐
│ PROMETHEUS SERVER │
│ Retrieval (scrape) ──▶ TSDB (block + WAL trên đĩa) │
│ │ │ │
│ │ HTTP API /api/v1/query ◀── Grafana│
│ ▼ │ │
│ Rule evaluation ───────────┼──▶ alert đang FIRING │
└────────┬────────────────────┼────────────┬───────────────┘
│ HTTP GET /metrics │ │ HTTP POST
▼ (chu kỳ 15-60s) │ ▼
┌────────────────┐ ┌────────────────┐ ┌──────────────┐
│ node_exporter │ │ app tự phơi │ │ ALERTMANAGER │
│ :9100 (host) │ │ /metrics (SDK) │ │ group/route/ │
└────────────────┘ └────────────────┘ │ notify │
└──────────────┘
| Thành phần | Cổng mặc định | Vai trò |
|---|---|---|
| Prometheus server | 9090 | Scrape, lưu TSDB, chạy PromQL, đánh giá alerting rule |
| node_exporter | 9100 | Phơi metric hệ điều hành Linux của host |
| Alertmanager | 9093 | Nhận alert từ server, gom nhóm, định tuyến, gửi thông báo |
| Pushgateway | 9091 | Trung gian cho batch job ngắn hạn |
| Exporter khác | 9xxx | blackbox (probe HTTP/ICMP), mysqld, nginx, redis... mỗi dịch vụ một exporter |
Exporter là điểm khác biệt kiến trúc so với Zabbix agent: thay vì một agent vạn năng trả lời mọi item key, hệ Prometheus dùng nhiều exporter nhỏ — mỗi exporter dịch trạng thái của một hệ (kernel, MySQL, nginx) thành định dạng text /metrics. Ứng dụng tự viết thì nhúng client library (Go/Python/Java...) để phơi metric nghiệp vụ trực tiếp.
prometheus.yml tối thiểu chạy được:
global:
scrape_interval: 15s # chu kỳ kéo mặc định
evaluation_interval: 15s # chu kỳ đánh giá rule
rule_files:
- "rules/*.yml"
alerting:
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
scrape_configs:
- job_name: "prometheus" # tự giám sát chính nó
static_configs:
- targets: ["localhost:9090"]
- job_name: "node"
static_configs:
- targets:
- "10.0.0.20:9100"
- "10.0.0.21:9100"
labels:
env: "prod"
Mỗi target scrape được tự gắn hai label định danh: job (tên job) và instance (host:port). Môi trường động (Kubernetes, cloud) thay static_configs bằng service discovery (kubernetes_sd_configs, ec2_sd_configs...) — target sinh/chết tự được phát hiện, không ai phải "khai host" thủ công.
Định dạng text mà exporter phơi ra (mở curl http://10.0.0.20:9100/metrics là thấy):
# HELP node_cpu_seconds_total Seconds the CPUs spent in each mode.
# TYPE node_cpu_seconds_total counter
node_cpu_seconds_total{cpu="0",mode="idle"} 8.5230889e+06
node_cpu_seconds_total{cpu="0",mode="iowait"} 12043.02
node_cpu_seconds_total{cpu="0",mode="steal"} 3894.77
node_cpu_seconds_total{cpu="0",mode="user"} 118920.31
node_memory_MemAvailable_bytes 6.442450944e+09
9.6.3 Data model — tên metric, label và 4 kiểu metric
Một time-series = tên metric + tập label; mỗi mẫu (sample) = (timestamp mili-giây, giá trị float64). Ví dụ node_cpu_seconds_total{cpu="0",mode="idle",instance="10.0.0.20:9100",job="node"} là một chuỗi; đổi bất kỳ label nào là một chuỗi khác. Hệ quả quan trọng: label cardinality quyết định chi phí — label nhận giá trị không chặn (user ID, URL đầy đủ, IP client) sẽ sinh hàng triệu chuỗi và giết server (tương tự mapping explosion bên Elasticsearch, xem 9.1.2).
Bốn kiểu metric:
| Kiểu | Bản chất | Ví dụ | Cách dùng đúng |
|---|---|---|---|
| Counter | Chỉ tăng (reset về 0 khi process restart) | node_cpu_seconds_total, http_requests_total |
Không đọc giá trị thô; luôn qua rate()/increase() |
| Gauge | Lên xuống tự do | node_memory_MemAvailable_bytes, node_load1 |
Đọc trực tiếp, avg_over_time() |
| Histogram | Đếm quan sát vào các bucket le (kèm _sum, _count) |
http_request_duration_seconds_bucket |
histogram_quantile(0.95, ...) tính percentile phía server |
| Summary | Percentile tính sẵn phía client | ..._{quantile="0.99"} |
Đọc trực tiếp nhưng không aggregate được giữa các instance |
Vì sao counter chỉ tăng? Để chịu được mất mẫu: giữa hai lần scrape, dù mất vài mẫu, hiệu số hai giá trị counter vẫn cho biết chính xác lượng tăng trong khoảng đó. rate() còn tự xử lý counter reset (giá trị tụt xuống = process restart → nó cộng bù). Đây là lý do quy ước đặt tên _total và câu thần chú "counter thô vô nghĩa, rate của counter mới có nghĩa" — tương đương preprocessing "Change per second" bên Zabbix (9.5.3).
Histogram vs Summary: histogram tính percentile lúc truy vấn nên aggregate được toàn fleet ("p95 của cả cụm"); summary tính sẵn ở client nên chính xác hơn cho một instance nhưng không gộp được. Thực tế đa số chọn histogram.
9.6.4 node_exporter — các metric hệ thống đáng nhìn nhất
node_exporter đọc /proc, /sys và phơi ra vài nghìn chuỗi. Những nhóm mình dùng hằng ngày:
| Nhóm | Metric | Kiểu | Ý nghĩa vận hành |
|---|---|---|---|
| CPU | node_cpu_seconds_total{mode=...} |
counter | Giây CPU theo mode: user, system, iowait, steal, idle... Đọc theo mode mới chẩn đoán được: iowait cao = nghẽn đĩa chứ không phải thiếu CPU; steal cao = hypervisor bóp VM (noisy neighbor trên cloud) |
| Memory | node_memory_MemAvailable_bytes, node_memory_MemTotal_bytes |
gauge | Nhìn available, đừng nhìn "used" — Linux tận dụng RAM trống làm page cache nên used luôn cao |
| PSI | node_pressure_memory_waiting_seconds_total, node_pressure_cpu_waiting_seconds_total, node_pressure_io_waiting_seconds_total |
counter | Pressure Stall Information (kernel ≥ 4.20): tổng thời gian có process phải dừng chờ tài nguyên. rate() của nó = tỉ lệ thời gian bị nghẽn — tín hiệu "thiếu thật" trung thực hơn mọi con số % used |
| Filesystem | node_filesystem_avail_bytes, node_filesystem_files_free |
gauge | Dung lượng còn trống và inode còn trống (hết inode cũng "đầy đĩa" dù còn GB) |
| Disk I/O | node_disk_io_time_seconds_total, node_disk_read_bytes_total, node_disk_written_bytes_total |
counter | rate(io_time) ≈ %util của iostat; throughput đọc/ghi |
| Network | node_network_receive_bytes_total, node_network_receive_drop_total, node_network_receive_errs_total |
counter | Cảnh báo mạng nên nhìn drop/error, không phải % băng thông |
| Load/Procs | node_load1, node_procs_blocked |
gauge | procs_blocked = số process trạng thái D (kẹt I/O) — load cao + blocked cao = nghẽn đĩa, không phải thiếu CPU |
Cách đọc mấy chỉ số này bằng lệnh ngay trên máy (mpstat, PSI, iostat, ss...) nằm ở chương 2 (Linux) — dashboard và lệnh là hai nửa của cùng một runbook.
9.6.5 PromQL cơ bản — các truy vấn dùng thật
PromQL thao tác trên hai loại giá trị chính: instant vector (mỗi chuỗi một giá trị tại một thời điểm — node_load1) và range vector (mỗi chuỗi một dãy mẫu trong cửa sổ thời gian — node_cpu_seconds_total[5m]). Hàm như rate() nhận range vector, trả instant vector.
# 1. CPU usage % từng máy (mẹo kinh điển: 100% trừ đi phần idle)
100 * (1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])))
# 2. CPU tách theo mode — panel "CPU by mode" chính là truy vấn này
sum by (mode) (rate(node_cpu_seconds_total{instance="10.0.0.20:9100"}[5m]))
# 3. % RAM available (nhìn available, không nhìn used)
100 * node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes
# 4. Áp lực RAM thật (PSI) — tỉ lệ thời gian có process phải chờ vì thiếu RAM
rate(node_pressure_memory_waiting_seconds_total[5m])
# 5. % đĩa còn trống của mount /
100 * node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}
# 6. Dự báo: với đà 6 giờ qua, 24 giờ nữa / có đầy không? (âm = sẽ đầy)
predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 24*3600) < 0
# 7. Packet drop trên mọi interface (bỏ loopback)
rate(node_network_receive_drop_total{device!="lo"}[5m])
# 8. Máy nào mất tích (scrape thất bại)
up == 0
Mấy điểm hay vấp:
rate()cần cửa sổ ≥ 2 lầnscrape_interval(thường quy ước ≥ 4×, ví dụ interval 15s → cửa sổ tối thiểu 1m) — cửa sổ quá ngắn sẽ ra đồ thị lỗ chỗ.sum(node_cpu_seconds_total)không córate()là vô nghĩa (cộng các counter tích lũy từ lúc boot).by (label)giữ label để nhóm,without (label)bỏ label —sum by (mode)gộp mọi CPU/instance nhưng giữ mode.predict_linearlà hồi quy tuyến tính trên range vector — câu "đĩa sẽ đầy trong 24h" đáng giá hơn nhiều câu "đĩa đang trên 90%": cảnh báo khi còn thời gian xử lý, và không kêu oan máy có đĩa 91% nhưng ổn định.
9.6.6 Alerting rule và Alertmanager
Prometheus tách đôi việc cảnh báo: server đánh giá rule và bắn alert; Alertmanager nhận alert rồi gom nhóm, khử trùng lặp, định tuyến, gửi đi. Vì sao tách? Để nhiều Prometheus server dùng chung một chỗ quản lý thông báo, và để logic "gửi cho ai, khi nào, gom thế nào" không trộn vào logic "cái gì là bất thường".
Rule file (rules/node.yml):
groups:
- name: node-health
rules:
- alert: HostDiskWillFillIn24h
expr: predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 24*3600) < 0
for: 30m
labels:
severity: warning
annotations:
summary: "Đĩa / của {{ $labels.instance }} sẽ đầy trong ~24h"
- alert: HostHighCpu
expr: 100 * (1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m]))) > 90
for: 15m
labels:
severity: warning
annotations:
summary: "CPU {{ $labels.instance }} > 90% suốt 15 phút"
- alert: HostDown
expr: up{job="node"} == 0
for: 3m
labels:
severity: critical
annotations:
summary: "{{ $labels.instance }} không scrape được 3 phút"
Trường for là chốt chống nhiễu: biểu thức phải đúng liên tục trong khoảng đó thì alert mới chuyển từ pending sang firing — vai trò tương tự hysteresis của Zabbix trigger (9.5.5): một cú spike CPU 30 giây không đáng đánh thức ai.
alertmanager.yml — route dạng cây, receiver là đích gửi:
route:
receiver: ops-default # nhánh mặc định
group_by: [alertname, instance] # gom alert cùng loại thành 1 thông báo
group_wait: 30s # đợi gom thêm alert cùng nhóm
repeat_interval: 4h # nhắc lại nếu chưa ai xử lý
routes:
- matchers: [ severity="critical" ]
receiver: ops-oncall # critical đi đường riêng
receivers:
- name: ops-default
webhook_configs:
- url: "https://hooks.example/ops-channel"
- name: ops-oncall
webhook_configs:
- url: "https://hooks.example/oncall"
Ngoài route, Alertmanager có silence (tắt tiếng theo matcher trong thời gian bảo trì, tạo qua UI/API) và inhibition (alert to đè alert nhỏ: host đã HostDown thì đừng gửi thêm 15 alert CPU/RAM của chính host đó).
Lưu ý bảo mật Prometheus:
/metricslộ nhiều thông tin trinh sát (phiên bản kernel, mount point, tên interface, đôi khi cả path ứng dụng). node_exporter không có xác thực — chỉ bind mạng nội bộ, chặn firewall cổng 9100/9090/9093 khỏi Internet.- Bản thân Prometheus/Alertmanager hỗ trợ TLS + basic auth cho web/API (từ Prometheus 2.24 — cần kiểm chứng); trước đó và cả nay nhiều nơi vẫn đặt sau reverse proxy để kiểm soát truy cập.
- UI Prometheus cho chạy PromQL tùy ý trên toàn bộ dữ liệu — coi nó như công cụ quản trị, không phơi công khai.
9.6.7 Prometheus vs Zabbix — khi nào chọn gì
| Tiêu chí | Zabbix | Prometheus |
|---|---|---|
| Mô hình thu thập | Push (active) / poll từng item (passive), SNMP/IPMI | Pull scrape endpoint HTTP |
| Cấu hình | GUI + template, lưu trong DB | File YAML + rule file → Git/review được |
| Data model | Item key phẳng theo host | Metric + label đa chiều, cắt lát tùy ý |
| Ngôn ngữ truy vấn | Hàm trigger trên từng item | PromQL — tính toán trên cả fleet trong một biểu thức |
| Target động (container, autoscaling) | Yếu hơn (đăng ký host, LLD) | Rất mạnh (service discovery) |
| Thiết bị mạng (SNMP), server vật lý | Rất mạnh, có sẵn template | Phải qua snmp_exporter, cực hơn |
| Lưu dài hạn | History/Trends trong DB, giữ nhiều năm | TSDB cục bộ ~tuần; dài hạn cần Thanos/Mimir/remote write |
| Alert | Trigger + action + escalation trong một hệ | Rule (server) + Alertmanager (route) tách đôi |
Kinh nghiệm chọn: hạ tầng tĩnh, nhiều thiết bị mạng, cần SNMP và escalation trực ca bài bản → Zabbix vẫn rất tốt. Hạ tầng cloud/container, target sinh diệt liên tục, đội đã quen IaC → Prometheus + Grafana là mặc định của hệ sinh thái (Kubernetes phơi metric sẵn theo định dạng Prometheus). Bản thân mình dùng Prometheus + node_exporter + Grafana cho metric máy chủ; Zabbix thì học để hiểu mô hình monitoring truyền thống, và vì nhiều doanh nghiệp Việt Nam vẫn chạy nó.
9.7 Grafana — Một mặt kính cho nhiều nguồn dữ liệu
9.7.1 Grafana là gì, giải quyết vấn đề gì
Grafana là nền tảng dashboard mã nguồn mở. Nó không lưu metric hay log nào cả — mỗi lần vẽ, nó cầm câu truy vấn đi hỏi datasource (Prometheus, Elasticsearch/OpenSearch, Loki, MySQL, CloudWatch...) rồi hiển thị kết quả. Vấn đề nó giải quyết rất đời thường: mỗi công cụ có UI riêng (Prometheus có UI thô, Kibana chỉ nhìn được Elasticsearch, Zabbix có frontend riêng) — vận hành thật thì không ai muốn mở 4 tab để trả lời câu "hệ thống có ổn không". Grafana cho phép một dashboard trộn panel từ nhiều nguồn: panel CPU đọc từ Prometheus nằm ngay cạnh panel đếm alert đọc từ index Wazuh.
Với người làm bảo mật, đây là điểm ăn tiền: dashboard an ninh và dashboard hiệu năng cùng một chỗ. Wazuh indexer bản chất là OpenSearch (xem 9.9), nên Grafana trỏ datasource vào đó là truy vấn được wazuh-alerts-* như dữ liệu thường — mình dựng dashboard SOC trên Grafana mà không đụng tới Wazuh dashboard.
9.7.2 Datasource
| Datasource | Kiểu dữ liệu | Ngôn ngữ truy vấn trong panel |
|---|---|---|
| Prometheus | Metric time-series | PromQL |
| Elasticsearch / OpenSearch | Document/log | Lucene query string + aggregation cấu hình trong panel |
| Loki | Log (label như Prometheus) | LogQL |
| MySQL/PostgreSQL | Bảng quan hệ | SQL |
| CloudWatch, Azure Monitor... | Metric cloud | Query builder riêng |
Mỗi datasource khai báo URL + credential (lưu phía server Grafana, mã hóa trong DB của nó). Nguyên tắc quyền tối thiểu áp dụng y như 9.1.5: user mà Grafana dùng để đọc Elasticsearch/OpenSearch chỉ cần quyền read trên đúng index cần vẽ (wazuh-alerts-*, logs-*), tuyệt đối không cấp admin.
9.7.3 Dashboard, Panel, Variable
- Panel: một ô hiển thị = một (hoặc vài) truy vấn + một kiểu vẽ (time series, gauge, stat, table, bar chart, heatmap). Ngưỡng màu (xanh/vàng/đỏ) đặt ngay trong panel — "panel chuyển đỏ" là ngôn ngữ chung của cả team.
- Dashboard: lưới các panel, chia sẻ chung time range và bộ lọc. Toàn bộ dashboard là một file JSON — export/import được, commit vào Git được (lại GitOps).
- Variable: biến dropdown ở đầu dashboard, ví dụ
instancelấy giá trị động bằnglabel_values(node_uname_info, instance)— mọi panel dùng$instancetrong truy vấn. Nhờ đó một dashboard phục vụ cả fleet thay vì mỗi máy một bản sao. - Dashboard cộng đồng: grafana.com có kho dashboard đánh số ID để import thẳng (ví dụ "Node Exporter Full" cho node_exporter). Kinh nghiệm của mình: import để học cách người ta viết truy vấn, rồi tự dựng bản gọn hơn — dashboard 40 panel nhìn mọi thứ nhưng không nói được điều gì; dashboard tự dựng 7-8 panel đúng thứ mình cần lại dùng được hằng ngày.
9.7.4 Grafana alerting vs Alertmanager
Grafana có hệ alerting riêng (unified alerting, từ Grafana 8): định nghĩa rule trên bất kỳ datasource nào, đánh giá theo chu kỳ, gửi qua contact point (email/webhook/chat), có thể trỏ tới Alertmanager ngoài. Vậy trùng với Alertmanager của Prometheus? Cách mình phân vai:
| Prometheus rule + Alertmanager | Grafana alerting | |
|---|---|---|
| Rule sống ở đâu | File YAML cạnh server, review qua Git | DB của Grafana, tạo qua UI |
| Gần dữ liệu | Sát TSDB, không thêm hop nào | Thêm một lớp (Grafana query datasource) |
| Đa datasource | Chỉ Prometheus | Bất kỳ datasource nào (kể cả Elasticsearch) |
| Hợp với | Alert hạ tầng chuẩn hóa, fleet lớn | Alert cần trộn nguồn, hoặc team muốn thao tác UI |
Nguyên tắc mình theo: alert hạ tầng cốt lõi đặt ở tầng thấp nhất có thể (Prometheus rule + Alertmanager) — nó vẫn chạy kể cả khi Grafana chết; Grafana alerting dùng cho những cảnh báo tiện-thì-làm trên dữ liệu mà Alertmanager không với tới (ví dụ ngưỡng đếm trên index log).
9.7.5 Kinh nghiệm thực tế: hai dashboard mình dùng hằng ngày
Dashboard "SOC Analyst View" — tự dựng, datasource là index wazuh-alerts-* trên Wazuh indexer (OpenSearch):
| Panel | Truy vấn/aggregation | Trả lời câu gì |
|---|---|---|
| Alerts theo thời gian, tách severity | date histogram + terms theo rule.level |
Hôm nay có "bão" alert không, bắt đầu từ lúc nào |
| Top rule kích hoạt | terms theo rule.id / rule.description |
Loại sự kiện nào đang trội |
| Top source IPs | terms theo data.srcip |
IP nào đang "chăm" hệ thống nhất |
| Top agent bị nhắm | terms theo agent.name |
Máy nào đang hứng nhiều nhất |
| Bảng alert level cao mới nhất | filter rule.level >= 10, sort theo thời gian |
Cái gì cần mở Wazuh xem ngay |
Panel Top source IPs là panel có "công" nhất: một lần liếc buổi sáng, mình thấy một IP lạ vọt lên đầu bảng với hàng trăm alert trong vài giờ — kéo ra là một VPS nước ngoài đang quét path traversal có bài bản trên một máy dev. Không có panel đó, từng alert riêng lẻ mức trung bình sẽ trôi qua như nhiễu nền; gom theo IP thì chiến dịch mới hiện hình. Phần điều tra sâu (query aggregation trực tiếp trên index, đọc full_log) nằm ở chương 8 (Wazuh); phản ứng phía nginx (rate limit, chặn tên tệp nhạy cảm) ở chương 11.
Dashboard node metrics — mỗi hàng một nhóm tài nguyên, biến $instance chọn máy:
- CPU by mode (truy vấn số 2 ở 9.6.5): nhìn màu là biết bệnh —
iowaitphình = đĩa,stealphình = hypervisor,userphình = app. - Memory available + Memory PSI: hai panel cạnh nhau, và PSI mới là panel quyết định (xem Ghi chú cuối chương).
- Root FS + kết quả
predict_linear: còn bao nhiêu, bao lâu nữa đầy. - Disk I/O (
rate(node_disk_io_time_seconds_total)) + I/O PSI. - Network (throughput + drop/error).
- Procs blocked (
node_procs_blocked): chỉ số bị bỏ quên nhưng cực nhạy với nghẽn I/O.
Triết lý dùng: dashboard cho biết cao ở đâu, SSH + lệnh cho biết con nào gây ra. Panel chỉ trả lời "CPU cao", "PSI memory dương kéo dài" — còn process nào thì phải vào máy chạy htop, mpstat -P ALL, ps aux --sort=-%mem, đọc /proc/pressure/*... Bộ lệnh theo từng panel mình để ở chương 2 (Linux) dạng runbook "panel đỏ → chạy gì trước". Hai thứ này là một cặp: dashboard không có runbook thì chỉ để ngắm, runbook không có dashboard thì không biết bắt đầu từ đâu.
Lưu ý bảo mật Grafana:
- Đổi ngay tài khoản mặc định
admin/admin; bật HTTPS; đặt sau reverse proxy nếu phơi ra ngoài. - Grafana từng có CVE nghiêm trọng bị khai thác thực tế (điển hình path traversal CVE-2021-43798 đọc file tùy ý qua URL plugin) — cập nhật như một ứng dụng web công khai thực thụ.
- Phân quyền theo org/team/folder: viewer chỉ xem dashboard, không sửa datasource. Nhớ rằng ai sửa được panel là chạy được truy vấn tùy ý bằng credential datasource của server — quyền editor cũng là một dạng quyền đọc dữ liệu.
- Không nhúng secret vào truy vấn/annotation; dashboard JSON hay được chia sẻ công khai, soát trước khi đăng (tên host nội bộ, IP, tên index).
9.8 Zabbix/Prometheus vs SIEM — Phân định bản chất
Đây là điểm hay bị nhầm. Monitoring (Zabbix, Prometheus) giám sát "trạng thái/hiệu năng hạ tầng", SIEM phân tích "sự kiện bảo mật". Khác nhau ở mô hình dữ liệu và động cơ phát hiện.
| Tiêu chí | Monitoring (Zabbix / Prometheus) | SIEM (Wazuh / Elastic Security / Splunk ES) |
|---|---|---|
| Dữ liệu | Metric số theo thời gian (CPU, RAM, disk, up/down) | Log/event đa nguồn đã chuẩn hóa |
| Phát hiện | Trigger/alerting rule ngưỡng trên metric | Decoder + rule + correlation theo hành vi |
| Câu hỏi | "Hệ thống có khỏe không?" | "Có ai đang tấn công không?" |
| Tương quan đa nguồn | Hạn chế (chủ yếu theo host/metric) | Mạnh (correlation across logs, MITRE ATT&CK) |
| Lưu trữ | RDBMS/TSDB, history+trends | Index full-text (Elasticsearch/Lucene) |
| Lý tưởng cho | Ops/SRE, availability, capacity | SOC, threat detection, IR, compliance |
Một sự cố "đĩa đầy" là việc của Zabbix/Prometheus. Một chuỗi "1000 lần đăng nhập thất bại rồi 1 lần thành công từ IP lạ" là việc của SIEM. Hai miền này bổ sung chứ không thay thế nhau. Dù vậy, metric vẫn có giá trị an ninh gián tiếp: CPU vọt bất thường lúc 3 giờ sáng (cryptominer), network egress tăng đột biến (exfiltration) — dashboard hiệu năng đôi khi là người báo tin đầu tiên, còn xác nhận thì phải quay về log/SIEM.
9.9 Khi nào dùng ELK vs Wazuh vs Zabbix vs Prometheus/Grafana
| Nhu cầu | Công cụ phù hợp | Lý do |
|---|---|---|
| Tập trung, tìm kiếm full-text log khối lượng lớn, tự xây dashboard điều tra | ELK | Search engine mạnh, linh hoạt mapping/query DSL |
| Threat detection sẵn sàng dùng: HIDS, FIM, rootcheck, rule MITRE, compliance (PCI, CIS), agent đa nền tảng | Wazuh | Là SIEM/XDR mã nguồn mở, có decoder+rule sẵn, thường dựng trên chính Elasticsearch/OpenSearch để lưu/hiển thị |
| Giám sát hạ tầng truyền thống, thiết bị mạng SNMP/IPMI, quy trình trực ca escalation bài bản | Zabbix | Trigger engine + template + proxy phân tán, agentless SNMP/IPMI |
| Giám sát metric hạ tầng cloud/container, target động, alert as code | Prometheus | Pull model + service discovery + PromQL + Alertmanager; cấu hình dạng file hợp GitOps |
| Một dashboard chung cho cả metric lẫn log/an ninh, nhiều nguồn dữ liệu | Grafana | Datasource đa hệ (Prometheus, Elasticsearch/OpenSearch...), panel/variable/alerting hợp nhất một mặt kính |
Mối quan hệ kỹ thuật cần nắm: - Wazuh dùng nền lưu trữ/hiển thị của Elastic Stack/OpenSearch (Wazuh indexer dựa trên OpenSearch ~ Elasticsearch; Wazuh dashboard ~ Kibana). Tức ELK là nền hạ tầng dữ liệu, Wazuh thêm lớp phát hiện bảo mật (decoder, rule, agent, FIM) lên trên. - ELK thuần cũng có thể làm SIEM nếu dùng Elastic Security (detection rules, ECS schema), nhưng phải tự dựng/áp rule; Wazuh cho sẵn nhiều hơn out-of-the-box. - Zabbix và Prometheus cùng đứng ở miền monitoring; không cạnh tranh với ELK/Wazuh mà chạy song song. Giữa hai công cụ này, chọn theo bài toán (xem 9.6.7) — hiếm khi cần cả hai. - Grafana đọc được cả hai miền: trỏ datasource vào Prometheus là có dashboard hiệu năng, trỏ vào Wazuh indexer (OpenSearch) là có dashboard an ninh — cùng một chỗ. Kibana/Wazuh dashboard vẫn mạnh hơn cho điều tra tương tác (Discover, Dev Tools); Grafana thắng ở màn hình theo dõi hằng ngày.
Kiến trúc tham chiếu trong một tổ chức:
Hạ tầng/hiệu năng ──▶ node_exporter ──▶ Prometheus ──▶ Alertmanager (alert ops)
│
Log & security event ──▶ agent ──▶ Wazuh ──┼──▶ Wazuh indexer (OpenSearch)
│ │
└────────────┴──▶ GRAFANA (một mặt kính:
dashboard node metrics
+ dashboard SOC)
Biến thể truyền thống: Zabbix thay Prometheus; Beats/Logstash ──▶ Elasticsearch ──▶ Kibana
9.10 Tóm tắt các quyết định thiết kế cốt lõi
| Quyết định | Vì sao |
|---|---|
| Inverted index + BM25 | Tìm full-text nhanh trên hàng tỷ doc; điểm số bão hòa chống nhồi từ |
| Tách doc values khỏi inverted index | Sort/aggregate cần truy cập columnar theo docId |
| Số primary shard cố định | Routing dùng % n_shards; đổi = reindex toàn bộ |
| Translog | Khôi phục segment chưa fsync sau crash |
dynamic: strict mapping |
Chống mapping explosion (DoS heap) |
| Filebeat at-least-once + registry inode | Không mất log qua logrotate/crash |
| Zabbix History vs Trends | History phình nhanh → giữ ngắn; Trends gộp giờ → giữ lâu |
Trigger hysteresis / for trong alerting rule |
Chống flapping cảnh báo; spike ngắn không đáng đánh thức ai |
| Magic "ZBXD" + length header | Tách message trên TCP stream |
| Prometheus pull model | Server kiểm soát nhịp thu; up == 0 phát hiện target chết miễn phí; debug bằng trình duyệt |
Counter chỉ tăng + rate() |
Chịu mất mẫu, tự xử lý reset; giá trị thô vô nghĩa, tốc độ thay đổi mới có nghĩa |
| Metric name + label thay item key phẳng | Cắt lát đa chiều (sum by (mode)) — nhưng phải kiểm soát cardinality |
| Tách Alertmanager khỏi Prometheus server | "Cái gì bất thường" (rule) tách khỏi "báo cho ai, gom thế nào" (route) |
| Grafana không lưu dữ liệu | Mặt kính đa datasource: metric + log/an ninh cùng một dashboard |
| TLS/PSK Zabbix, xpack.security ES, bind nội bộ /metrics | Mặc định không mã hóa/không xác thực = lộ dữ liệu/giả mạo |
| Monitoring ≠ SIEM | Metric/threshold vs event/correlation — bổ sung nhau |
Ghi chú của mình
Khu vực ghi chú cá nhân: những điểm từng hiểu sai, phần còn đang tìm hiểu, hoặc kinh nghiệm rút ra khi thực hành — cập nhật dần.
- Học Zabbix, vận hành Prometheus — mình học Zabbix khá kỹ (trigger, template, protocol) nhưng khi vận hành thật thì lại là Prometheus + Grafana, và cảm nhận hai bên khác hẳn. Zabbix cho mình cảm giác "phần mềm quản trị": mọi thứ qua GUI, cấu hình sống trong DB, làm xong không nhớ mình đã bấm gì. Prometheus thì mọi thứ là file — sửa một alert rule là một commit, đọc lại diff là hiểu ai đổi gì vì sao. Học Zabbix không phí: nhờ nó mình hiểu trigger/hysteresis/escalation là gì trước khi gặp
for/route/inhibition, và nhiều công ty ở đây vẫn chạy Zabbix thật. - PSI đáng tin hơn %RAM used — bài học mình phải trả bằng vài lần hết hồn. Panel memory used đỏ rực, SSH vào thì máy chạy êm ru: RAM "used" cao vì Linux tận dụng page cache và app (nhất là mấy con chạy JVM/heap lớn) giữ chỗ sẵn. Từ ngày thêm panel PSI (
rate(node_pressure_memory_waiting_seconds_total[5m])), mình gần như không nhìn % used nữa: PSI bằng 0 thì kệ used bao nhiêu; PSI dương kéo dài mới là thiếu RAM thật — và lúc đóvmstatsẽ thấy si/so,dmesglảng vảng OOM. - %steal — chỉ số mình từng không biết tồn tại. Có lúc panel CPU của một máy lên cao mà
htopkhông thấy process nào ăn. Hóa ra làsteal: VM cloud bị hypervisor bóp (noisy neighbor). Từ đó panel CPU của mình luôn tách theo mode chứ không vẽ một đường "CPU %" duy nhất — cùng là "CPU cao" nhưnguser/iowait/steallà ba bệnh khác nhau, ba cách xử khác nhau (và riêng steal thì... không phải lỗi máy mình). - Panel Top source IPs là panel rẻ nhất mà được việc nhất. Chỉ là một terms aggregation theo IP nguồn trên index alert của Wazuh, nhưng chính nó giúp mình phát hiện sớm một chiến dịch quét path traversal: một IP lạ đứng đầu bảng với hàng trăm alert dồn trong vài giờ. Từng alert lẻ mức trung bình sẽ chìm trong nhiễu; gom theo IP thì hành vi có chủ đích nổi lên ngay. Bài học lớn hơn: dashboard an ninh không cần hoành tráng, cần đúng vài câu hỏi mình thật sự muốn hỏi mỗi sáng.
- Dashboard cho biết cao ở đâu, lệnh cho biết con nào gây ra. Hồi đầu mình cứ ngồi nhìn Grafana đợi nó "nói" thủ phạm — nó không nói được, vì node_exporter không phơi metric theo process. Sau mình viết hẳn một runbook: panel nào đỏ thì SSH vào chạy bộ lệnh nào trước (để ở chương 2). Từ lúc có cặp dashboard + runbook, thời gian từ "thấy đỏ" đến "biết tại sao" ngắn hơn hẳn.
- Đang tìm hiểu tiếp: recording rules (tính sẵn truy vấn nặng), lưu metric dài hạn (Thanos/Mimir — mới đọc, chưa dựng), và Loki để log nhẹ đi cạnh Prometheus xem có thay được một phần use case ELK cho hệ nhỏ không.