Aller au contenu

Grafana Alloy — remplacer Promtail (et tout le reste)

Promtail est en fin de vie. Grafana Agent aussi. Alloy est le successeur officiel depuis avril 2024 — un collecteur universel qui gère logs, métriques et traces dans un seul binaire avec un langage de config déclaratif. Alloy complète la stack LGTM en unifiant la collecte des données.

La syntaxe change complètement, mais la migration est assistée.

Concepts clés

Alloy fonctionne par composants connectés entre eux. Chaque composant a un type et un nom, accepte des arguments, expose des exports.

// type.nom { ... }
loki.source.file "app_logs" {
  targets    = local.file_match.app.targets
  forward_to = [loki.write.default.receiver]
}

On connecte les composants en passant les exports d'un composant comme argument d'un autre — loki.write.default.receiver est l'export receiver du composant loki.write nommé default.

L'UI de debug est dispo sur http://localhost:12345 — elle affiche le graphe des composants et leur état en temps réel.

Installation

Debian / Ubuntu

# Import GPG + repo
wget -q -O - https://apt.grafana.com/gpg.key | gpg --dearmor > /etc/apt/keyrings/grafana.gpg
echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" \
  > /etc/apt/sources.list.d/grafana.list
apt-get update && apt-get install -y alloy

Docker

docker run -d \
  -v /etc/alloy/config.alloy:/etc/alloy/config.alloy \
  -p 12345:12345 \
  grafana/alloy:latest \
  run /etc/alloy/config.alloy

Kubernetes (Helm)

helm repo add grafana https://grafana.github.io/helm-charts
helm upgrade --install alloy grafana/alloy \
  --namespace monitoring \
  -f values.yaml

Le fichier de config par défaut : /etc/alloy/config.alloy

# Valider la config avant de reload
alloy fmt /etc/alloy/config.alloy
alloy validate /etc/alloy/config.alloy

# Reload à chaud
systemctl reload alloy
# ou via API
curl -X POST http://localhost:12345/-/reload

Migration depuis Promtail

Alloy embarque un convertisseur automatique pour faciliter la transition. Pour configurer les alertes basées sur les logs collectés, voir l'alerting avec Loki.

alloy convert \
  --source-format=promtail \
  --output=config.alloy \
  promtail-config.yaml

Le résultat est fonctionnel mais souvent verbeux — ça vaut le coup de simplifier à la main ensuite. La commande supporte aussi --source-format=static (Grafana Agent static mode).

Pipelines

Logs → Loki

Pipeline classique : découverte de fichiers + envoi vers Loki.

local.file_match "app" {
  path_targets = [{"__path__" = "/var/log/app/*.log", "job" = "app"}]
}

loki.source.file "app_logs" {
  targets    = local.file_match.app.targets
  forward_to = [loki.process.add_labels.receiver]
}

loki.process "add_labels" {
  forward_to = [loki.write.default.receiver]

  stage.static_labels {
    values = {
      env = "prod",
    }
  }
}

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

Logs Kubernetes

Pour collecter les logs des pods K8s, on passe par loki.source.kubernetes :

discovery.kubernetes "pods" {
  role = "pod"
}

discovery.relabel "pods" {
  targets = discovery.kubernetes.pods.targets

  rule {
    source_labels = ["__meta_kubernetes_namespace"]
    target_label  = "namespace"
  }
  rule {
    source_labels = ["__meta_kubernetes_pod_name"]
    target_label  = "pod"
  }
  rule {
    source_labels = ["__meta_kubernetes_pod_container_name"]
    target_label  = "container"
  }
}

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

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

Métriques → Prometheus / Mimir

prometheus.scrape "node" {
  targets = [{"__address__" = "localhost:9100"}]
  forward_to = [prometheus.remote_write.mimir.receiver]
}

prometheus.remote_write "mimir" {
  endpoint {
    url = "http://mimir:9009/api/v1/push"
  }
}

Pour scraper la discovery K8s :

