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.