· · 12 min leestijd Intermediate

Loki voor Kubernetes logging: de Prometheus-achtige aanpak

Deploy Grafana Loki voor Kubernetes log aggregatie. Leer de label-based aanpak, LogQL queries, en waarom Loki goedkoper is dan Elasticsearch.

Een meisje leest één tape onder een bureaulamp terwijl een verkoper in goudpak een archiefkast met prijskaartje aanprijst

TL;DR Loki indexeert labels en bewaart de logtekst als gecomprimeerde chunks, dus een maand logs past op commodity hardware. Wat je inlevert is full-text search. Hieronder de single-binary-install waar ik iedereen mee laat beginnen, dezelfde chart op MinIO met drie replicas, en de knoppen waar ik pas aan zat toen het volume echt was.

Je hebt Prometheus voor metrics, dus je ziet al wat er in je clusters gebeurt. Metrics vertellen je dat de latency 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 de logregel nodig.

Het standaardantwoord was jarenlang Elasticsearch. Krachtig en flexibel, en het indexeert elke token in elke logregel. Die full-text index is prachtig totdat je de rekening ziet. Je betaalt ervoor in CPU bij ingest, in RAM om de index warm te houden, en in opslag die harder groeit dan de logs zelf. Bij een vorige werkgever draaide ik een ELK stack en was ik meer tijd kwijt aan JVM heap sizes dan aan logs lezen.

Loki indexeert een kleine set labels en bewaart de logtekst zelf als gecomprimeerde chunks. Dezelfde truc die Prometheus goedkoop maakt voor metrics, nu op logs gericht. Je levert willekeurige full-text search in en krijgt er een systeem voor terug dat je op commodity hardware kunt betalen.

Waarom Loki, en wat het je kost

Grafana Labs maakte vier keuzes die de rest van deze post verklaren. Alleen de labels worden geïndexeerd, de logregels gaan gecomprimeerd naar object storage. De labels komen rechtstreeks uit de Kubernetes-metadata, dus er is geen mapping-config om bij te houden. De dashboards, de alerting en de Explore view zijn dezelfde Grafana die je al voor metrics gebruikt. En er is geen JVM om op te passen en geen shard rebalancing om je bed voor uit te komen.

De prijs is dat je niet door al je logs kunt grepen zonder eerst op label te versmallen. Je moet weten welke namespace, app of pod je zoekt voordat je op inhoud gaat filteren. Mis ik dat? Alleen als ik niet weet welke app het gelogd heeft, en dat komt minder voor dan ik had verwacht. Kubernetes-debuggen begint bij “de checkout pods doen raar” en boort van daaruit in, en dat ís een label selector. Is je werk juist open log-analytics waarbij je niet weet waar je naar jaagt, dan gaat die beperking knellen. Daar kom ik aan het eind op terug.

Architectuur

Kubernetes Cluster

Nodes

Promtail
(DaemonSet)

Promtail

Promtail

Pod Logs
(/var/log/pods)

Loki

Object Storage
(chunks)

Grafana

Drie bewegende delen.

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.

Heb je Prometheus gedraaid, dan ken je deze vorm al, collectors die een server voeden die Grafana voedt, en Grafana Labs heeft die expres overgenomen.

Eén proces op één schijf

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, één proces dat chunks naar een PVC schrijft. Hiermee laat ik iedereen beginnen, omdat je binnen een minuut of vijf logs in Grafana ziet en kunt beslissen of het labelmodel 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

Aan die single binary heb je genoeg om LogQL te leren. Wat hij niet overleeft is een node die omvalt, want de chunks staan op de PVC van die node. Zodra dat ertoe doet, verhuis je de chunks 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 leest uit zichzelf niks. Promtail 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. Hij parset het CRI-logformaat, dropt het luidruchtige filename-label en trekt 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

Labels zijn de index

Onthoud je één ding over Loki, dan dit. Labels zijn de hele index. Ze bepalen hoe logs worden opgeslagen en hoe snel een query terugkomt. Een slechte labelset is hoe een systeem dat zuinig op RAM hoort te zijn alsnog omvalt.

Promtail geeft je deze Kubernetes labels cadeau:

LabelBronVoorbeeld
namespacePod namespacedefault, monitoring
podPod naamnginx-abc123
containerContainer naamnginx, sidecar
node_nameNodeworker-1
appPod labelnginx
jobScrape configkubernetes-pods

