Général 5 min de lecture · 952 mots

Loki + Promtail : centraliser les logs de tout ton homelab dans Grafana

Loki + Promtail : centraliser les logs de tout ton homelab dans Grafana
Général · 2026.08.05

Pendant longtemps, déboguer un problème sur le homelab ressemblait à une chasse au trésor sans carte. Un service qui ne répond plus : ssh heighliner, journalctl -fu nginx, rien d’anormal. ssh 10.10.30.161, journalctl -fu vaultwarden, toujours rien. Passer d’une machine à l’autre en espérant tomber sur le bon log au bon moment – c’est intenable quand tu gères une douzaine de VMs et LXC. La corrélation temporelle entre les événements sur plusieurs hôtes est quasiment impossible à la main.

J’avais déjà Prometheus et Grafana sur hubble.arewel.com pour les métriques. Il me manquait la couche logs. La stack PLG (Promtail + Loki + Grafana) est la réponse naturelle dans cet écosystème.

Architecture

heighliner (Proxmox 8.3)
  ├── LXC 109  Loki 3.x         10.10.30.109:3100  ← backend logs
  ├── LXC 120  Prometheus       10.10.30.120:9090
  ├── LXC 121  Grafana          10.10.30.121:3000   ← lecture seule, point d'entrée
  │
  ├── LXC xxx  Pi-hole          → Promtail (systemd)
  ├── LXC xxx  Vaultwarden      → Promtail (systemd)
  ├── LXC xxx  Nginx PM         → Promtail (systemd + fichiers /var/log/nginx/)
  ├── VM  203  Immich            → Promtail (systemd)
  └── ...

Loki est le backend de stockage des logs. Promtail tourne sur chaque machine et envoie les logs vers Loki. Grafana se connecte à Loki comme datasource et permet d’interroger les logs en LogQL – Grafana ne stocke rien, il lit.

Le choix de déployer Promtail en service systemd (et non Docker) sur chaque hôte est délibéré : c’est plus simple pour lire journald directement, sans avoir à monter des sockets dans des containers.

Déployer Loki dans un LXC dédié

Créer le LXC 109 en VLAN 30 sur heighliner :

pct create 109 local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst 
  --hostname loki 
  --memory 1024 
  --cores 2 
  --net0 name=eth0,bridge=vmbr0,tag=30,ip=10.10.30.109/24,gw=10.10.30.1 
  --storage local-lvm 
  --rootfs local-lvm:20 
  --unprivileged 1 
  --onboot 1

pct start 109
pct enter 109

Loki sera déployé via Docker Compose dans ce LXC. C’est l’exception à la règle du « Promtail en systemd » : Loki lui-même est plus simple à maintenir avec Docker quand on veut contrôler sa version exactement.

apt update && apt install -y docker.io docker-compose-plugin curl
mkdir -p /opt/loki/data /opt/loki/config

Créer la configuration Loki :

# /opt/loki/config/loki-config.yml
auth_enabled: false

server:
  http_listen_port: 3100
  grpc_listen_port: 9096

common:
  instance_addr: 127.0.0.1
  path_prefix: /loki
  storage:
    filesystem:
      chunks_directory: /loki/chunks
      rules_directory: /loki/rules
  replication_factor: 1
  ring:
    kvstore:
      store: inmemory

query_range:
  results_cache:
    cache:
      embedded_cache:
        enabled: true
        max_size_mb: 100

schema_config:
  configs:
    - from: 2024-01-01
      store: tsdb
      object_store: filesystem
      schema: v13
      index:
        prefix: index_
        period: 24h

ruler:
  alertmanager_url: http://10.10.30.120:9093

limits_config:
  retention_period: 30d
  ingestion_rate_mb: 16
  ingestion_burst_size_mb: 32

compactor:
  working_directory: /loki/compactor
  compaction_interval: 10m
  retention_enabled: true
  retention_delete_delay: 2h
  retention_delete_worker_count: 150

La rétention est fixée à 30 jours. Pour un homelab, c’est suffisant. Les logs de prod tournent en semaines ou mois, mais pour du debugging personnel, 30 jours couvre tous les cas réels que j’ai rencontrés.

