Loki and LogQL: logs without Elasticsearch overhead

Published: 2026-03-21

Loki indexes only log labels, not log content. This makes it dramatically cheaper than Elasticsearch for raw log storage — 10× less disk, minimal CPU for ingestion. The tradeoff: full-text search is slower because it regex-scans log lines rather than looking up an inverted index. For most use cases — finding errors, tracing requests, debugging — LogQL is fast enough and the cost savings are real.


Install with Helm

yamlapiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: loki
  namespace: observability
spec:
  chart:
    spec:
      chart: loki
      version: "6.x"
      sourceRef:
        kind: HelmRepository
        name: grafana
  values:
    deploymentMode: SingleBinary
    loki:
      commonConfig:
        replication_factor: 1
      storage:
        type: filesystem
      auth_enabled: false
      limits_config:
        retention_period: 30d
        ingestion_rate_mb: 16
        ingestion_burst_size_mb: 32
    singleBinary:
      replicas: 1
      persistence:
        enabled: true
        storageClass: local-path
        size: 50Gi
    monitoring:
      serviceMonitor:
        enabled: true
        labels:
          release: kube-prom-stack

SingleBinary mode runs all Loki components in one pod. Fine for a single-cluster setup. For HA, use SimpleScalable or Distributed mode with object storage.


Grafana Alloy (Promtail replacement)

Promtail is deprecated in favor of Grafana Alloy. Alloy ships logs to Loki, metrics to Prometheus, and traces to Tempo from the same agent:

yamlapiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: alloy
  namespace: observability
spec:
  chart:
    spec:
      chart: alloy
      version: "0.x"
      sourceRef:
        kind: HelmRepository
        name: grafana
  values:
    alloy:
      configMap:
        content: |
          discovery.kubernetes "pods" {
            role = "pod"
          }

          discovery.relabel "pods" {
            targets = discovery.kubernetes.pods.targets
            rule {
              source_labels = ["__meta_kubernetes_pod_node_name"]
              target_label  = "node"
            }
            rule {
              source_labels = ["__meta_kubernetes_namespace"]
              target_label  = "namespace"
            }
            rule {
              source_labels = ["__meta_kubernetes_pod_label_app"]
              target_label  = "app"
            }
          }

          loki.source.kubernetes "pods" {
            targets    = discovery.relabel.pods.output
            forward_to = [loki.write.default.receiver]
          }

          loki.write "default" {
            endpoint {
              url = "http://loki.observability.svc.cluster.local:3100/loki/api/v1/push"
            }
          }

LogQL basics

LogQL starts with a log stream selector and optionally pipes through filters and parsers.

Stream selectors:

logql{namespace="app", app="my-service"}
{namespace="app"} |= "ERROR"
{namespace="app"} |~ "timeout|refused"
{namespace="app"} != "healthz"
  • |= line contains string
  • |~ line matches regex
  • != line does not contain string
  • !~ line does not match regex

JSON parsing:

logql{namespace="app", app="my-service"}
  | json
  | level="error"
  | line_format "{{.message}}"

| json parses each log line as JSON and extracts fields. | level="error" filters on the parsed level field. line_format controls what's shown.

Rate queries for Grafana panels:

logqlrate({namespace="app"} |= "ERROR" [5m])

Errors per second over the last 5 minutes. Use in dashboards to show error rate alongside metrics.

Count errors by service:

logqlsum by (app) (
  rate({namespace="app"} |= "ERROR" [5m])
)

Grafana datasource

yamladditionalDataSources:
  - name: Loki
    type: loki
    url: http://loki.observability.svc.cluster.local:3100
    access: proxy
    isDefault: false
    jsonData:
      derivedFields:
        - name: TraceID
          matcherRegex: '"traceId":"(\w+)"'
          url: "${__value.raw}"
          datasourceUid: jaeger

The derivedFields config makes trace IDs in log lines clickable links that open in Jaeger. This bridges logs and traces without a full APM solution.


Loki vs Elasticsearch

Feature Loki Elasticsearch
Storage cost Low (label-only index) High (full inverted index)
Ingestion CPU Minimal Significant
Full-text search Regex scan (slower) Inverted index (fast)
Structured field filtering Via JSON parser Native
Integration Grafana Kibana
Best for Kubernetes logs Application search

Troubleshooting

Too many streams error: Loki has a default limit on unique label combinations. Avoid high-cardinality labels like pod names or request IDs. Keep labels to: namespace, app, job, level.

Ingestion rate limit: Increase ingestion_rate_mb in limits_config or apply per-tenant limits.

Queries timing out: Add |= "search term" to reduce the number of log lines before parsing. The stream selector filters on labels (fast), then filters narrow by content.