discovery.kubernetes "services" {
  role = "service"
}

prometheus.scrape "k8s_services" {
  targets    = discovery.kubernetes.services.targets
  forward_to = [prometheus.remote_write.mimir.receiver]
}

Traces → Tempo

otelcol.receiver.otlp "default" {
  grpc { endpoint = "0.0.0.0:4317" }
  http { endpoint = "0.0.0.0:4318" }

  output {
    traces = [otelcol.exporter.otlp.tempo.input]
  }
}

otelcol.exporter.otlp "tempo" {
  client {
    endpoint = "http://tempo:4317"
    tls {
      insecure = true
    }
  }
}

Multiline parsing

Par défaut, Alloy envoie une entrée Loki par ligne de log. Pour les stack traces et les exceptions, c'est la galère — chaque ligne arrive séparément, impossible de corréler.

stage.multiline agrège les lignes jusqu'à ce que firstline matche à nouveau (ou que max_wait_time expire).

loki.process "multiline_app" {
  forward_to = [loki.write.default.receiver]

  stage.multiline {
    firstline     = "^\\d{4}-\\d{2}-\\d{2}"  // ligne qui commence par une date
    max_wait_time = "3s"
    max_lines     = 128
  }
}
  • firstline : regex qui identifie le début d'un nouvel événement
  • max_wait_time : flush forcé si aucune nouvelle ligne après ce délai (défaut : 5s)
  • max_lines : flush forcé au-delà de N lignes (garde-fou contre les dumps infinis)

Java stack traces

2024-01-15 10:23:45.123 ERROR [main] c.example.MyService - Something went wrong
java.lang.NullPointerException: Cannot invoke method foo()
    at com.example.MyService.doSomething(MyService.java:42)
    at com.example.MyController.handle(MyController.java:18)

Les lignes de continuation commencent par une tab ou Caused by: — le firstline matche le timestamp.

loki.process "java_logs" {
  forward_to = [loki.write.default.receiver]

  stage.multiline {
    firstline     = "^\\d{4}-\\d{2}-\\d{2}\\s\\d{2}:\\d{2}:\\d{2}"
    max_wait_time = "3s"
    max_lines     = 256
  }

  // Extraire level et logger depuis la première ligne
  stage.regex {
    expression = "^\\d{4}-\\d{2}-\\d{2}\\s\\d{2}:\\d{2}:\\d{2}\\.\\d+\\s(?P<level>\\w+)\\s\\[(?P<thread>[^\\]]+)\\]"
  }

  stage.labels {
    values = {
      level = "",
    }
  }
}

Python tracebacks

Traceback (most recent call last):
  File "/app/main.py", line 42, in handle
    result = process(data)
ValueError: invalid data

