Observability ~4 Min.

Grafana Loki: LogQL und Kosten

Labels, Retention und wo Logs teuer werden.

von PlatPrep

Die Cloud-Rechnung für September hatte eine Zeile, die niemand erklären konnte: Logging-Storage, 847 Euro. Für Logs. Aus Kubernetes-Pods.

Das Erste, was man sich anschauen sollte: Was wird eigentlich geloggt — und wie kommt es in Loki an?

Loki ist im Vergleich zu Elasticsearch bewusst anders konzipiert: Es indexiert nicht den Log-Inhalt, sondern nur die Labels. Der eigentliche Log-Text landet komprimiert in Chunks im Object Storage (S3, GCS), der Index daneben ist winzig. Das macht Ingestion und Storage günstig — S3-Storage kostet einen Bruchteil dessen, was ein Elasticsearch-Cluster mit denselben Daten verschlingt. Aber „günstig pro Gigabyte” heißt nicht „egal, wie viele Gigabytes”: Wenn Applikationen Debug-Logs in Produktion schreiben oder jeder HTTP-Request drei Log-Zeilen erzeugt, summiert sich das trotzdem.

Warum Kardinalität das eigentliche Problem ist

Jede einzigartige Kombination von Labels ist in Loki ein eigener Stream, und jeder Stream produziert eigene Chunks. Wer pod als Label definiert, erzeugt bei jedem Deployment-Rollout neue Streams — bei 50 Deployments pro Tag und 10 Replicas sind das schnell zehntausende Streams. Die Folge: viele kleine, schlecht komprimierte Chunks, ein aufgeblähter Index, langsame Queries und im schlimmsten Fall Ingester, die wegen max_streams_per_user-Limits Logs ablehnen.

Die Faustregel: Labels nur für Dinge mit niedriger, stabiler Kardinalität — namespace, app, environment, level. Alles andere (Request-IDs, User-IDs, Pod-Namen) bleibt im Log-Text und wird zur Query-Zeit extrahiert. Genau andersherum als bei Prometheus, wo Dimensionen in Labels gehören.

LogQL: vom Filter zur Metrik

Eine Basis-Query selektiert über Labels und filtert dann auf Text:

{namespace="team-payments", app="checkout"} |= "ERROR"

Der Label-Matcher ist das, was Loki schnell macht — er grenzt über den Index ein, welche Chunks überhaupt aus dem Storage geladen werden. Die Line-Filter (|=, !=, |~ für Regex) laufen danach brute-force über die geladenen Zeilen. Eine Query über {cluster="prod"} mit 30 Tagen Zeitraum lädt Terabytes — die Reihenfolge „erst eng selektieren, dann filtern” ist keine Stilfrage, sondern bestimmt, ob die Query in 2 Sekunden oder 2 Minuten antwortet.

Strukturierte Logs parst du zur Query-Zeit, nicht beim Ingest:

{namespace="team-payments"} | json | status >= 500 | line_format "{{.method}} {{.path}}"

Für Dashboards und Alerts gibt es Metric-Queries — Logs werden zu Zeitreihen:

sum by (app) (rate({namespace="team-payments"} | json | status >= 500 [5m]))

Damit baust du Error-Rate-Panels direkt aus Logs. Aber Vorsicht: Wenn so eine Query in einem Dashboard mit 10-Sekunden-Refresh hängt, wird sie selbst zum Kostenfaktor. Für dauerhafte Alerting-Metriken ist Prometheus das richtige Werkzeug; Loki-Metric-Queries sind fürs Ad-hoc-Debugging und für Recording Rules gedacht.

An den Stellschrauben drehen

Bei der 847-Euro-Rechnung haben am Ende drei Maßnahmen gewirkt, alle ohne neue Technologie:

Drosseln vor dem Ingest. Der Log-Agent (Promtail, Grafana Alloy oder ein OpenTelemetry Collector — siehe Collector in Produktion) kann Pipeline-Stages ausführen, bevor Logs Loki erreichen: Debug-Zeilen droppen, Health-Check-Requests von Load Balancern verwerfen, mehrzeilige Stacktraces zu einem Event zusammenfassen. Allein die Health-Checks machten bei uns rund 30 Prozent des Volumens aus.

Retention differenzieren. Nicht alle Logs müssen 30 Tage liegen. Lokis Compactor übernimmt die Retention (retention_enabled: true), und über retention_stream in der limits_config kannst du per Stream-Selektor unterscheiden: Debug-Streams 3 Tage, Standard 14, Audit-relevante Namespaces 90. Das ist eine Konfigurationsänderung, kein Architekturprojekt.

Kardinalität aufräumen. Ein Team hatte trace_id als Label gesetzt — gut gemeint, katastrophal in der Wirkung: praktisch jede Log-Zeile ein eigener Stream. Nach dem Umbau auf Query-Zeit-Extraktion fiel die Stream-Anzahl um zwei Größenordnungen, und nebenbei wurden die Queries schneller.

Hilfreich bei der Diagnose ist, dass Loki sich selbst vermisst: Die Distributor- und Ingester-Metriken zeigen das Ingestion-Volumen pro Tenant, und mit einer Query wie topk(10, sum by (namespace) (bytes_over_time({cluster="prod"}[24h]))) findest du in Sekunden heraus, welche Namespaces das Volumen treiben. Erst messen, dann mit dem verursachenden Team reden — das Gespräch “ihr loggt 40 GB am Tag, davon 70 Prozent Health-Checks” führt sich deutlich leichter als ein pauschales “loggt weniger”.

Log-Sampling ist die radikalste Option für High-Volume-Services: nur jeden zehnten erfolgreichen Request loggen, Fehler und Warnungen weiterhin vollständig. Das tut weniger weh, als es klingt, wenn Metriken und Traces den Normalbetrieb abdecken — Logs braucht man vor allem, wenn etwas kaputt ist.

Was im Interview hängen bleibt

Wenn dich jemand nach Loki fragt, ist „Labels niedrig halten” die halbe Antwort. Die ganze Antwort erklärt warum: Streams, Chunks, Index — und dass Lokis Kostenmodell genau dann kippt, wenn man es wie ein Volltext-Suchsystem behandelt. Wer dann noch sagen kann, wie er ein konkretes Kostenproblem diagnostiziert hat (Volumen pro Namespace messen, Top-Streams identifizieren, am Agent drosseln), hat das Thema durchdrungen.

Die 847-Euro-Rechnung landete übrigens bei unter 200 Euro. Mehr zum Zusammenspiel von Loki, Grafana und Prometheus findest du im Grafana-Stack-Lernpfad.