Grafana + Loki + Vector: logování bez zbytečné vědy

Docker logy nemusíte hledat ručně na každém serveru. Ukážeme si, jak pomocí Vectoru sbírat logy z Dockeru i journald, ukládat je do Loki a pohodlně procházet v Grafaně. Vše prakticky, pomocí Docker Compose a konkrétních příkladů konfigurace.
Logy v docker compose logs jsou fajn, dokud máte jeden server. Jakmile je serverů nebo služeb víc, začne být pohodlnější posílat všechno na jedno místo. Jenže tohle je často složitější, než to vypadá.
Pokud nechcete své logy posílat přes internet do komerční služby, jako je Datadog Logs nebo Better Stack, začínají být vaše možnosti značně omezené.
První, co se nabízí, je použít ELK (Elasticsearch + Logstash + Kibana), ale každý, kdo ELK Stack zkoušel, mi jistě dá za pravdu, že není úplně jednoduché ho nastavit, rozchodit a hlavně udržovat. Další možností je Graylog, ale tohle řešení je snad ještě horší. Všechna tahle řešení jsem zkoušel a několik let také provozoval.
Jako vždy je tady ještě jedna možnost (ta nejlepší), na kterou mě přivedl Jakub Onderka. V tomhle článku si ukážeme jednoduchý logovací stack postavený na třech službách:
- Vector sbírá logy z Dockeru a upravuje je,
- Loki je ukládá a umí v nich hledat,
- Grafana nad nimi nabízí webové rozhraní.
Výsledkem bude lokální setup v Docker Compose. Není to Kubernetes, nepotřebujete Prometheus a první log uvidíte za pár minut.