2 approches : matcher ^Traceback comme firstline (on groupe à partir de l'exception), ou matcher les lignes qui ne commencent pas par un espace (plus générique).

loki.process "python_logs" {
  forward_to = [loki.write.default.receiver]

  // Toute ligne qui ne commence pas par un espace = nouveau log
  stage.multiline {
    firstline     = "^[^\\s]"
    max_wait_time = "3s"
    max_lines     = 128
  }
}

Go panics

goroutine 1 [running]:
main.(*Server).handleRequest(...)
    /app/server.go:87 +0x1a4
panic: runtime error: index out of range [3] with length 3

Les panics Go commencent par goroutine ou panic: — on matche les deux.

loki.process "go_logs" {
  forward_to = [loki.write.default.receiver]

  stage.multiline {
    firstline     = "^(goroutine|panic:|\\d{4}/\\d{2}/\\d{2})"
    max_wait_time = "3s"
    max_lines     = 64
  }
}

JSON avec stack trace dans un champ

Pattern fréquent avec les frameworks modernes (Logback JSON, structlog...) — le log est une ligne JSON mais le champ stack_trace contient un multiline.

{"time":"2024-01-15T10:23:45Z","level":"error","msg":"boom","stack_trace":"goroutine 1...\n\tat main.go:42"}

Dans ce cas, pas besoin de multiline — chaque ligne est déjà un événement complet. On parse le JSON directement :

loki.process "json_logs" {
  forward_to = [loki.write.default.receiver]

  stage.json {
    expressions = {
      level       = "level",
      stack_trace = "stack_trace",
    }
  }

  stage.labels {
    values = {
      level = "",
    }
  }
}

Logs systemd (journal)

Sur du bare metal ou des VMs, les logs applicatifs passent souvent par journald plutôt que des fichiers. loki.source.journal lit directement le journal systemd — pas besoin de local.file_match.

loki.source.journal "systemd" {
  forward_to    = [loki.process.journal.receiver]
  relabel_rules = discovery.relabel.journal.rules
  labels        = {job = "systemd"}
}

discovery.relabel "journal" {
  targets = []

  // Unité systemd comme label (sshd, nginx, docker...)
  rule {
    source_labels = ["__journal__systemd_unit"]
    target_label  = "unit"
  }
  // Priorité syslog comme label (err, warning, info...)
  rule {
    source_labels = ["__journal_priority_keyword"]
    target_label  = "level"
  }
  // Hostname
  rule {
    source_labels = ["__journal__hostname"]
    target_label  = "host"
  }
}

loki.process "journal" {
  forward_to = [loki.write.default.receiver]

  // Ignorer les logs debug et info trop verbeux
  stage.drop {
    expression = ".*systemd.*Started.*"
  }
}

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

Les labels disponibles depuis __journal_* correspondent aux champs journald standards — _SYSTEMD_UNIT, _HOSTNAME, PRIORITY, _COMM, etc. On les liste avec :

journalctl -o json | head -1 | jq 'keys'

Par défaut loki.source.journal démarre depuis la position courante. Pour rejouer depuis le début (migration initiale) :

loki.source.journal "systemd" {
  forward_to = [loki.write.default.receiver]
  path       = "/var/log/journal"
  max_age    = "24h"           // ne pas remonter plus loin que 24h
}

Permissions

Alloy doit appartenir au groupe systemd-journal pour lire le journal :

usermod -aG systemd-journal alloy
systemctl restart alloy

Helm values K8s (DaemonSet)

Configuration minimale pour déployer Alloy en DaemonSet sur K8s — collecte des logs de tous les pods :

alloy:
  configMap:
    content: |
      discovery.kubernetes "pods" {
        role = "pod"
      }

      discovery.relabel "pods" {
        targets = discovery.kubernetes.pods.targets

        rule {
          source_labels = ["__meta_kubernetes_namespace"]
          target_label  = "namespace"
        }
        rule {
          source_labels = ["__meta_kubernetes_pod_name"]
          target_label  = "pod"
        }
        rule {
          source_labels = ["__meta_kubernetes_pod_container_name"]
          target_label  = "container"
        }
        // Exclure les namespaces système
        rule {
          source_labels = ["__meta_kubernetes_namespace"]
          regex         = "kube-system"
          action        = "drop"
        }
      }

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

      loki.write "default" {
        endpoint {
          url = env("LOKI_URL")
        }
      }

controller:
  type: daemonset

rbac:
  create: true

serviceAccount:
  create: true

Les variables d'environnement sont injectables directement dans la config via env("VAR").

Troubleshooting

# Logs du service
journalctl -u alloy -f

# État des composants via API
curl -s http://localhost:12345/api/v0/web/components | jq '.[] | {name, health}'

# Métriques Alloy lui-même
curl http://localhost:12345/metrics | grep alloy_

# Debug d'un composant spécifique dans l'UI
open http://localhost:12345/component/loki.source.file.app_logs

Les erreurs courantes :

  • component evaluation failed → vérifier que les exports référencés existent bien (typo dans le nom du composant)
  • failed to read targets → permission sur les fichiers de log (Alloy tourne souvent en alloy user)
  • 429 Too Many Requests → rate limiting Loki, ajouter un loki.process avec stage.limit

Voir aussi