Centraliser l'observabilité de son Homelab et de ses VPS avec OpenObserve et Vector
Quand on administre un homelab et un ou plusieurs VPS distants — avec un serveur mail en propre, un reverse proxy en bordure, un tunnel ZTNA et une douzaine de conteneurs Docker — la gestion des journaux devient vite un angle mort. Tant que tout fonctionne, personne ne va lire les logs.
Mais le jour où un mail légitime est rejeté en SMTP, où un certificat TLS refuse de se renouveler ou qu'un conteneur renvoie une erreur 502, faire le tour des serveurs en SSH à coup de docker logs et de journalctl montre vite ses limites.
Pendant longtemps, les solutions d'observabilité auto-hébergées se résumaient à deux extrêmes : la stack ELK (Elasticsearch, Logstash, Kibana), redoutable mais capable d'engloutir 4 à 8 Go de RAM juste pour tourner à vide, ou le couple Grafana + Loki + Promtail, plus sobre mais parfois rigide dès qu'on veut effectuer des recherches plein texte rapides ou croiser logs, traces et métriques au même endroit.
C'est exactement là qu'intervient le duo OpenObserve + Vector. Tous deux écrits en Rust, ils changent radicalement la donne :
- OpenObserve remplace à lui seul Elasticsearch, Prometheus, Jaeger et Kibana dans un binaire unique qui consomme une poignée de mégaoctets de RAM. Grâce au stockage colonnaire Parquet et à la compression Zstd, 450 Mo de données brutes ingérées (plus de 820 000 événements) n'occupent qu'environ 41 Mo sur le disque.
- Vector (développé par Datadog mais totalement open-source) est un collecteur ultra-léger capable de lire les sockets Docker, de filtrer le bruit et de transformer les logs à la volée grâce à son langage intégré VRL (Vector Remap Language).
Dans ce guide complet, nous allons monter une architecture d'observabilité centralisée de A à Z : déploiement d'OpenObserve en local avec son collecteur Docker, branchement natif OpenTelemetry (OTLP) d'un serveur mail Stalwart, collecte et décodage des logs d'un VPS distant (Traefik, Pangolin, Gerbil, CrowdSec, Uptime Kuma), et création de tableaux de bord SQL sur mesure.
1. Architecture cible
Notre objectif est de centraliser trois sources distinctes vers une instance unique d'OpenObserve exposée en HTTPS (par exemple sur [https://logs.mondomaine.fr](https://logs.mondomaine.fr)) :
- Les conteneurs locaux : collectés directement via le socket Docker par un agent Vector local vers le flux
docker_logs. - Le serveur mail Stalwart : poussé nativement sans agent tiers grâce au protocole OpenTelemetry (OTLP HTTP) vers le flux
stalwart(logs, traces et métriques). - Le VPS d'entrée (Edge) : collecté par un agent Vector distant qui lit les conteneurs
traefik,pangolin,gerbil,newt,crowdsecetuptime-kuma, nettoie les logs, décode le JSON/Logfmt à la volée et expédie le tout en HTTPS compressé vers le fluxvps_logs.
2. Déploiement d'OpenObserve et de l'agent Vector local
Sur le serveur principal qui hébergera le stockage et l'interface web d'OpenObserve, créez un répertoire dédié :
mkdir -p /opt/openobserve/data
cd /opt/openobserve
Créez le fichier docker-compose.yml suivant. On y inclut directement la variable ZO_COMPACT_DATA_RETENTION_DAYS=30 afin de purger automatiquement les données de plus de 30 jours et de maîtriser l'espace disque sur le long terme :
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
container_name: openobserve
restart: unless-stopped
volumes:
- ./data:/data
environment:
- ZO_ROOT_USER_EMAIL=admin@mondomaine.fr
- ZO_ROOT_USER_PASSWORD=VotreMotDePasseSolide
- ZO_DATA_DIR=/data
- ZO_COMPACT_DATA_RETENTION_DAYS=30
ports:
- "5080:5080"
vector-local:
image: timberio/vector:latest-alpine
container_name: vector-local
restart: unless-stopped
depends_on:
- openobserve
command: ["--config", "/etc/vector/vector.toml"]
volumes:
- ./vector.toml:/etc/vector/vector.toml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
Juste à côté, créez le fichier vector.toml pour collecter les logs de tous les conteneurs de cette machine locale (en prenant soin d'exclure vector-local et openobserve pour éviter une boucle infinie où Vector collecte ses propres logs d'envoi) :
[sources.docker_local]
type = "docker_logs"
exclude_containers = ["vector-local", "openobserve"]
[transforms.clean_local]
type = "remap"
inputs = ["docker_local"]
source = '''
if exists(.container_name) {
.container_name = replace(string!(.container_name), r'^/', "")
}
del(.source_type)
'''
[sinks.openobserve_local]
type = "http"
inputs = ["clean_local"]
uri = "http://openobserve:5080/api/default/docker_logs/_json"
method = "post"
encoding.codec = "json"
framing.method = "newline_delimited"
auth.strategy = "basic"
auth.user = "admin@mondomaine.fr"
auth.password = "VotreMotDePasseSolide"
compression = "gzip"
Lancez la pile avec docker compose up -d et exposez le port 5080 derrière votre reverse proxy habituel en HTTPS (ex. [https://logs.mondomaine.fr](https://logs.mondomaine.fr)).
3. Intégration native de Stalwart Mail Server via OpenTelemetry (OTLP)
L'un des grands atouts de Stalwart Mail Server est son support natif d'OpenTelemetry : nul besoin de parser des fichiers texte avec des expressions régulières complexes, Stalwart sait exporter directement ses traces, ses événements structurés (accountname, remoteip, listenerid, elapsed, details) et ses ~170 métriques internes en HTTP.
Rendez-vous dans l'interface d'administration de Stalwart : Settings -> Telemetry -> Tracers (et répétez l'opération dans Metrics si vous souhaitez également les compteurs de performance) :
- Transport :
HTTP(OpenTelemetry) - URL :
[https://logs.mondomaine.fr/api/default](https://logs.mondomaine.fr/api/default)(Attention : ne mettez pas/v1/logsou/v1/tracesà la fin, Stalwart l'ajoute lui-même). - Authentication :
Basic Authenticationavec votre identifiantadmin@mondomaine.fret le mot de passe administrateur OpenObserve (ou passez surNonesi vous utilisez le jeton Base64 dans l'en-têteAuthorizationgénéré depuis le menu Ingestion d'OpenObserve).
Le piège classique des en-têtes HTTP dans Stalwart
Pour indiquer à OpenObserve dans quel flux ranger les données de Stalwart, il faut ajouter l'en-tête personnalisé stream-name.
Dans l'interface Web de Stalwart, la section Headers fonctionne comme un dictionnaire Clé / Valeur séparés:
- Dans le champ du bas
Key..., tapez uniquementstream-name(sans deux-points ni valeur) puis cliquez sur+ Add. - Dans le champ de valeur qui s'ouvre sous le titre
stream-name, tapez uniquementstalwart. - Sauvegardez et rechargez Stalwart (Reload).
Tester la connectivité et l'authentification en CLI
Si aucun flux n'apparaît après une minute, vérifiez depuis le terminal du serveur mail que la requête traverse bien votre proxy et que vos identifiants sont acceptés :
curl -i -X POST "https://logs.mondomaine.fr/api/default/stalwart_test/_json" \
-u "admin@mondomaine.fr:VotreMotDePasseSolide" \
-H "Content-Type: application/json" \
-d '[{"message": "test connexion depuis stalwart"}]'
Un retour HTTP/2 200 accompagné de l'en-tête x-api-node confirme que la liaison et l'authentification sont opérationnelles.
(Note : Si vous activez l'export des Metrics de Stalwart en plus du Tracer, vous verrez le compteur global de Streams sur la page d'accueil d'OpenObserve grimper autour de 170 streams. C'est tout à fait normal : dans OpenObserve, chaque indicateur OpenTelemetry constitue un stream de métrique individuel, mais l'ensemble ne pèse que quelques mégaoctets grâce à la compression Parquet).
4. Exporter et parser les logs d'un VPS distant (Traefik, Pangolin, CrowdSec, Uptime Kuma)
Passons maintenant au VPS exposé sur Internet qui héberge la brique ZTNA et sécurité (Pangolin, Gerbil, Newt, Traefik, CrowdSec et Uptime Kuma).
Étape 4.1 : Activer les Access Logs JSON dans Traefik
Par défaut, si le bloc accessLog n'est pas déclaré dans la configuration statique de Traefik (traefik.yml), ce dernier n'émet aucun journal lors des requêtes HTTP. De plus, le format common est moins riche à exploiter que le JSON.
Modifiez les sections log et accessLog de votre fichier traefik.yml pour émettre directement en JSON sur la sortie standard Docker :
log:
level: INFO
format: json
maxSize: 100
maxBackups: 3
maxAge: 3
compress: true
accessLog:
format: json
bufferingSize: 0
fields:
defaultMode: keep
headers:
defaultMode: drop
names:
User-Agent: keep
X-Forwarded-For: keep
Cf-Connecting-Ip: keep
Redémarrez Traefik (docker restart traefik). Chaque requête génère désormais un objet JSON complet contenant clienthost, downstreamstatus, requestpath, duration et entrypointname.
Étape 4.2 : Configurer Vector et le pipeline VRL sur le VPS
Sur le VPS distant, déployez uniquement un conteneur vector-agent :
services:
vector-agent:
image: timberio/vector:latest-alpine
container_name: vector-agent
restart: unless-stopped
command: ["--config", "/etc/vector/vector.toml"]
volumes:
- ./vector.toml:/etc/vector/vector.toml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
Le cœur de l'intelligence réside dans le fichier vector.toml du VPS. Nous devons :
- Nettoyer le
/initial que Docker ajoute parfois devantcontainer_name. - Filtrer les requêtes
/v1/heartbeatque l'agent CrowdSec génère toutes les minutes sur127.0.0.1pour éviter de polluer les graphiques. - Décoder automatiquement le JSON (Traefik, Pangolin) et le format Clé-Valeur / Logfmt (CrowdSec) tout en respectant le typage strict du compilateur VRL (qui exige
., _ = merge(...)sur une cible générique mais. = merge(...)sur les objets typés). - Envoyer le flux vers l'endpoint JSON d'OpenObserve (
/api/default/vps_logs/_json) et non vers/v1/logs(qui est réservé au protocole binaire/OTLP et renverrait une erreur400 Bad Request).
Voici le fichier vector.toml complet et validé sans le moindre avertissement VRL :
# 1. Source : Capture de tous les conteneurs du VPS
[sources.docker_vps]
type = "docker_logs"
exclude_containers = ["vector", "vector-agent"]
# 2. Filtrage : Exclusion du bruit interne (heartbeat CrowdSec chaque minute)
[transforms.filter_noise]
type = "filter"
inputs = ["docker_vps"]
condition = '!contains(string!(.message), "/v1/heartbeat")'
# 3. Transformation VRL : Nettoyage, parsing multi-format et normalisation
[transforms.parse_vps]
type = "remap"
inputs = ["filter_noise"]
source = '''
# Retirer le slash éventuel devant le nom du conteneur (/traefik -> traefik)
if exists(.container_name) {
.container_name = replace(string!(.container_name), r'^/', "")
}
msg_str = strip_whitespace(string!(.message))
# Essai 1 : Décodage JSON (Traefik Access Logs, Pangolin, Uptime Kuma)
parsed_json, err_json = parse_json(msg_str)
if err_json == null && is_object(parsed_json) {
., _ = merge(., parsed_json)
} else {
# Essai 2 : Décodage Key-Value / Logfmt (CrowdSec : time="..." level=info msg="...")
parsed_kv, err_kv = parse_key_value(msg_str, key_value_delimiter: "=", field_delimiter: " ")
if err_kv == null && is_object(parsed_kv) {
. = merge(., parsed_kv)
} else {
# Essai 3 : Format Apache Common Log en repli
parsed_clf, err_clf = parse_common_log(msg_str)
if err_clf == null {
. = merge(., parsed_clf)
}
}
}
# Normalisation du niveau de sévérité (level) y compris selon le code HTTP Traefik
if exists(.level) {
.level = upcase(string!(.level))
} else if exists(.downstreamstatus) {
status = to_int(.downstreamstatus) ?? 200
if status >= 500 {
.level = "ERROR"
} else if status >= 400 {
.level = "WARN"
} else {
.level = "INFO"
}
} else if exists(.DownstreamStatus) {
status = to_int(.DownstreamStatus) ?? 200
if status >= 500 {
.level = "ERROR"
} else if status >= 400 {
.level = "WARN"
} else {
.level = "INFO"
}
} else if contains(downcase(msg_str), "error") || contains(downcase(msg_str), "err") {
.level = "ERROR"
} else if contains(downcase(msg_str), "warn") {
.level = "WARN"
} else {
.level = "INFO"
}
del(.source_type)
'''
# 4. Sink : Expédition compressée vers OpenObserve
[sinks.openobserve_remote]
type = "http"
inputs = ["parse_vps"]
uri = "https://logs.mondomaine.fr/api/default/vps_logs/_json"
method = "post"
encoding.codec = "json"
framing.method = "newline_delimited"
auth.strategy = "basic"
auth.user = "admin@mondomaine.fr"
auth.password = "VotreMotDePasseSolide"
compression = "gzip"
5. Concevoir des Tableaux de bord efficaces dans OpenObserve v5
Une fois les flux stalwart, docker_logs et vps_logs alimentés, la création des tableaux de bord dans les versions récentes d'OpenObserve (schéma version: 5) repose sur trois bonnes pratiques essentielles découvertes sur le terrain :
- Toujours déclarer
dashboardIdà la racine lors d'un import JSON : Sans la clé"dashboardId", l'importateur v5 rejette le fichier. - Privilégier
"customQuery": falseavec le mappage explicite des alias (x_axis_1,y_axis_1) : En modecustomQuery: truesans déclaration dans l'objetfields.xetfields.y, OpenObserve exécute bien la requête SQL en arrière-plan (le compteur en bas du tableau indique par exemple1-39 sur 39), mais l'interface laisse le graphique ou la table vide car elle ne sait pas quelle colonne associer à quel axe. - Filtrer les tâches périodiques internes :
- Pour Traefik (dans
vps_logs) : exploitez directement les colonnes minuscules créées par l'aplatissement JSON d'OpenObserve (clienthost,downstreamstatus,duration,entrypointname,host) pour isoler les erreurs HTTP 4xx/5xx ou lister les visiteurs en direct.
- Pour Traefik (dans
Pour Stalwart : excluez les tâches planifiées internes (otelMetrics, calculateMetrics, alertMetrics, internalMetrics, trainSpamClassifier, purgeAccount, purgeDataStore, purgeBlobStore) dans vos tableaux en temps réel afin de ne conserver que les vraies transactions SMTP, IMAP, JMAP et Sieve:
SELECT _timestamp as "x_axis_1", severity as "x_axis_2", name as "x_axis_3", listenerid as "x_axis_4", accountname as "x_axis_5", remoteip as "x_axis_6", body as "x_axis_7"
FROM "stalwart"
WHERE name NOT IN ('otelMetrics', 'calculateMetrics', 'alertMetrics', 'internalMetrics', 'trainSpamClassifier', 'purgeAccount', 'purgeDataStore', 'purgeBlobStore')
AND body != 'Metrics collected'
ORDER BY _timestamp DESC
LIMIT 100
Conclusion
En combinant OpenObserve et Vector, on obtient une plateforme d'observabilité moderne, souveraine et redoutablement rapide, sans sacrifier les ressources matérielles de son homelab ou de ses VPS.
Là où une pile traditionnelle réclamerait plusieurs gigaoctets de mémoire vive et une maintenance constante, ce duo en Rust se contente de quelques dizaines de mégaoctets tout en offrant la puissance du SQL pour fouiller dans des centaines de milliers d'événements en quelques millisecondes.
Que ce soit pour diagnostiquer un rejet SPF/DKIM sur Stalwart, vérifier un callback OIDC sur Pangolin, contrôler les décisions de bannissement de CrowdSec ou surveiller les temps de réponse de Traefik, toute l'infrastructure est désormais visible en un coup d'œil sur une interface unique.
La suite logique ?
Brancher le système d'alertes natif d'OpenObserve sur un Webhook (Discord, Gotify ou ntfy) pour être prévenu en temps réel dès qu'un conteneur passe en erreur critique ou qu'une attaque est bloquée !