Jak data tečou
Celé řešení vypadá takhle:
Docker kontejnery -> Vector -> Loki -> Grafana
Vector čte Docker socket, takže automaticky vidí stdout a stderr všech kontejnerů na hostiteli. Každý záznam převede na JSON, přidá k němu název kontejneru a služby a odešle ho do Loki.
Loki není Elasticsearch. Neindexuje celý obsah každého řádku, ale především takzvané labels. Díky tomu má menší nároky na disk i paměť. O to důležitější je rozmyslet si, co do labels uložíte. host, service nebo container jsou dobrá volba. ID requestu nebo ID uživatele už ne, protože vytvářejí příliš mnoho unikátních streamů.
Struktura projektu
Vytvořte si následující soubory:
logging/
├── compose.yml
├── loki-config.yml
├── vector.yaml
└── provisioning/
└── datasources/
└── loki.yml
Code language: plaintext (plaintext)
Grafaně rovnou připravíme datasource pomocí provisioningu. Po prvním spuštění tedy nebudete muset nic ručně naklikávat.
Docker Compose
Do compose.yml vložte tři služby:
services:
loki:
image: grafana/loki:latest
restart: unless-stopped
command: -config.file=/etc/loki/local-config.yaml
ports:
- "3100:3100"
volumes:
- ./loki-config.yml:/etc/loki/local-config.yaml:ro
- loki-data:/loki
grafana:
image: grafana/grafana:latest
restart: unless-stopped
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
volumes:
- grafana-data:/var/lib/grafana
- ./provisioning:/etc/grafana/provisioning:ro
vector:
image: timberio/vector:latest-alpine
restart: unless-stopped
command: --config /etc/vector/vector.yaml
depends_on:
- loki
volumes:
- ./vector.yaml:/etc/vector/vector.yaml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
volumes:
loki-data:
grafana-data:
Code language: YAML (yaml)
Používám tady tag latest, aby byl příklad krátký. V produkci si verze připněte, aby vám nešedivěly vlasy. Upgrade Loki může vyžadovat úpravu konfigurace a nechcete ho řešit náhodou při obyčejném restartu serveru.
Port 3000 patří Grafaně. Port 3100 je API Loki, ne webové rozhraní. Když na http://localhost:3100/ dostanete 404, není to chyba, koukněte na http://localhost:3100/metrics, tam budou vidět základní metriky.
Konfigurace Loki
Pro první spuštění vystačíme s lokálním diskem a TSDB indexem. Do loki-config.yml vložte:
auth_enabled: false
server:
http_listen_port: 3100
common:
path_prefix: /loki
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
storage_config:
filesystem:
directory: /loki/chunks
tsdb_shipper:
active_index_directory: /loki/index
cache_location: /loki/index_cache
limits_config:
retention_period: 14d
reject_old_samples: true
reject_old_samples_max_age: 168h
discover_log_levels: true
compactor:
working_directory: /loki/compactor
compaction_interval: 10m
retention_enabled: true
delete_request_store: filesystem
Code language: YAML (yaml)
auth_enabled: false vypíná multi-tenancy. Pro lokální síť nebo jeden interní server je to pohodlné, ale Loki pak sám nikoho neověřuje. Port 3100 proto nevystavujte volně do internetu. Dejte před něj reverzní proxy s autentizací, firewall, nebo klidně obojí.
Retenci jsme nastavili na 14 dní. Samotné retention_period nestačí, mazání musí provádět také compactor s retention_enabled: true.
Grafana bez klikání
Datasource nakonfigurujeme souborem provisioning/datasources/loki.yml:
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
url: http://loki:3100
isDefault: true
editable: true
Code language: YAML (yaml)
Adresa je http://loki:3100, nikoli localhost. Uvnitř Compose sítě se služby hledají podle svého názvu. localhost by z pohledu Grafany znamenal kontejner Grafany.
Vector sbírá Docker logy
Teď přichází nejdůležitější část. Do vector.yaml vložte:
sources:
docker:
type: docker_logs
exclude_containers:
- vector
- loki
- grafana
transforms:
normalize:
type: remap
inputs:
- docker
source: |
.message = strip_ansi_escape_codes!(string!(.message))
.container = .container_name
.service = .label."com.docker.compose.service" ?? .container_name
.project = .label."com.docker.compose.project" ?? "docker"
sinks:
loki:
type: loki
inputs:
- normalize
endpoint: http://loki:3100
encoding:
codec: json
labels:
source: docker
container: "{{ container }}"
service: "{{ service }}"
project: "{{ project }}"
out_of_order_action: accept
dangerously_allow_unconfined_template_resolution: true
Code language: YAML (yaml)
Zdroj docker_logs poslouchá Docker socket. Transformace v jazyce VRL odstraní ANSI barvy z konzole a vytáhne Docker Compose labels. Sink pak odešle celý záznam jako JSON do Loki.
Vector schválně nesbírá vlastní logy ani logy Loki a Grafany. Jinak si snadno vyrobíte šum, případně nepříjemnou logovací smyčku.
Konfiguraci si ještě před spuštěním ověřte:
docker compose run --rm vector validate /etc/vector/vector.yamlCode language: Shell Session (shell)
Vector má poměrně přísnou validaci. Překlep v názvu transformace nebo neexistující input tak odhalíte dřív, než začnete hledat chybu v Loki.
První spuštění
Stack nastartujte:
docker compose up -d
docker compose psCode language: Shell Session (shell)
Ověřte, že Loki odpovídá:
curl -s http://localhost:3100/loki/api/v1/labels | jqCode language: Shell Session (shell)
Pak spusťte kontejner, který bude něco zapisovat:
docker run --rm alpine sh -c \
'echo "Ahoj z Dockeru"; echo "Něco se pokazilo" >&2'Code language: Shell Session (shell)
Otevřete http://localhost:3000, přihlaste se jako admin / admin a přejděte do Explore. Vyberte datasource Loki a zkuste LogQL dotaz:
{source="docker"}Code language: JSON / JSON with Comments (json)
Jen konkrétní službu najdete pomocí labelu:
{source="docker", service="api"}Code language: JavaScript (javascript)
A text v logu pomocí filtru:
{source="docker"} |= "pokazilo"Code language: Shell Session (shell)
Protože Vector posílá JSON, můžete parsovat jednotlivá pole až při dotazu:
{source="docker"} | json | message =~ "(?i)error|failed|exception"Code language: Shell Session (shell)
Tohle je důležitý princip Loki. Stabilní metadata patří do labels, proměnlivá data zůstávají v těle logu a parsují se až při hledání.
Co se severity
Grafana umí nad logy nabídnout filtr podle úrovně, ale nejdřív jí musíte dát použitelná data. Nejjednodušší je logovat z aplikace strukturovaný JSON:
{"level":"error","message":"Platba se nepodařila","order_id":1234}Code language: JSON / JSON with Comments (json)
Loki s discover_log_levels: true běžně pozná pole jako level nebo severity. Pokud aplikace posílá obyčejný text, můžete úroveň dopočítat ve Vectoru. Například pro Nginx lze odpovědi 500 a vyšší označit jako error, odpovědi 400 až 499 jako warning a zbytek jako info.
Nedělejte ale z level automaticky label. Nízká kardinalita je sice bezpečná, ale structured metadata nebo JSON pole obvykle stačí a dovolí Grafaně úroveň rozpoznat bez vytváření dalšího streamu.
Journald místo souborů
Na serveru často nechcete jen Docker logy. Vector umí číst také journald (případně jakékoliv další textové logy):
sources:
journal:
type: journald
current_boot_only: true
sinks:
loki:
inputs:
- normalize
- journalCode language: YAML (yaml)
V praxi je nejjednodušší spouštět Vector jako systemd službu přímo na hostiteli. Kontejner by potřeboval připojit journal adresáře a obvykle také /etc/machine-id. Agent na každém serveru pak může posílat do jednoho centrálního Loki label host, podle kterého v Grafaně snadno poznáte původ záznamu.
Postup vypadá následovně. Nejprve odstraňte z compose.yml službu vector, jinak budou Docker logy zdvojené. Pak přímo na stroji nainstalujte Vector:
curl --proto '=https' --tlsv1.2 -sSfL https://sh.vector.dev | bash
sudo apt-get install vector
sudo systemctl enable --now vectorCode language: Shell Session (shell)
Nastavte oprávnění:
sudo usermod -aG docker vector
sudo usermod -aG systemd-journal vector
sudo systemctl restart vectorCode language: Shell Session (shell)
Přístup ověřte:
sudo -u vector docker ps
sudo -u vector journalctl -n 1Code language: Shell Session (shell)
Oba příkazy musí fungovat bez chyby permission denied. Poté stačí vytvořit /etc/vector/vector.yaml, který bude obsahovat všechny zdroje:
data_dir: /var/lib/vector
sources:
docker:
type: docker_logs
journal:
type: journald
current_boot_only: true
transforms:
docker_labels:
type: remap
inputs:
- docker
source: |
.message = strip_ansi_escape_codes!(string!(.message))
.source = "docker"
.container = .container_name
.service = .label."com.docker.compose.service" ?? .container_name
if exists(.label."com.docker.compose.project") {
.project = .label."com.docker.compose.project"
} else {
.project = "docker"
}
journal_labels:
type: remap
inputs:
- journal
source: |
.source = "journald"
.container = "system"
.project = "system"
.service = ._SYSTEMD_UNIT ?? .SYSLOG_IDENTIFIER ?? "journald"
priority = to_int(.PRIORITY) ?? 6
if priority <= 3 {
.severity = "error"
} else if priority == 4 {
.severity = "warning"
} else {
.severity = "info"
}
message = downcase(to_string(.message) ?? "")
if contains(message, "segfault") {
.severity = "error"
}
sinks:
loki:
type: loki
inputs:
- docker_labels
- journal_labels
endpoint: http://logs.example.com:3100
encoding:
codec: json
labels:
host: "{{ host }}"
source: "{{ source }}"
container: "{{ container }}"
service: "{{ service }}"
out_of_order_action: accept
dangerously_allow_unconfined_template_resolution: true
buffer:
type: disk
max_size: 1073741824
when_full: blockCode language: YAML (yaml)
Veškeré logy se teď budou posílat na adresu http://logs.example.com:3100, tady by vám měla běžet Grafana a Loki. Obě transformace vytvářejí stejná základní pole: host, source,container a service. Při ladění konfigurace se vám bude hodit validace:
sudo vector validate /etc/vector/vector.yamlCode language: Shell Session (shell)
A samozřejmě budete muset službu po změně konfigurace restartovat:
sudo systemctl restart vector
sudo systemctl status vectorCode language: Shell Session (shell)
Když něco nefunguje, koukněte do logů samotného Vectoru:
sudo journalctl -u vector -fCode language: Shell Session (shell)
Produkční provoz
Lokální filesystem je ideální pro demo a menší instalaci. Pokud chcete oddělit data od jednoho serveru, Loki umí ukládat chunks a TSDB index například do AWS S3.
Závěrem
Grafana, Loki a Vector tvoří příjemně malý stack. Vector se postará o sběr a normalizaci, Loki o levné uložení a LogQL dotazy a Grafana o procházení logů. Nemusíte kvůli tomu stavět velký observability cluster.
Začněte Docker logy z jednoho serveru. Jakmile setup funguje, přidejte journald, další hostitele, rozumné labels a případně S3. Největší službu přitom uděláte sami sobě tím, že aplikace budou od začátku logovat strukturovaný JSON. Všechno ostatní se pak hledá podstatně snáz.
… liked this!