Docker Compose :

# /opt/loki/docker-compose.yml
services:
  loki:
    image: grafana/loki:3.1.0
    container_name: loki
    restart: unless-stopped
    ports:
      - "3100:3100"
    volumes:
      - ./config/loki-config.yml:/etc/loki/local-config.yaml:ro
      - ./data:/loki
    command: -config.file=/etc/loki/local-config.yaml
    user: "0"

Le user: "0" est nécessaire pour que Loki puisse écrire dans /loki/data quand le répertoire appartient à root. En production, tu créerais un utilisateur dédié. Pour un LXC homelab, c’est acceptable.

cd /opt/loki
docker compose up -d
docker compose logs -f --tail=50

Vérifier que Loki répond :

curl -s http://localhost:3100/ready
# ready
curl -s http://localhost:3100/loki/api/v1/labels
# {"status":"200","data":[]}  ← vide pour l'instant, c'est normal

Installer Promtail sur chaque machine

Promtail est un binaire statique. Je l’installe en service systemd sur chaque VM et LXC à monitorer.

PROMTAIL_VERSION="3.1.0"
cd /tmp
wget -q https://github.com/grafana/loki/releases/download/v${PROMTAIL_VERSION}/promtail-linux-amd64.zip
unzip -q promtail-linux-amd64.zip
install -m 755 promtail-linux-amd64 /usr/local/bin/promtail

useradd --no-create-home --shell /bin/false promtail
usermod -aG systemd-journal promtail
mkdir -p /etc/promtail /var/lib/promtail
chown promtail:promtail /var/lib/promtail

L’ajout de promtail au groupe systemd-journal est indispensable. Sans ça, Promtail ne peut pas lire les logs journald et démarre sans erreur visible mais n’ingère rien.

La configuration Promtail varie légèrement selon le rôle de la machine. Voici la version générique pour une machine avec uniquement journald :

# /etc/promtail/config.yml
server:
  http_listen_port: 9080
  grpc_listen_port: 0

positions:
  filename: /var/lib/promtail/positions.yaml

clients:
  - url: http://10.10.30.109:3100/loki/api/v1/push

scrape_configs:
  - job_name: journal
    journal:
      max_age: 12h
      labels:
        job: systemd-journal
        host: __HOSTNAME__
    relabel_configs:
      - source_labels: ['__journal__systemd_unit']
        target_label: service
      - source_labels: ['__journal__priority_keyword']
        target_label: level

Remplacer __HOSTNAME__ par le nom réel de la machine (ou utiliser la variable shell $(hostname) dans un script de déploiement).

