Log tập trung trên Kubernetes: Loki & Promtail
Tiếp đi: gom log trên Kubernetes. Pod payment chết là kubectl logs mất luôn, mà order có 20 Pod thì mình không thể mở từng cái.
Đúng hai nỗi đau kinh điển. Log phải sống lâu hơn Pod và tìm được trên mọi Pod cùng lúc. Phần "log thế nào cho tốt" (JSON, level, field chuẩn) đã ở 19.1; bài này lo phần hạ tầng: log đi từ container tới nơi lưu thế nào.
1. Bài toán
- payment-service OOMKilled lúc 3h sáng (alert từ 17.9). Sáng ra Pod mới đã thay,
kubectl logs --previouschỉ giữ được lần chết gần nhất, và nếu Pod bị xóa/đổi node thì mất hẳn. - order-service 20 replica: khách báo đơn
ORD-1042lỗi. Log nằm ở Pod nào? kubectl logs -l app=orderchỉ lấy vài Pod, vài dòng cuối, không lọc theo thời gian tốt.
2. Vấn đề: log container nằm ở đâu?
Spring Boot ghi ra stdout/stderr
│
▼ container runtime (containerd) bắt stream
/var/log/pods/<namespace>_<pod>_<uid>/<container>/0.log (trên node)
/var/log/containers/<pod>_<ns>_<container>-<id>.log → symlink tới file trên
Mỗi dòng theo CRI format:
2026-10-14T03:02:11.123456789Z stdout F {"level":"ERROR","message":"..."}
stream F = full line, P = partial (dòng dài bị cắt)
- kubelet rotate file theo
containerLogMaxSize(mặc định 10Mi) vàcontainerLogMaxFiles(mặc định 5). App log nhiều thì vài phút là file cũ bị xóa. - Pod bị xóa → thư mục log của nó bị dọn. Node chết → mất hết.
kubectl logschỉ là kubelet đọc file này giúp bạn. Không phải hệ thống log.
Vì vậy app chỉ ghi stdout (không ghi file trong container), và cần một agent trên mỗi node đọc file đó gửi đi.
3. Giải pháp: node-level agent (DaemonSet)
node worker-1 node worker-2
┌────────────────────────────────┐ ┌────────────────────────────────┐
│ order-1 order-2 payment-1 │ │ order-3 postgres-0 │
│ │ stdout │ │ │ │ │ │ │
│ ▼ ▼ ▼ │ │ ▼ ▼ │
│ /var/log/pods/... (hostPath) │ │ /var/log/pods/... │
│ ▲ │ │ ▲ │
│ Alloy/Promtail (DaemonSet Pod) │ │ Alloy/Promtail │
│ + gắn label từ API server: │ │ │
│ namespace, pod, app, node │ │ │
└───────┼────────────────────────┘ └───────┼────────────────────────┘
└──────────────┬─────────────────────┘
▼ push (HTTP)
Loki ── chunk + index ──► object storage (S3/GCS/Azure Blob)
▲
Grafana (LogQL)
Các cách khác: sidecar trong từng Pod (chỉ khi app bắt buộc ghi file), hoặc app gửi thẳng qua OTLP. DaemonSet là mặc định vì app không cần biết gì.
Promtail đang bị thay bằng Grafana Alloy. Grafana đã đưa Promtail vào chế độ chỉ bảo trì (LTS) và khuyên chuyển sang Alloy: một agent duy nhất gom cả log, metric, trace (OpenTelemetry). Cluster mới nên dùng Alloy; tên "Promtail" vẫn gặp nhiều trong tài liệu và cluster cũ, khái niệm giống nhau.
So sánh
| Lựa chọn | Index cái gì | Chi phí | Mạnh | Yếu |
|---|---|---|---|---|
| Loki + Alloy/Promtail | Chỉ label (namespace, app…); nội dung lưu nén trong object storage | Thấp | Rẻ, cùng Grafana với metric/trace, label giống Prometheus | Tìm full-text trên khoảng thời gian dài chậm hơn; label sai là chậm/nổ |
| EFK/ELK, OpenSearch | Full-text mọi field | Cao (RAM, disk) | Tìm kiếm, aggregation mạnh, Kibana | Vận hành cluster nặng. Chi tiết → bài 17.11 |
| Cloud (CloudWatch Logs, Azure Monitor / Log Analytics) | Tùy dịch vụ | Tính theo GB ingest + lưu, dễ tăng | Không phải vận hành, gắn IAM, EKS/AKS bật một nút | Khóa vào cloud, query language riêng, đắt khi log nhiều |
4. Code từng bước
Bước 1: Spring Boot ghi JSON ra stdout
Spring Boot 3.4+ có structured logging sẵn, không cần thêm dependency:
# application.yaml (order-service, payment-service)
logging:
structured:
format:
console: ecs # hoặc logstash / gelf
management:
tracing:
sampling:
probability: 0.1 # Micrometer Tracing đưa traceId/spanId vào MDC
{"@timestamp":"2026-10-14T03:02:11.123Z","log.level":"ERROR","service.name":"payment-service",
"message":"Charge failed for ORD-1042","traceId":"4bf92f3577b34da6a3ce929d0e0e4736",
"error.stack_trace":"java.net.SocketTimeoutException: Read timed out\n\tat ..."}
Một lợi ích lớn của JSON: stack trace Java nằm gọn trong một field của một dòng, không bị cắt thành 40 dòng log rời.
Bước 2: values cho Loki (Helm)
# loki-values.yaml (chart grafana/loki, chế độ SingleBinary cho cluster nhỏ)
deploymentMode: SingleBinary
loki:
auth_enabled: false
schemaConfig:
configs:
- from: "2025-01-01"
store: tsdb
object_store: s3
schema: v13
index: { prefix: loki_index_, period: 24h }
storage:
type: s3
bucketNames: { chunks: shop-loki-chunks, ruler: shop-loki-ruler }
s3:
region: ap-southeast-1 # credential qua IRSA / Pod Identity, không ghi key ở đây
limits_config:
retention_period: 14d
ingestion_rate_mb: 10
max_label_names_per_series: 15
compactor:
retention_enabled: true
delete_request_store: s3
singleBinary:
replicas: 1
persistence: { size: 20Gi }
Bước 3: cài Loki và Alloy
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update
helm upgrade --install loki grafana/loki -n monitoring -f loki-values.yaml --atomic
helm upgrade --install alloy grafana/alloy -n monitoring -f alloy-values.yaml --atomic
Config Alloy: tìm Pod, gắn label ít và ổn định, ghép stack trace nếu log là text, đẩy về Loki.
# alloy-values.yaml
alloy:
mounts:
varlog: true # mount /var/log của node
configMap:
content: |
discovery.kubernetes "pods" { role = "pod" }
discovery.relabel "pods" {
targets = discovery.kubernetes.pods.targets
rule { source_labels = ["__meta_kubernetes_namespace"] target_label = "namespace" }
rule { source_labels = ["__meta_kubernetes_pod_label_app"] target_label = "app" }
rule { source_labels = ["__meta_kubernetes_pod_name"] target_label = "pod" }
rule { source_labels = ["__meta_kubernetes_pod_container_name"] target_label = "container" }
rule { source_labels = ["__meta_kubernetes_pod_node_name"] target_label = "node" }
}
loki.source.kubernetes "pods" {
targets = discovery.relabel.pods.output
forward_to = [loki.process.java.receiver]
}
loki.process "java" {
// Chỉ cần khi app ghi text: dòng không bắt đầu bằng timestamp là dòng tiếp của stack trace
stage.multiline {
firstline = "^\\d{4}-\\d{2}-\\d{2}"
max_wait_time = "3s"
}
// Log JSON: lấy level làm label (ít giá trị), traceId để ở nội dung
stage.json { expressions = { level = "\"log.level\"" } }
stage.labels { values = { level = "" } }
forward_to = [loki.write.default.receiver]
}
loki.write "default" {
endpoint { url = "http://loki-gateway.monitoring.svc/loki/api/v1/push" }
}
Bước 4: thêm datasource Loki vào Grafana và nối sang trace
# trong monitoring-values.yaml (kube-prometheus-stack, bài 17.9)
grafana:
additionalDataSources:
- name: Loki
type: loki
url: http://loki-gateway.monitoring.svc
jsonData:
derivedFields:
- name: traceId
matcherType: regex
matcherRegex: '"traceId":"(\w+)"'
url: '${__value.raw}'
datasourceUid: tempo # click traceId trong log → mở trace ở Tempo
Bước 5: test
curl -s -X POST http://order.shop.local/api/orders -H 'Content-Type: application/json' \
-d '{"sku":"SKU-1","qty":1}'
kubectl -n monitoring port-forward svc/loki-gateway 3100:80
curl -sG http://localhost:3100/loki/api/v1/query_range \
--data-urlencode 'query={namespace="shop", app="order-service"} |= "ORD-1042"' \
--data-urlencode 'limit=20' | jq '.data.result[].values[][1]'
5. Cách hoạt động: label và LogQL
Loki giống Prometheus: mỗi tổ hợp label là một stream. Chỉ label được index; nội dung dòng chỉ được quét lúc query.
Label tốt (ít giá trị, ổn định): namespace, app, container, node, level
Label xấu (mỗi request một giá trị): traceId, orderId, userId, requestId
→ mỗi traceId tạo một stream mới → hàng triệu stream nhỏ → Loki chậm, tốn RAM, bị rate-limit
→ traceId để trong nội dung JSON, lọc bằng | json | traceId="..."
pod là ranh giới: số lượng vừa phải nhưng đổi mỗi lần deploy. Giữ được với cluster vừa; cluster rất lớn nhiều team chuyển nó thành structured metadata.
LogQL cơ bản
# 1. Chọn stream (bắt buộc, dùng label) rồi lọc text
{namespace="shop", app="payment-service"} |= "Charge failed"
# 2. Parse JSON, lọc theo field
{namespace="shop", app="payment-service"} | json | log_level="ERROR"
# 3. Tìm mọi log của một request trên mọi service
{namespace="shop"} | json | traceId="4bf92f3577b34da6a3ce929d0e0e4736"
# 4. Hiển thị gọn
{app="order-service"} | json | line_format "{{.log_level}} {{.message}}"
# 5. Metric từ log: số dòng ERROR mỗi phút theo app
sum by (app) (count_over_time({namespace="shop"} | json | log_level="ERROR" [1m]))
# 6. Log của Pod đã chết lúc 3h sáng (chọn time range trong Grafana)
{namespace="shop", pod=~"payment-service-.*"} |= "OutOfMemoryError"
Ghi chú: | json đổi dấu chấm trong key thành gạch dưới, nên log.level thành log_level.
Nối metric ↔ log ↔ trace trong Grafana
Alert (Prometheus): error rate payment > 2% ← 17.9 / 19.3
│ cùng label namespace/app, cùng khoảng thời gian
▼
Explore Loki: {namespace="shop", app="payment-service"} | json | log_level="ERROR"
│ dòng log có traceId → derived field
▼
Tempo: trace order → payment → postgres, thấy span postgres 4.8s
│ exemplar trên biểu đồ latency cũng nhảy thẳng vào trace
▼
Nguyên nhân: connection pool cạn
Chìa khóa là label đặt giống nhau giữa Prometheus và Loki (namespace, app) và traceId có trong mọi dòng log.
Retention và chi phí
- Loki lưu chunk nén trong object storage, rẻ hơn disk SSD của Elasticsearch nhiều lần. Retention do compactor xóa (
retention_period), có thể đặt riêng theo stream:shop30 ngày, namespace dev 3 ngày. - Chi phí thật nằm ở lượng log ingest. Giảm từ nguồn: không log DEBUG ở prod, không log payload lớn, drop log health check (
stage.dropvới/actuator/health). - Log audit/pháp lý cần giữ 1–7 năm: xuất sang bucket lifecycle rẻ (S3 Glacier), không giữ trong Loki "nóng".
6. So sánh: kubectl logs vs log tập trung
kubectl logs | Loki/ELK/Cloud | |
|---|---|---|
| Pod đã xóa | Mất | Còn tới hết retention |
| Nhiều Pod, nhiều service | Từng Pod hoặc vài Pod theo label | Một query cho cả namespace |
| Tìm theo traceId, thời gian | grep thủ công | Có, nối sang trace |
| Alert từ log | Không | Có (Loki ruler) |
| Khi nào dùng | Debug nhanh lúc đang xảy ra (17.7) | Điều tra sau sự cố, production |
7. Khi nào nên dùng / không nên dùng
- Nên Loki: đã có Grafana/Prometheus, log nhiều, muốn rẻ, chủ yếu tìm theo service + thời gian + traceId.
- Nên ELK/OpenSearch: cần full-text search và aggregation phức tạp trên log (security, phân tích nghiệp vụ). Xem 17.11.
- Nên cloud: team nhỏ trên EKS/AKS, log ít, không muốn vận hành gì.
- Không nên: ghi log ra file trong container rồi tự rotate; dùng traceId/userId làm label; gom log mà không đặt retention.
Log của mình là text thường, stack trace Java lên Loki bị tách mỗi dòng at com.shop... thành một entry riêng. Sửa ở đâu nhỉ?
Đúng chỗ đau. Runtime ghi mỗi dòng stdout thành một dòng log, nên stack trace 40 dòng thành 40 entry, lọc |= "Exception" chỉ thấy dòng đầu. Hai cách, ưu tiên cách 1:
- Sửa từ nguồn: log JSON (
logging.structured.format.console, Bước 1). Stack trace thành chuỗi có\ntrong fielderror.stack_trace, một entry duy nhất. Không phụ thuộc agent. - Ghép ở agent:
stage.multilinevớifirstlinelà regex dòng bắt đầu bằng timestamp. Dòng không khớp được nối vào entry trước. Nhược điểm: phải khớp đúng pattern log, chờmax_wait_time, và hai Pod khác format là vỡ.
2026-10-14 03:02:11 ERROR ... Charge failed ← firstline → entry mới
java.net.SocketTimeoutException: Read timed out ← nối vào
at java.base/sun.nio.ch... ← nối vào
Thêm một lưu ý: dòng rất dài (trên 16KB) runtime cắt thành nhiều dòng partial (P); agent đọc theo CRI format sẽ ghép lại, đừng tự đọc file bằng regex.
Vậy mình để orderId làm label cho dễ tìm đơn có được không? Query sẽ nhanh hơn mà.
Bạn hiểu gần đúng về tốc độ, nhưng tính sai giá phải trả. Label nhanh khi nó ít giá trị:
label app → 3 giá trị → 3 stream lớn, chunk đầy, nén tốt
label orderId → 1 triệu đơn/tháng → 1 triệu stream, mỗi stream vài dòng
→ index phình, chunk nhỏ xíu, ingester tốn RAM, Loki báo
"maximum active stream limit exceeded" và bỏ log
Cách đúng: thu hẹp bằng label, rồi lọc nội dung.
{namespace="shop", app="order-service"} |= "ORD-1042"
|= lọc text rất nhanh vì chỉ quét chunk của một app trong khoảng thời gian chọn. Nếu cần thường xuyên, Loki 3 có structured metadata và bloom filter cho field kiểu ID: gắn kèm dòng log mà không tạo stream mới. Quy tắc giống hệt cardinality ở Prometheus (19.3).
Phỏng vấn hỏi về log tập trung trên Kubernetes thì trả lời sao?
Q1. Log container trên Kubernetes đi đâu, vì sao cần log tập trung?
🇻🇳 Tiếng Việt:
App ghi ra stdout, container runtime lưu thành file trên node ở
/var/log/pods, kubelet rotate theo kích thước vàkubectl logschỉ đọc lại file đó. Khi Pod bị xóa hoặc node chết thì log mất, và với nhiều replica thì không thể xem từng Pod. Nên em chạy một agent dạng DaemonSet trên mỗi node, như Grafana Alloy hoặc Fluent Bit, đọc file log, gắn label namespace, app, pod và đẩy về Loki hoặc Elasticsearch, giữ theo retention.
🇬🇧 English:
The app writes to stdout, the container runtime stores it as files on the node under
/var/log/pods, the kubelet rotates them by size, andkubectl logsjust reads those files. When a pod is deleted or a node dies the logs are gone, and with many replicas you can't inspect pods one by one. So I run a DaemonSet agent on every node, such as Grafana Alloy or Fluent Bit, which tails the files, attaches namespace, app and pod labels and ships them to Loki or Elasticsearch with a retention policy.
Q2. Loki khác Elasticsearch thế nào, bạn chọn khi nào?
🇻🇳 Tiếng Việt:
Loki chỉ index label, nội dung log được nén và lưu trong object storage, nên rẻ và đơn giản, query bằng cách chọn stream theo label rồi lọc text. Elasticsearch index full-text mọi field nên tìm kiếm và aggregation mạnh hơn nhưng tốn RAM, disk và công vận hành. Em chọn Loki khi đã có Grafana và chủ yếu tìm theo service, thời gian, traceId; chọn Elasticsearch hoặc OpenSearch khi cần full-text search sâu hoặc phân tích log phức tạp.
🇬🇧 English:
Loki only indexes labels; log content is compressed and kept in object storage, so it's cheap and simple, and you query by selecting streams with labels and then filtering text. Elasticsearch indexes every field for full-text search, so search and aggregations are more powerful but it costs more memory, disk and operational effort. I pick Loki when Grafana is already in place and we mostly search by service, time and trace ID, and Elasticsearch or OpenSearch when we need deep full-text search or complex log analytics.
Q3. Bạn đặt label cho log thế nào và nối log với trace ra sao?
🇻🇳 Tiếng Việt:
Label chỉ gồm giá trị ít và ổn định như namespace, app, container, level, đặt giống label bên Prometheus để chuyển qua lại được. Những thứ như traceId, orderId, userId không làm label vì mỗi giá trị tạo một stream, làm Loki chậm và tốn tài nguyên; chúng nằm trong log JSON và được lọc bằng
| json. Spring Boot đưa traceId vào mọi dòng log qua Micrometer Tracing, và Grafana dùng derived field để click traceId mở thẳng trace trong Tempo.
🇬🇧 English:
Labels only hold low-cardinality, stable values such as namespace, app, container and level, named the same as in Prometheus so I can jump between them. Values like trace ID, order ID or user ID are never labels, because each value creates a new stream and makes Loki slow and expensive; they stay inside the JSON log and are filtered with
| json. Spring Boot puts the trace ID in every log line through Micrometer Tracing, and a Grafana derived field turns it into a link that opens the trace in Tempo.
Cách nhớ ngắn gọn
- stdout →
/var/log/podstrên node → rotate → mất khi Pod/node mất. - Agent DaemonSet (Alloy thay Promtail) → Loki → object storage → Grafana.
- Label ít giá trị; traceId/orderId nằm trong nội dung.
- LogQL:
{label}chọn stream →|=/| jsonlọc. - Log JSON giải quyết luôn stack trace nhiều dòng; retention đặt từ ngày đầu.
Tự kiểm tra
Vì sao agent log chạy dạng DaemonSet chứ không phải Deployment?
Log nằm trên disk từng node; mỗi node cần đúng một agent mount /var/log của node đó.
kubectl logs --previous có thay được log tập trung không?
Không. Nó chỉ giữ lần chạy trước của container trong Pod còn tồn tại; Pod bị xóa hoặc file bị rotate là mất.
Query {namespace="shop"} | json | traceId="abc" chậm. Cải thiện thế nào mà không thêm label traceId?
Thu hẹp stream bằng thêm label (app), thu hẹp khoảng thời gian, dùng |= "abc" trước | json; Loki 3 có structured metadata/bloom filter.
Grafana Alloy liên quan gì đến Promtail?
Alloy là agent thay thế Promtail (Promtail chỉ còn bảo trì); gom được cả log, metric, trace theo OpenTelemetry.
Cách tốt nhất để stack trace Java không bị tách dòng?
Log JSON từ Spring Boot (structured logging); stack trace thành một field trong một dòng.