Nu de fout die ik zelf ook gemaakt heb. Elke unieke combinatie van labelwaarden is een aparte stream, en Loki houdt per stream een index-entry bij. Voeg een label toe met duizenden verschillende waarden en je hebt duizenden minuscule streams gemaakt. Dat is high cardinality, en het is de snelste manier om Loki ongelukkig te maken. Een request_id-label maakt voor elke request een nieuwe stream. Dat zijn miljoenen streams per dag, een index die niet meer in het geheugen past, en queries die kruipen.

Dus 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 terugleest. Ken je PromQL, dan ken je het meeste al. Kies een stream met label selectors en pijp die door filters en parsers.

Basisqueries

# Alle logs van een namespace
{namespace="production"}

# Specifieke app
{app="frontend", namespace="production"}

# Meerdere containers
{container=~"nginx|envoy"}

# Exclude een namespace
{namespace!="kube-system"}

Inhoud 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 extraheren

# 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

Daarom kost het je niks om request_id uit de labels te houden. De {...}-selector raakt de index en versmalt je tot een paar streams. Alles na de pijp draait alleen over die regels, niet over je hele logvolume, en | json haalt het request-id op query-tijd weer tevoorschijn.

Aggregaties, oftewel metrics uit logs

# 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 een aparte log-stack is dat de logs in dezelfde Grafana landen die ik al voor metrics gebruik. Eén data source erbij, 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

In Explore zit ik het grootste deel van m’n Loki-tijd als er iets in brand staat. Kies de Loki data source, bouw de selector in de label browser, zet er een inhoudsfilter achter en klik op een regel om de regels eromheen te zien.

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 in één dashboard

# 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 ziet een 5xx-piek in de metric, kijkt naar beneden, en de bijbehorende error-regels staan er al. Geen tabs wisselen, geen timestamps overtypen in een ander tool. Dit is wat ik van een logsysteem wilde toen ik klaar was met heap sizes tunen.

Alerten op een logregel

Zodra je logs bevraagt, kun je er ook op alerten. Soms bestaat het signaal dat je zoekt alleen in een logregel en niet in een metric, en wil je niet wachten tot iemand het opmerkt. Loki geeft je daar 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 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

Elke chunk die je bewaart is schijf waar je voor betaalt. Loki’s compactor regelt retentie en verwijdert chunks voorbij je grens. Dertig dagen dekt vrijwel elke “wat gebeurde er vorige week”-vraag zonder dat de bucket 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 vast, 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 lang 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

Querylimieten

Iemand draait vroeg of laat 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()

De values die ik draai

Hier staat alles van hierboven 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

De bucket staat op MinIO en niet bij een cloudprovider om de reden uit Sovereign Infrastructure, namelijk dat m’n logs niet mogen afhangen van een provider die ik niet kan inspecteren of waar ik niet bij weg kan. Retentie is 30 dagen, omdat dat de vragen beantwoordt die ik stel zonder dat ik betaal om een jaar ruis te hamsteren. Drie replicas, zodat een omvallende node m’n logs niet meeneemt. En Promtail ook op de control plane, omdat de logs die je tijdens een incident het hardst nodig hebt vaak van de componenten komen die je vergat te verzamelen.

Loki vs Elasticsearch

AspectLokiElasticsearch
IndexeringAlleen labelsFull-text
Opslag kostenLagerHoger
Query flexibiliteitLabel-firstFull-text search
OperationsSimpelerComplex
GeheugengebruikLagerHoger (JVM)
Grafana integratieNativeGoed

Elasticsearch kan één ding dat Loki niet kan, en dat is zoeken waar je niet op had gerekend. De twee systemen optimaliseren voor verschillende vragen, dus de vraag is welke van de twee jij vaker stelt.

Loki past als je bevraagt op bekende dimensies als namespace, app en pod, als kosten meetellen, als je liever minder bewegende delen beheert, en als je toch al in Grafana zit. Elasticsearch past als je full-text search over al je logs nodig hebt, als je niet weet waar je naar zoekt, en als log-analytics je hoofddoel is en niet een hulpmiddel bij debuggen.

Waarom ik deze draai

Ik kan een maand logs bewaren op hardware die van mij is, de labels passen bij hoe ik toch al over m’n cluster denk, en het leeft in dezelfde Grafana die ik elke dag open. Gaat er iets stuk, dan is het antwoord één LogQL-query in het panel onder de metric die alarm sloeg.

Zet er Prometheus en Thanos voor metrics naast en traces erbovenop, en je hebt alle drie de signalen op infrastructuur waarvan je de config kunt lezen. De piek van 14:32 van bovenaan deze post krijgt een |= "error"-panel onder z’n grafiek, en de config-key met de typefout staat in een van die regels.