Pour les machines avec Nginx, ajouter un scrape_config supplémentaire :

  - job_name: nginx
    static_configs:
      - targets:
          - localhost
        labels:
          job: nginx
          host: heighliner
          service: nginx
          __path__: /var/log/nginx/*.log
    pipeline_stages:
      - regex:
          expression: '^(?PS+) - S+ [(?P[^]]+)] "(?PS+) (?P[^"]*)" (?Pd{3}) (?Pd+)'
      - labels:
          method:
          status:

L’extraction du code HTTP (status) comme label permet ensuite de filtrer rapidement les erreurs 5xx en LogQL.

Ne mets pas trop de labels. Chaque combinaison unique de labels crée un nouveau stream dans Loki. Sur un homelab, job, host et service suffisent. Ajouter status ou level comme labels peut faire exploser la cardinalité si tes logs sont volumineux.

Service systemd :

cat > /etc/systemd/system/promtail.service << 'EOF'
[Unit]
Description=Promtail Log Shipper
After=network.target

[Service]
User=promtail
ExecStart=/usr/local/bin/promtail -config.file=/etc/promtail/config.yml
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target
EOF

systemctl enable --now promtail
systemctl status promtail

Vérifier que Promtail ingère bien des logs :

curl -s http://localhost:9080/metrics | grep promtail_sent_entries_total
# promtail_sent_entries_total{host="heighliner"} 1234

Un compteur qui monte, c'est bon signe. Si c'est 0 après quelques minutes, le problème est soit la connectivité vers Loki, soit les permissions sur journald.

Déployer sur toutes les machines

Pour éviter de répéter les mêmes commandes à la main, j'utilise un script depuis heighliner qui dépose la configuration et installe Promtail sur chaque LXC :

for CT in 150 160 161 162 163 164 165 166 167 203; do
  echo "=== LXC/VM $CT ==="
  HOSTNAME=$(pct exec $CT -- hostname 2>/dev/null || qm guest exec $CT -- hostname)
  
  # Copier le binaire et la config
  pct exec $CT -- bash -s << SCRIPT
    apt install -y unzip wget 2>/dev/null
    wget -q https://github.com/grafana/loki/releases/download/v3.1.0/promtail-linux-amd64.zip -O /tmp/promtail.zip
    unzip -qo /tmp/promtail.zip -d /tmp/
    install -m 755 /tmp/promtail-linux-amd64 /usr/local/bin/promtail
    useradd --no-create-home --shell /bin/false promtail 2>/dev/null || true
    usermod -aG systemd-journal promtail
    mkdir -p /etc/promtail /var/lib/promtail
    chown promtail:promtail /var/lib/promtail
SCRIPT

  # Générer la config avec le bon hostname
  sed "s/__HOSTNAME__/${HOSTNAME}/" /root/promtail-template.yml | 
    pct push $CT /dev/stdin /etc/promtail/config.yml

  pct exec $CT -- bash -c "
    systemctl enable --now promtail
    systemctl is-active promtail
  "
done

Configurer Loki comme datasource dans Grafana

Dans Grafana (http://10.10.30.121:3000) : Connections > Data sources > Add new > Loki.

  • URL : http://10.10.30.109:3100
  • Authentication : aucune (Loki tourne sans auth sur le réseau interne)
  • Cliquer Save & test - doit afficher "Data source connected and labels found"

LogQL : les requêtes essentielles

LogQL est le langage de Loki. La syntaxe de base :

# Tous les logs d'un hôte
{host="heighliner"}

# Filtrer par mot-clé
{host="heighliner"} |= "error"

# Exclure
{host="heighliner"} != "debug"

# Regex
{host="heighliner"} |~ "error|fatal|panic"

# Filtrer par service
{host="heighliner", service="nginx"}

# Tous les hosts, erreurs SSH
{job="systemd-journal", service="sshd.service"} |= "Failed"

# Tous les logs, erreurs 5xx Nginx (avec label extrait)
{job="nginx", status=~"5.."}

Les fonctions d'agrégation pour les dashboards :

# Taux d'erreurs par hôte (sur 5 minutes)
sum by (host) (rate({job="systemd-journal"} |= "error" [5m]))

# Nombre de connexions SSH échouées par IP
sum by (host) (count_over_time({service="sshd.service"} |= "Failed password" [1h]))

# Taux d'erreurs 5xx Nginx
sum(rate({job="nginx", status=~"5.."} [5m]))

Dans Grafana, l'onglet "Explore" est l'endroit idéal pour tester des requêtes LogQL interactivement avant de les mettre dans un dashboard. Sélectionne la datasource Loki et utilise le builder visuel au départ.

Dashboard Grafana : vue globale

Plutôt que de décrire chaque panel, voici les quatre panels que j'ai construits et qui couvrent 90% de mes besoins de debugging :

Panel 1 - Volume de logs par hôte (Time series)

sum by (host) (rate({job="systemd-journal"} [5m]))

Permet de voir d'un coup d'oeil si un hôte génère anormalement plus de logs que d'habitude - souvent un signe avant-coureur d'un problème.

Panel 2 - Taux d'erreurs SSH (Stat)

sum(count_over_time({service="sshd.service"} |= "Failed password" [1h]))

Un chiffre unique. S'il dépasse 50 tentatives par heure, quelqu'un scanne le port.

Panel 3 - Erreurs 5xx Nginx (Time series)

sum by (host) (rate({job="nginx", status=~"5.."} [5m]))

Corrélé avec les métriques Prometheus de latence, ça permet de distinguer une surcharge d'une erreur applicative.

Panel 4 - Logs bruts récents (Logs panel)

{job="systemd-journal"} |~ "error|fatal|panic" | line_format "{{.host}} | {{.service}} | {{.line}}"

Un flux en temps réel des erreurs sur tout le homelab. Le line_format reformate chaque ligne pour avoir le contexte sans avoir à cliquer sur chaque entrée.

Ce qui a coincé

Le premier écueil que j'ai rencontré concerne les LXC non-privilégiés. Sur Proxmox, les LXC non-privilégiés n'ont pas accès à /run/log/journal par défaut - le répertoire de journald partagé avec l'hôte n'est pas monté dans le namespace du container.

La première tentative avec la configuration journald standard dans Promtail donnait zéro logs ingérés, et aucun message d'erreur explicite dans les logs de Promtail. Le débogage a pris du temps :

# Dans le LXC non-privilégié
ls -la /run/log/journal/
# ls: cannot access '/run/log/journal/': No such file or directory

journalctl --list-boots
# Fonctionne, mais depuis le journal local /var/log/journal/

Solution : activer le stockage persistant des logs journald dans chaque LXC, ce qui crée /var/log/journal/ et permet à Promtail de lire depuis là :

# Dans chaque LXC non-privilégié
mkdir -p /var/log/journal
systemd-tmpfiles --create --prefix /var/log/journal
systemctl restart systemd-journald

Et dans la config Promtail, remplacer la section journal: par un scrape sur les fichiers :

  - job_name: journal-files
    static_configs:
      - targets:
          - localhost
        labels:
          job: systemd-journal
          host: pihole
          __path__: /var/log/journal/**/*.journal
    pipeline_stages:
      - match:
          selector: '{job="systemd-journal"}'
          stages:
            - json:
                expressions:
                  service: SYSLOG_IDENTIFIER
                  level: PRIORITY
            - labels:
                service:
                level:

