Je hebt Prometheus voor metrics, dus je kunt al zien wat er gebeurt in je clusters. Metrics vertellen je dat de latency van een request om 14:32 piekte. Ze vertellen je niet dat de payment service een null pointer gooide omdat iemand een config-wijziging met een typefout uitrolde. Daarvoor heb je logs nodig.
Het standaardantwoord was jarenlang Elasticsearch. Krachtig en flexibel, en het indexeert elke token in elke logregel. Die full-text index is geweldig totdat je naar de rekening kijkt. Je betaalt ervoor in CPU bij ingest, in RAM om de index warm te houden, en in opslag die sneller groeit dan je daadwerkelijke logvolume. Ik draaide ooit een ELK stack in een vorige baan en was meer tijd kwijt aan het tunen van JVM heap sizes dan aan het lezen van logs.
Loki draait het model om. In plaats van de inhoud van elke regel te indexeren, indexeert het een kleine set labels en bewaart het de logtekst zelf als gecomprimeerde chunks. Dezelfde truc die Prometheus goedkoop maakt voor metrics, nu gericht op logs. Je levert willekeurige full-text search in en krijgt er een systeem voor terug dat je je daadwerkelijk kunt veroorloven op commodity hardware.
Deze post bouwt Loki op in lagen: het kleinste dat werkt, dan een productie-setup, en daarna de tuning-knoppen die je pakt zodra je het echt draait. Stop met lezen zodra je genoeg hebt.
Waarom Loki, en wat het je kost
Grafana Labs bouwde Loki rond een paar bewuste keuzes:
- Kostenefficiënt - Alleen de labels worden geïndexeerd. Logregels worden gecomprimeerd en in object storage gedumpt.
- Kubernetes native - Labels komen direct uit Kubernetes metadata. Geen mapping-config om te onderhouden.
- Grafana integratie - Dezelfde dashboards, dezelfde alerting, dezelfde Explore view die je al voor metrics gebruikt.
- Operationeel simpel - Geen JVM om op te passen, geen shard rebalancing om 3 uur ’s nachts.
De eerlijke trade-off: je kunt niet door al je logs grepen zonder eerst op label te versmallen. Je moet weten welke namespace, app of pod je interesseert voordat je op content begint te filteren. Voor Kubernetes-debugging werk je vrijwel altijd toch al zo. Je begint bij “de checkout pods zijn ongelukkig” en boort in. Is je werk juist open log-analytics waarbij je niet weet waar je naar jaagt, dan gaat die beperking knellen, en daar kom ik aan het eind op terug.
Architectuur
flowchart TD
subgraph cluster["Kubernetes Cluster"]
subgraph nodes["Nodes"]
P1["Promtail<br/>(DaemonSet)"]
P2["Promtail"]
P3["Promtail"]
end
PODS["Pod Logs<br/>(/var/log/pods)"]
end
P1 --> PODS
P2 --> PODS
P3 --> PODS
P1 --> L["Loki"]
P2 --> L
P3 --> L
L --> OS["Object Storage<br/>(chunks)"]
L --> G["Grafana"]
Drie bewegende delen, en dat is het hele verhaal:
Promtail draait op elke node als DaemonSet, tailt de logbestanden onder /var/log/pods, plakt er labels op en stuurt de regels naar Loki.
Loki neemt die regels aan, indexeert ze op label, comprimeert de bodies tot chunks en schrijft de chunks naar object storage.
Grafana bevraagt Loki met LogQL en toont de resultaten in Explore of op een dashboard.
Als je Prometheus hebt gedraaid, voelt deze vorm vertrouwd. Dat is het punt.
De simpelste versie die werkt
Begin met de Grafana Helm charts.
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update
# Installeer Loki met simple scalable deployment
helm install loki grafana/loki \
--namespace monitoring \
--create-namespace \
--values loki-values.yaml
Voor een homelab of een klein cluster draai je Loki als single binary met het lokale filesystem eronder. Geen object storage, geen replicatie, gewoon één proces dat chunks naar een PVC schrijft. Dit is de versie waar ik iedereen mee zou laten starten, omdat je binnen vijf minuten logs in Grafana ziet en kunt beslissen of het model je bevalt voordat je MinIO aansluit.
# loki-values.yaml
loki:
auth_enabled: false
commonConfig:
replication_factor: 1
storage:
type: filesystem
schemaConfig:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
singleBinary:
replicas: 1
persistence:
size: 50Gi
# Disable componenten niet nodig voor single binary
backend:
replicas: 0
read:
replicas: 0
write:
replicas: 0
Die single binary is een prima plek om te stoppen als je nog aan het leren bent. Wil je dat logs een stervende node overleven, verplaats dan de chunks van de lokale disk naar object storage. Hier is dezelfde chart gericht op S3-compatibele storage met een replication factor van drie:
# loki-values.yaml
loki:
auth_enabled: false
commonConfig:
replication_factor: 3
storage:
type: s3
s3:
endpoint: minio.storage:9000
bucketnames: loki-chunks
access_key_id: ${MINIO_ACCESS_KEY}
secret_access_key: ${MINIO_SECRET_KEY}
insecure: true
schemaConfig:
configs:
- from: 2024-01-01
store: tsdb
object_store: s3
schema: v13
index:
prefix: index_
period: 24h
# Schaalbare deployment
backend:
replicas: 3
read:
replicas: 3
write:
replicas: 3
Logs erin krijgen met Promtail
Loki op zichzelf is een lege emmer. Promtail vult hem. Het draait op elke node en stuurt door wat er in de pod-logdirectory belandt.
helm install promtail grafana/promtail \
--namespace monitoring \
--set config.clients[0].url=http://loki:3100/loki/api/v1/push
Een realistischere Promtail-config doet onderweg wat werk: parse het CRI-logformaat, drop het luidruchtige filename-label, en trek een paar velden uit nginx access logs naar labels waarop je kunt bevragen.
# promtail-values.yaml
config:
clients:
- url: http://loki:3100/loki/api/v1/push
snippets:
# Voeg Kubernetes metadata toe als labels
pipelineStages:
- cri: {}
- labeldrop:
- filename
- match:
selector: '{app="nginx"}'
stages:
- regex:
expression: '^(?P<remote_addr>[\d\.]+) - (?P<remote_user>\S+) \[(?P<time_local>[^\]]+)\] "(?P<request>[^"]+)" (?P<status>\d+)'
- labels:
status:
# DaemonSet tolerations voor alle nodes
tolerations:
- operator: Exists
Het ene ding om goed te doen: labels
Begrijp je één ding over Loki, maak het dan dit. Labels zijn de hele index. Ze bepalen hoe logs worden opgeslagen en hoe snel je queries zijn. Doe je ze goed, dan is Loki snel en goedkoop. Doe je ze fout, dan vraag je je af waarom een systeem dat lichtgewicht hoort te zijn omvalt.
Promtail geeft je deze Kubernetes labels gratis:
| Label | Bron | Voorbeeld |
|---|---|---|
namespace | Pod namespace | default, monitoring |
pod | Pod naam | nginx-abc123 |
container | Container naam | nginx, sidecar |
node_name | Node | worker-1 |
app | Pod label | nginx |
job | Scrape config | kubernetes-pods |
Nu de valkuil die iedereen pakt, mij incluis. Elke unieke combinatie van labelwaarden maakt een aparte stream, en Loki houdt per stream een index-entry bij. Voeg een label toe met duizenden verschillende waarden en je hebt zojuist duizenden minuscule streams gemaakt. Dit is high cardinality, en het is de snelste manier om Loki ellendig te maken. Een request_id-label genereert een nieuwe stream voor elke request. Dat zijn miljoenen streams per dag, een index die niet meer in het geheugen past, en queries die kruipen.
De vuistregel: een label hoort een dimensie te zijn waarop je zou groeperen of filteren, met een handvol mogelijke waarden. Alles wat onbegrensd is blijft in de log-body, waar je het op query-tijd met LogQL filtert in plaats van het te indexeren.
Goede labels (begrensd, je filtert erop):
namespace,app,environment,team
Slechte labels (onbegrensd, ze horen in de logregel):
request_id,user_id,trace_id,timestamp
Bevragen met LogQL
LogQL is hoe je je logs daadwerkelijk teruglezt. Ken je PromQL, dan ken je het meeste al: kies een stream met label selectors, en pijp die door filters en parsers.
Basis queries
# Alle logs van een namespace
{namespace="production"}
# Specifieke app
{app="frontend", namespace="production"}
# Meerdere containers
{container=~"nginx|envoy"}
# Exclude een namespace
{namespace!="kube-system"}
Content filteren
# Regels met "error"
{app="frontend"} |= "error"
# Regels ZONDER "health"
{app="frontend"} != "health"
# Regex match
{app="frontend"} |~ "status=(4|5)[0-9]{2}"
# Case insensitive
{app="frontend"} |~ "(?i)error"
Parsen en extracten
# Parse JSON logs
{app="api"} | json
# Extract specifiek veld
{app="api"} | json | status_code >= 500
# Parse met pattern
{app="nginx"} | pattern `<ip> - - [<_>] "<method> <path> <_>" <status>`
# Gebruik geëxtraheerde velden
{app="nginx"} | pattern `<_> - - [<_>] "<method> <path> <_>" <status>` | status >= 400
Hier betaalt de splitsing van labels-plus-filter zich uit. De {...}-selector raakt de index en versmalt je in een oogwenk tot een paar streams. Alles na de pijp draait alleen over die regels, niet over je hele logvolume. Daarom kost het buiten de labels houden van request_id je eigenlijk niets: je parset het op query-tijd terug met | json.
Aggregaties (log metrics)
# Tel errors per app
sum by (app) (count_over_time({namespace="production"} |= "error" [5m]))
# Rate van requests
sum(rate({app="nginx"} | pattern `<_> "<method> <path> <_>" <status>` [1m])) by (status)
# Bytes per namespace
sum by (namespace) (bytes_over_time({job="kubernetes-pods"}[1h]))
Inhaken in Grafana
De reden dat ik Loki koos boven het draaien van een aparte log-stack zit precies hier: de logs landen in dezelfde Grafana die ik al voor metrics gebruik. Eén data source toevoegen, en de rest van de workflow is identiek.
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-datasources
namespace: monitoring
data:
loki.yaml: |
apiVersion: 1
datasources:
- name: Loki
type: loki
url: http://loki:3100
access: proxy
jsonData:
maxLines: 1000
Explore view
In Explore zit ik het grootste deel van mijn Loki-tijd wanneer er iets in brand staat:
- Selecteer Loki data source
- Bouw query met label browser
- Filter met content matches
- Klik op logregels voor context
Dashboard panels
Voeg logs toe aan je dashboards:
{
"type": "logs",
"datasource": "Loki",
"targets": [
{
"expr": "{namespace=\"production\", app=\"frontend\"} |= \"error\"",
"refId": "A"
}
],
"options": {
"showTime": true,
"showLabels": false,
"wrapLogMessage": true
}
}
Metrics en logs correleren
Dit is de beloning. Eén dashboard, metrics en logs naast elkaar.
# Prometheus panel die error rate toont
sum(rate(http_requests_total{status=~"5.."}[5m])) by (app)
# Loki panel die error logs toont
{app="$app"} |= "error"
De $app template-variabele voedt beide panels. Je spot een 5xx-spike in de metric, kijkt naar beneden, en de bijbehorende error-regels staan er al. Geen tab-switchen, geen timestamps kopiëren naar een ander tool.
Geavanceerde patronen: alerting op logs
Zodra je logs bevraagt, kun je er ook op alerten. Soms bestaat het signaal dat je interesseert alleen in een logregel, niet in een metric, en wil je niet wachten tot iemand het opmerkt. Loki geeft je hier twee manieren voor: Grafana alert rules, of Loki’s eigen ruler component.
# Grafana alert rule
apiVersion: 1
groups:
- name: LogAlerts
rules:
- alert: HighErrorRate
expr: |
sum(count_over_time({namespace="production"} |= "error" [5m])) > 100
for: 5m
labels:
severity: warning
annotations:
summary: "Hoge error rate in productie logs"
De ruler draait dezelfde LogQL binnen Loki zelf, dus de alert vuurt ook als Grafana down is. Handig voor de dingen die je echt niet mag missen, zoals een payment service die CRITICAL logt:
# loki-rules.yaml
groups:
- name: errors
rules:
- alert: CriticalError
expr: |
count_over_time({app="payment-service"} |= "CRITICAL" [1m]) > 0
for: 0m
labels:
severity: critical
annotations:
summary: "Kritieke error in payment service"
Retentie en opslag
Logs die eeuwig blijven leven zijn logs waarvoor je eeuwig betaalt om ze op te slaan. Loki’s compactor regelt retentie en verwijdert chunks voorbij je grens. Dertig dagen dekt vrijwel elke “wat gebeurde er vorige week”-vraag zonder dat je opslag onbegrensd groeit.
loki:
limits_config:
retention_period: 30d
compactor:
working_directory: /var/loki/compactor
retention_enabled: true
retention_delete_delay: 2h
retention_delete_worker_count: 150
Draai je multi-tenancy, dan kun je retentie per tenant instellen. Houd productie langer aan, laat dev-logs snel vervallen:
loki:
limits_config:
retention_period: 30d # Default
overrides:
production:
retention_period: 90d # Houd productie logs langer
development:
retention_period: 7d # Dev logs vervallen sneller
Performance tuning
Je kunt Loki lange tijd draaien zonder hier iets aan te raken. Dit zijn de knoppen die je pakt zodra je op echt volume zit en iets traag aanvoelt.
Chunk size
Grotere chunks betekenen minder index entries en betere compressie, ten koste van hogere latency op kleine queries. Het is een throughput-versus-latency-knop:
loki:
ingester:
chunk_target_size: 1572864 # 1.5MB
chunk_idle_period: 30m
max_chunk_age: 2h
Query limieten
Iemand draait uiteindelijk een onbegrensde query over zes maanden logs en vraagt zich af waarom het cluster traag werd. Limieten stoppen dat voordat het begint:
loki:
limits_config:
max_query_length: 721h # Max tijdsbereik
max_query_parallelism: 32 # Concurrent sub-queries
max_entries_limit_per_query: 5000
Caching
Herhaalde queries over hetzelfde tijdsbereik worden een stuk sneller met een memcached voor de chunk- en results-paden:
loki:
memcached:
chunk_cache:
enabled: true
host: memcached.monitoring
results_cache:
enabled: true
host: memcached.monitoring
Structured logging betaalt zich hier uit
Loki beloont je voor loggen in JSON. Een gestructureerde regel betekent dat | json elk veld eruit trekt als iets waarop je kunt filteren, zonder regex-archeologie.
{
"level": "error",
"message": "Failed to process order",
"order_id": "12345",
"error": "payment declined",
"duration_ms": 234
}
Nu werkt een query als deze gewoon, en haalt level en duration_ms op query-tijd uit de regel:
{app="order-service"} | json | level="error" | duration_ms > 1000
De meeste runtimes hebben een one-liner voor JSON-output:
# Spring Boot
logging.pattern.console: '{"timestamp":"%d","level":"%p","logger":"%c","message":"%m"}%n'
# Node.js met winston
const logger = winston.createLogger({
format: winston.format.json(),
});
# Go met zap
logger, _ := zap.NewProduction()
Het complete plaatje: mijn productie-setup
Hier is alles uit de lagen hierboven samengetrokken in één config. Object storage op MinIO, drie replicas, dertig dagen retentie, ingest rate limits, en Promtail op elke node inclusief de control plane.
# Loki met object storage
loki:
auth_enabled: false
commonConfig:
replication_factor: 3
storage:
type: s3
s3:
endpoint: minio.storage:9000
bucketnames: loki-data
limits_config:
retention_period: 30d
ingestion_rate_mb: 10
ingestion_burst_size_mb: 20
# Drie-replica deployment
backend:
replicas: 3
persistence:
size: 50Gi
read:
replicas: 3
write:
replicas: 3
persistence:
size: 50Gi
# Promtail op alle nodes
promtail:
tolerations:
- operator: Exists
config:
clients:
- url: http://loki:3100/loki/api/v1/push
Waarom elk daarvan:
- Object storage op MinIO, geen cloud-bucket. Dezelfde reden waarom ik al het andere self-host, behandeld in Sovereign Infrastructure: mijn logs horen niet afhankelijk te zijn van een provider die ik niet kan inspecteren of waar ik niet bij weg kan lopen.
- 30 dagen retentie, omdat dat de vragen beantwoordt die ik daadwerkelijk stel zonder dat ik betaal om een jaar aan ruis te hamsteren.
- Drie replicas, zodat een omvallende node mijn logs niet meeneemt. Resilience is hier een instelling, geen project.
- Promtail op alle nodes, control plane incluis, omdat de logs die je tijdens een incident het hardst nodig hebt vaak van de componenten zijn die je vergat te verzamelen.
Loki vs Elasticsearch
| Aspect | Loki | Elasticsearch |
|---|---|---|
| Indexering | Alleen labels | Full-text |
| Opslag kosten | Lager | Hoger |
| Query flexibiliteit | Label-first | Full-text search |
| Operations | Simpeler | Complex |
| Geheugengebruik | Lager | Hoger (JVM) |
| Grafana integratie | Native | Goed |
Ik ben hier niet om je te vertellen dat Elasticsearch slecht is. Het doet iets wat Loki echt niet kan, namelijk zoeken waar je niet op had geanticipeerd. De twee systemen optimaliseren voor verschillende vragen, dus het eerlijke antwoord is “welke vraag stel je vaker?”
Kies Loki wanneer:
- Je bevraagt op bekende dimensies (namespace, app, pod)
- Kosten belangrijk zijn
- Je operationele simpelheid wilt
- Je al in het Grafana ecosysteem zit
Kies Elasticsearch wanneer:
- Je full-text search nodig hebt over alle logs
- Je niet weet waar je naar zoekt
- Log analytics een primaire use case is
Waarom dit ertoe doet
Denk terug aan waar dit begon: metrics vertelden je dat er iets brak, en je had de logs nodig om uit te vinden waarom. De reden dat ik Loki draai in plaats van een zwaardere stack zit hem in wat het van me vraagt. Ik kan me veroorloven een maand logs op commodity hardware te bewaren, de labels passen bij hoe ik al over mijn cluster denk, en het leeft in dezelfde Grafana die ik elke dag open. Als ik een failure moet begrijpen, is het antwoord één query weg, niet één JVM heap dump weg.
Combineer het met Prometheus en Thanos voor metrics en traces erbovenop, en je hebt het complete observability-plaatje op infrastructuur die je zelf bezit en kunt doorgronden. Dat laatste is precies het punt. Een logging-systeem dat je je niet kunt veroorloven, of dat je niet snapt wanneer het zich misdraagt, is gewoon nog een black box vastgeschroefd op de black boxes die je probeerde te debuggen.