Le deuxième problème : la cardinalité des labels. Sur Nginx, j'avais initialement extrait l'URL complète comme label (request). Deux jours plus tard, Loki refusait de nouvelles entrées avec l'erreur maximum of series reached. Chaque URL unique = un nouveau stream. Avec des URLs dynamiques (IDs de ressources, tokens), ça explose vite.

Correction : supprimer le label request, garder uniquement method et status. Les URLs restent dans le corps du log, interrogeables via |= ou regex.

Résultat mesuré

Après deux semaines de fonctionnement :

  • 12 hôtes avec Promtail actif
  • Environ 180 000 lignes de logs ingérées par heure en conditions normales
  • Rétention 30 jours sur le NVMe du LXC 109 : 4,2 GB occupés après 14 jours
  • Temps de requête sur {host="heighliner"} |= "error" sur 7 jours : moins d'une seconde

La corrélation logs/métriques dans Grafana change réellement la façon dont on débogue. Quand une alerte Prometheus se déclenche (CPU > 90% sur une VM), je peux directement ouvrir les logs de ce host dans la même vue temporelle sans changer d'outil.

Un exemple concret : une VM Immich avait des pics CPU inexpliqués la nuit. Les métriques montraient la même heure à ±2 minutes. Les logs Loki avec {host="vm-immich"} |= "machine-learning" ont immédiatement montré que la tâche d'indexation ML se lançait à 02h00 et prenait 40 minutes. Pas un problème - juste un comportement attendu que je ne connaissais pas.

Ce que je ferais différemment

Le déploiement de Promtail à la main sur chaque machine n'est pas viable à terme. La prochaine étape est d'intégrer ça dans un rôle Ansible - binaire, config et service en une seule passe, et idempotent si le rôle tourne deux fois.

Pour la configuration Loki en production (si je devais aller plus loin), je regarderais le mode "Simple Scalable Deployment" avec MinIO comme object store plutôt que le filesystem local. Mais pour un homelab à une seule machine, le filesystem NVMe est largement suffisant et évite une dépendance supplémentaire.

Une remarque, un retour ?

Cet article est vivant - corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.

Laisser un commentaire