fr

Stack d'observabilité Docker : OTel Collector + ClickHouse + Prometheus + Grafana

Setup complet d'une stack d'observabilité Docker avec OpenTelemetry Collector, ClickHouse pour les logs, Prometheus pour les métriques, et Grafana pour la visualisation. Philosophie, configuration, docker-compose.

Cet article fait suite à Gestion des logs Docker : enjeux, solutions et tendances — Guide complet. L’article précédent couvrait le panorama complet des solutions ; celui-ci détaille le setup concret d’une stack choisie.


Le choix s’est porté sur OTel Collector + ClickHouse + Prometheus + Grafana. Pourquoi cette combinaison :

  • ClickHouse gère la haute cardinalité nativement (filtrer par IP, par requête, par user agent — pas de problème)
  • SQL pour requêter les logs (pas de nouveau langage à apprendre)
  • Compression 15-20x (stockage très économique)
  • OpenTelemetry est le standard CNCF, vendor-neutral — on peut changer de backend sans tout refaire
  • Prometheus est le standard de facto pour les métriques conteneur (67% d’adoption en production)
  • Grafana unifie logs et métriques dans une seule interface

Voici l’architecture complète :

┌──────────────────────────────────────────────────────────────────┐
│                        Hôte Docker                               │
│                                                                  │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐         ┌─────────┐       │
│  │ Astro 1 │ │ Astro 2 │ │ Astro N │   ...   │  Caddy  │       │
│  │ (nginx) │ │ (nginx) │ │ (nginx) │         │ (proxy) │       │
│  └────┬────┘ └────┬────┘ └────┬────┘         └────┬────┘       │
│       │            │           │                   │             │
│       └────────────┴───────┬───┴───────────────────┘             │
│                            │ STDOUT/STDERR → JSON files          │
│                            ▼                                     │
│              /var/lib/docker/containers/*/*.log                   │
│                            │                                     │
│                            ▼                                     │
│                 ┌─────────────────────┐                          │
│                 │   OTel Collector    │                          │
│                 │    (contrib)        │                          │
│                 │                     │                          │
│                 │  filelog receiver   │──── parse, filter,       │
│                 │  OTLP receiver     │     enrich, batch        │
│                 └──────┬──────┬──────┘                          │
│                        │      │                                  │
│              logs ─────┘      └───── metrics                     │
│                │                      │                          │
│                ▼                      ▼                          │
│         ┌────────────┐      ┌──────────────┐   ┌──────────┐    │
│         │ ClickHouse │      │  Prometheus  │◄──│ cAdvisor │    │
│         │  (logs)    │      │  (métriques) │   │          │    │
│         └──────┬─────┘      └──────┬───────┘   └──────────┘    │
│                │                   │                             │
│                └─────────┬─────────┘                             │
│                          ▼                                       │
│                    ┌──────────┐                                  │
│                    │ Grafana  │ :3000                            │
│                    └──────────┘                                  │
└──────────────────────────────────────────────────────────────────┘

1. OpenTelemetry Collector — le routeur universel

Philosophie

L’OpenTelemetry Collector est un pipeline de données télémétriques vendor-neutral. Il reçoit des logs, métriques et traces depuis n’importe quelle source, les transforme, et les exporte vers n’importe quel backend. C’est le deuxième projet le plus actif de la CNCF après Kubernetes.

Le principe : découpler la collecte du stockage. Si demain on veut remplacer ClickHouse par autre chose, on change l’exporter — pas le reste de l’infrastructure.

Core vs Contrib

Il existe deux distributions :

DistributionImage DockerContenu
Coreotel/opentelemetry-collectorComposants de base uniquement
Contribotel/opentelemetry-collector-contribTous les composants communautaires

Pour cette stack, il faut Contrib — c’est la seule qui inclut le ClickHouse exporter, le filelog receiver, le Docker stats receiver, et le resource detection processor.

docker pull otel/opentelemetry-collector-contrib:0.117.0

Source : OTel Collector Docker Install

Architecture : Receivers → Processors → Exporters

La configuration a quatre sections qui se câblent dans service.pipelines :

receivers: # Comment les données entrent
processors: # Comment elles sont transformées
exporters: # Où elles sont envoyées
service:
  pipelines: # Ce qui relie le tout

Principe fondamental : configurer un receiver ou un exporter ne l’active pas. Il doit être référencé dans un pipeline sous service pour être actif. Source : OTel Collector Configuration

Comment collecter les logs Docker

Trois approches existent. Voici le comparatif, puis le détail de l’approche recommandée.

AspectFilelog receiverFluentd driverOTLP direct
docker logs fonctionneOuiNonNon
Modification de daemon.jsonNonOuiNon
Composant supplémentaireNonOptionnel (Fluent Bit)Non
BackpressureFilesystem (naturel)Mémoire (risque de perte)gRPC (backpressure native)
Métadonnées conteneurVia Docker APIVia tag templateNatif (SDK)
Applicable aux sites statiquesOuiOuiNon (pas d’app à instrumenter)

Recommandation : filelog receiver (Option A). Pas de changement de configuration Docker, docker logs continue de fonctionner, le filesystem sert de buffer naturel.

Filelog receiver : configuration

Le filelog receiver lit les fichiers de log JSON de Docker situés dans /var/lib/docker/containers/<container-id>/<container-id>-json.log. Chaque ligne a ce format :

{
  "log": "192.168.1.1 - - [03/Mar/2026] \"GET / HTTP/1.1\" 200 1234\n",
  "stream": "stdout",
  "time": "2026-03-03T10:15:30.123456789Z"
}

L’opérateur container de type docker parse automatiquement ce format — il remplace environ 69 lignes de configuration manuelle par 5 lignes :

receivers:
  filelog:
    include:
      - /var/lib/docker/containers/*/*-json.log
    exclude:
      # Exclure les logs du collector lui-même pour éviter les boucles
      - /var/lib/docker/containers/*otel*/*-json.log
    include_file_path: true
    start_at: end # Ne pas relire l'historique au démarrage
    operators:
      - id: container-parser
        type: container
        format: docker

Les volumes à monter :

volumes:
  - /var/lib/docker/containers:/var/lib/docker/containers:ro
  - /var/run/docker.sock:/var/run/docker.sock:ro

[Sources : OTel Container Log Parser, Filelog Receiver README]

Processors : l’ordre compte

L’ordre recommandé dans le pipeline : memory_limiter (premier) → resourcedetection → transform → filter → batch (dernier).

memory_limiter — empêche les OOM en surveillant la consommation mémoire. Doit être le premier processor de chaque pipeline :

processors:
  memory_limiter:
    check_interval: 5s
    limit_mib: 1500 # Limite dure
    spike_limit_mib: 300 # Limite souple = limit - spike

Source : Memory Limiter Processor

resourcedetection — détecte automatiquement les métadonnées de l’hôte et du conteneur :

processors:
  resourcedetection:
    detectors: [env, docker, system]
    timeout: 2s

Le détecteur env lit la variable OTEL_RESOURCE_ATTRIBUTES (utile pour injecter deployment.environment=production). Le détecteur docker ajoute les métadonnées du conteneur du collector. Le détecteur system ajoute host.name, os.type. Source : Resource Detection Processor

transform — utilise OTTL (OpenTelemetry Transformation Language) pour extraire les champs des access logs JSON de Caddy :

processors:
  transform/access-logs:
    error_mode: ignore
    log_statements:
      - context: log
        statements:
          # Parser le body JSON (les access logs de Caddy sont du JSON)
          - set(body, ParseJSON(body)) where IsString(body)
          # Extraire les champs utiles en attributs
          - set(attributes["http.method"], body["request"]["method"])
            where body["request"]["method"] != nil
          - set(attributes["http.status_code"], body["status"])
            where body["status"] != nil
          - set(attributes["http.host"], body["request"]["host"])
            where body["request"]["host"] != nil
          - set(attributes["http.path"], body["request"]["uri"])
            where body["request"]["uri"] != nil
          - set(attributes["net.peer.ip"], body["request"]["remote_ip"])
            where body["request"]["remote_ip"] != nil
          - set(attributes["http.duration_s"], body["duration"])
            where body["duration"] != nil
          # Assigner la sévérité selon le status code
          - set(severity_number, SEVERITY_NUMBER_INFO)
            where body["status"] != nil and body["status"] < 400
          - set(severity_number, SEVERITY_NUMBER_WARN)
            where body["status"] != nil and body["status"] >= 400
            and body["status"] < 500
          - set(severity_number, SEVERITY_NUMBER_ERROR)
            where body["status"] != nil and body["status"] >= 500

filter — supprime les logs à faible valeur. Pour des sites statiques, 80-90% des logs sont du bruit (assets, health checks) :

processors:
  filter/drop-noise:
    error_mode: ignore
    logs:
      log_record:
        # Assets statiques
        - 'IsMatch(attributes["http.path"], ".*\\.(css|js|png|jpg|jpeg|gif|svg|ico|woff2?)$")'
        # Health checks
        - 'attributes["http.path"] == "/health"'
        - 'attributes["http.path"] == "/healthz"'
        # 304 Not Modified (le navigateur a le cache)
        - 'attributes["http.status_code"] == 304'

Source : Filter Processor

batch — regroupe les données avant export. ClickHouse recommande des inserts d’au moins 1 000 lignes. Doit être le dernier processor :

processors:
  batch:
    send_batch_size: 5000
    timeout: 5s

Source : Batch Processor

Exporter ClickHouse

exporters:
  clickhouse:
    endpoint: tcp://clickhouse:9000?dial_timeout=10s&compress=lz4&async_insert=1
    database: otel
    username: default
    password: "${CLICKHOUSE_PASSWORD}"
    logs_table_name: otel_logs
    traces_table_name: otel_traces
    ttl: 720h # 30 jours
    create_schema: true # Créer automatiquement les tables
    sending_queue:
      enabled: true
      num_consumers: 10
      queue_size: 1000
    retry_on_failure:
      enabled: true
      initial_interval: 5s
      max_interval: 30s
      max_elapsed_time: 300s

Les paramètres clés :

  • async_insert=1 : ClickHouse bufferise les insertions avant de les flusher — réduit la charge
  • compress=lz4 : compression sur le réseau entre le collector et ClickHouse
  • ttl: 720h : rétention de 30 jours (modifiable aussi côté ClickHouse)
  • create_schema: true : crée automatiquement la base de données et les tables au premier démarrage

Source : ClickHouse Exporter README

Exporter Prometheus

Le collector expose un endpoint /metrics que Prometheus viendra scraper (modèle pull) :

exporters:
  prometheus:
    endpoint: 0.0.0.0:8889
    resource_to_telemetry_conversion:
      enabled: true

resource_to_telemetry_conversion convertit les attributs de resource en labels Prometheus — utile pour avoir service_name, host_name etc. comme labels. Source : Prometheus and OTel: Better Together

Configuration complète du collector

extensions:
  health_check:
    endpoint: 0.0.0.0:13133

receivers:
  # Lecture des fichiers de log Docker
  filelog:
    include:
      - /var/lib/docker/containers/*/*-json.log
    exclude:
      - /var/lib/docker/containers/*otel*/*-json.log
    include_file_path: true
    start_at: end
    operators:
      - id: container-parser
        type: container
        format: docker

  # Réception OTLP (pour les apps instrumentées)
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  memory_limiter:
    check_interval: 5s
    limit_mib: 1500
    spike_limit_mib: 300

  resourcedetection:
    detectors: [env, docker, system]
    timeout: 2s

  transform/access-logs:
    error_mode: ignore
    log_statements:
      - context: log
        statements:
          - set(body, ParseJSON(body)) where IsString(body)
          - set(attributes["http.method"], body["request"]["method"])
            where body["request"]["method"] != nil
          - set(attributes["http.status_code"], body["status"])
            where body["status"] != nil
          - set(attributes["http.host"], body["request"]["host"])
            where body["request"]["host"] != nil
          - set(attributes["http.path"], body["request"]["uri"])
            where body["request"]["uri"] != nil
          - set(attributes["net.peer.ip"], body["request"]["remote_ip"])
            where body["request"]["remote_ip"] != nil
          - set(attributes["http.duration_s"], body["duration"])
            where body["duration"] != nil
          - set(severity_number, SEVERITY_NUMBER_INFO)
            where body["status"] != nil and body["status"] < 400
          - set(severity_number, SEVERITY_NUMBER_WARN)
            where body["status"] != nil and body["status"] >= 400
            and body["status"] < 500
          - set(severity_number, SEVERITY_NUMBER_ERROR)
            where body["status"] != nil and body["status"] >= 500

  filter/drop-noise:
    error_mode: ignore
    logs:
      log_record:
        - 'IsMatch(attributes["http.path"], ".*\\.(css|js|png|jpg|jpeg|gif|svg|ico|woff2?)$")'
        - 'attributes["http.path"] == "/health"'
        - 'attributes["http.path"] == "/healthz"'
        - 'attributes["http.status_code"] == 304'

  batch:
    send_batch_size: 5000
    timeout: 5s

exporters:
  clickhouse:
    endpoint: tcp://clickhouse:9000?dial_timeout=10s&compress=lz4&async_insert=1
    database: otel
    username: default
    password: "${CLICKHOUSE_PASSWORD}"
    ttl: 720h
    create_schema: true
    sending_queue:
      enabled: true
      num_consumers: 10
      queue_size: 1000
    retry_on_failure:
      enabled: true
      initial_interval: 5s
      max_interval: 30s
      max_elapsed_time: 300s

  prometheus:
    endpoint: 0.0.0.0:8889
    resource_to_telemetry_conversion:
      enabled: true

service:
  extensions: [health_check]
  pipelines:
    logs:
      receivers: [filelog, otlp]
      processors:
        [
          memory_limiter,
          resourcedetection,
          transform/access-logs,
          filter/drop-noise,
          batch,
        ]
      exporters: [clickhouse]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [prometheus]
    traces:
      receivers: [otlp]
      processors: [memory_limiter, resourcedetection, batch]
      exporters: [clickhouse]

2. ClickHouse — le moteur de stockage des logs

Philosophie

ClickHouse est une base de données OLAP columnaire écrite en C++. Contrairement à Elasticsearch qui indexe chaque mot de chaque document (index inversé), ClickHouse stocke les données par colonne et les compresse. Résultat : des requêtes analytiques 5-30x plus rapides qu’Elasticsearch, avec une compression 15-20x. Source : ClickHouse vs Elasticsearch: The Billion-Row Matchup

Pour les logs, ça signifie qu’on peut filtrer par IP (haute cardinalité), par plage horaire, par service — le tout en SQL standard avec des temps de réponse sub-seconde.

Image Docker et ports

docker pull clickhouse/clickhouse-server:24.12
PortProtocoleUsage
8123HTTPInterface REST, requêtes via curl
9000TCP natifClient natif (clickhouse-client), connexion Grafana
9009InterserverRéplication multi-nœud (pas nécessaire en single-node)

Source : ClickHouse Docker Install

Schéma auto-créé par l’exporter OTel

Quand create_schema: true, le ClickHouse exporter crée automatiquement les tables. Voici la table otel_logs :

CREATE TABLE otel.otel_logs
(
    Timestamp          DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    TraceId            String CODEC(ZSTD(1)),
    SpanId             String CODEC(ZSTD(1)),
    TraceFlags         UInt32 CODEC(ZSTD(1)),
    SeverityText       LowCardinality(String) CODEC(ZSTD(1)),
    SeverityNumber     Int32 CODEC(ZSTD(1)),
    ServiceName        LowCardinality(String) CODEC(ZSTD(1)),
    Body               String CODEC(ZSTD(1)),
    ResourceAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    ScopeAttributes    Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    LogAttributes      Map(LowCardinality(String), String) CODEC(ZSTD(1)),

    -- Index bloom filter pour recherche rapide par TraceId
    INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
    -- Index pour recherche dans les clés des attributs
    INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    -- Index tokenisé pour recherche dans le Body
    INDEX idx_body Body TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SeverityText, toUnixTimestamp(Timestamp), TraceId)
TTL toDateTime(Timestamp) + toIntervalDay(30)
SETTINGS ttl_only_drop_parts = 1;

Points clés du schéma :

  • PARTITION BY toDate(Timestamp) : une partition par jour. Quand le TTL expire, ClickHouse supprime des partitions entières (très efficace) plutôt que des lignes individuelles.
  • ORDER BY (ServiceName, SeverityText, toUnixTimestamp(Timestamp), TraceId) : suit le principe de cardinalité croissante — le champ le plus sélectif (ServiceName) en premier. Cet ORDER BY optimise les requêtes qui filtrent par service puis par temps.
  • LowCardinality(String) : optimisation pour les colonnes avec peu de valeurs distinctes (noms de service, niveaux de sévérité). Gains de stockage significatifs.
  • Map(LowCardinality(String), String) : stockage flexible clé-valeur pour les attributs — évite l’explosion de colonnes. C’est là que les champs extraits par le transform processor (http.method, http.host, net.peer.ip…) sont stockés.
  • CODEC(Delta(8), ZSTD(1)) : Delta encoding sur les timestamps pour une compression additionnelle. ZSTD(1) sur toutes les autres colonnes (bon ratio compression/vitesse).

L’exporter crée aussi otel_traces (même structure avec SpanName, Duration, StatusCode, Events, Links) et les tables de métriques (otel_metrics_gauge, otel_metrics_sum, otel_metrics_histogram, etc.).

[Sources : ClickHouse OTel Integration, Storing Log Data in ClickHouse]

TTL et rétention

Le TTL peut être configuré soit dans l’exporter OTel (ttl: 720h), soit directement dans la DDL ClickHouse pour plus de granularité :

-- TTL simple : supprimer après 30 jours
ALTER TABLE otel.otel_logs
  MODIFY TTL toDateTime(Timestamp) + INTERVAL 30 DAY DELETE;

-- TTL par sévérité : rétention différenciée
ALTER TABLE otel.otel_logs
  MODIFY TTL
    toDateTime(Timestamp) + INTERVAL 7 DAY DELETE
      WHERE SeverityText IN ('TRACE', 'DEBUG'),
    toDateTime(Timestamp) + INTERVAL 30 DAY DELETE
      WHERE SeverityText = 'INFO',
    toDateTime(Timestamp) + INTERVAL 365 DAY DELETE
      WHERE SeverityText IN ('WARN', 'ERROR');

Le setting ttl_only_drop_parts = 1 supprime les parts entières quand elles expirent plutôt que de réécrire les données — bien plus efficace. Source : ClickHouse TTL

Requêtes SQL utiles

Logs par plage horaire (que s’est-il passé à 14h05 ?) :

SELECT
    Timestamp,
    SeverityText,
    ServiceName,
    Body,
    LogAttributes['http.status_code'] AS status,
    LogAttributes['http.host'] AS host
FROM otel.otel_logs
WHERE Timestamp BETWEEN '2026-03-03 14:05:00' AND '2026-03-03 14:06:00'
ORDER BY Timestamp
LIMIT 100;

Suivre un utilisateur par IP :

SELECT
    Timestamp,
    ServiceName,
    LogAttributes['http.method'] AS method,
    LogAttributes['http.path'] AS path,
    LogAttributes['http.status_code'] AS status,
    LogAttributes['http.host'] AS host,
    LogAttributes['http.duration_s'] AS duration
FROM otel.otel_logs
WHERE LogAttributes['net.peer.ip'] = '192.168.1.42'
  AND Timestamp > now() - INTERVAL 1 HOUR
ORDER BY Timestamp;

Logs d’un service spécifique :

SELECT Timestamp, SeverityText, Body
FROM otel.otel_logs
WHERE ServiceName = 'caddy'
  AND SeverityText IN ('WARN', 'ERROR')
  AND Timestamp > now() - INTERVAL 24 HOUR
ORDER BY Timestamp DESC
LIMIT 50;

Top 10 des erreurs :

SELECT
    LogAttributes['http.host'] AS site,
    LogAttributes['http.path'] AS path,
    LogAttributes['http.status_code'] AS status,
    count() AS occurrences
FROM otel.otel_logs
WHERE SeverityText = 'ERROR'
  AND Timestamp > now() - INTERVAL 24 HOUR
GROUP BY site, path, status
ORDER BY occurrences DESC
LIMIT 10;

Volume de logs par service (pour identifier les conteneurs bruyants) :

SELECT
    ServiceName,
    formatReadableSize(sum(length(Body))) AS volume,
    count() AS lines
FROM otel.otel_logs
WHERE Timestamp > now() - INTERVAL 1 HOUR
GROUP BY ServiceName
ORDER BY lines DESC;

Dimensionnement single-node

ChargeCPURAMDisque
Dev/staging2 cores4 Go20 Go SSD
Production (~200 conteneurs)4 cores8-16 GoSSD, taille selon rétention
Production haute charge8+ cores32+ GoNVMe SSD

Le file descriptor limit est essentiel — ClickHouse ouvre des milliers de fichiers simultanément pendant les requêtes. Configurer ulimits.nofile: 262144.


3. Prometheus — les métriques conteneur

Philosophie

Prometheus est un système de monitoring et d’alerting basé sur un modèle pull : il scrape des endpoints HTTP /metrics à intervalles réguliers pour collecter des métriques numériques (time-series). C’est le premier projet CNCF a avoir atteint le statut Graduated (2018, avant même Fluentd).

Prometheus n’est pas un outil de logs. Il ne stocke que des métriques numériques. Mais combiné à ClickHouse (logs) et Grafana (visualisation), il couvre le pilier métriques de l’observabilité : CPU, mémoire, réseau, taux de requêtes, latence.

prometheus.yml

global:
  scrape_interval: 15s # Intervalle par défaut
  evaluation_interval: 15s # Évaluation des alertes

scrape_configs:
  # Métriques exposées par l'OTel Collector
  - job_name: otel-collector
    scrape_interval: 10s
    static_configs:
      - targets: ["otel-collector:8889"]

  # Métriques conteneur via cAdvisor
  - job_name: cadvisor
    scrape_interval: 15s
    static_configs:
      - targets: ["cadvisor:8080"]

  # Prometheus scrape lui-même
  - job_name: prometheus
    static_configs:
      - targets: ["localhost:9090"]

cAdvisor : métriques conteneur

cAdvisor (Container Advisor) est un agent Google qui expose les métriques de ressources de chaque conteneur Docker. C’est la source standard pour les métriques conteneur dans l’écosystème Prometheus. Source : Prometheus — Monitoring Docker with cAdvisor

Métriques clés pour 200+ sites statiques :

MétriqueDescription
container_cpu_usage_seconds_totalUtilisation CPU cumulée
container_memory_working_set_bytesMémoire effective (meilleur indicateur d’OOM)
container_memory_cacheCache de pages (important pour du file serving)
container_network_receive_bytes_totalTrafic réseau entrant
container_network_transmit_bytes_totalTrafic réseau sortant
container_start_time_secondsTimestamp de démarrage (détecter les restarts)

Docker daemon metrics (optionnel)

Pour exposer les métriques du démon Docker lui-même, ajouter dans /etc/docker/daemon.json :

{
  "metrics-addr": "0.0.0.0:9323"
}

Puis redémarrer Docker. Vérifier avec curl localhost:9323/metrics. Ajouter dans prometheus.yml :

- job_name: docker-daemon
  static_configs:
    - targets: ["host.docker.internal:9323"]

Source : Docker Daemon Prometheus Metrics

Rétention et stockage

La rétention se configure via des flags de ligne de commande (pas dans le fichier de config) :

command:
  - "--config.file=/etc/prometheus/prometheus.yml"
  - "--storage.tsdb.path=/prometheus"
  - "--storage.tsdb.retention.time=15d" # Garder 15 jours
  - "--storage.tsdb.retention.size=10GB" # Ou max 10 Go
  - "--web.enable-lifecycle" # API de reload

Si les deux limites (temps et taille) sont configurées, c’est la première atteinte qui déclenche la suppression. Source : Prometheus Storage

Requêtes PromQL utiles

CPU par conteneur (top 10) :

topk(10,
  rate(container_cpu_usage_seconds_total{name!=""}[5m])
)

Mémoire par conteneur :

container_memory_working_set_bytes{name!=""} / 1024 / 1024

Conteneurs qui ont redémarré (dans les dernières 24h) :

changes(container_start_time_seconds{name!=""}[24h]) > 0

Trafic réseau par conteneur (Mo/s) :

rate(container_network_receive_bytes_total{name!=""}[5m]) / 1024 / 1024

4. Grafana — la visualisation unifiée

Philosophie

Grafana est un dashboard universel d’observabilité qui sait parler à 100+ sources de données. Dans cette stack, il unifie les logs (ClickHouse) et les métriques (Prometheus) dans une seule interface.

Plugin ClickHouse

Le plugin grafana-clickhouse-datasource (par Grafana Labs) connecte Grafana à ClickHouse. Il supporte le mapping OTel pour naviguer dans otel_logs et otel_traces avec des fonctionnalités d’exploration intégrées.

Installation via variable d’environnement :

environment:
  - GF_INSTALL_PLUGINS=grafana-clickhouse-datasource

[Sources : Grafana ClickHouse Plugin, ClickHouse Grafana Docs]

Provisioning automatique

Au lieu de configurer manuellement via l’interface web, on provisionne les data sources et dashboards via des fichiers YAML montés dans le conteneur.

grafana/provisioning/datasources/datasources.yaml :

apiVersion: 1

datasources:
  # Prometheus — métriques conteneur
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
    editable: false

  # ClickHouse — logs et traces
  - name: ClickHouse
    type: grafana-clickhouse-datasource
    access: proxy
    editable: false
    jsonData:
      host: clickhouse
      port: 9000
      protocol: native
      defaultDatabase: otel
      username: default
      # Activer le mapping OTel pour l'exploration de logs/traces
      otelEnabled: true
      otelVersion: latest
      logs:
        defaultDatabase: otel
        defaultTable: otel_logs
        otelEnabled: true
        otelVersion: latest
        timeColumn: Timestamp
        logLevelColumn: SeverityText
        logMessageColumn: Body
      traces:
        defaultDatabase: otel
        defaultTable: otel_traces
        otelEnabled: true
        otelVersion: latest
    secureJsonData:
      password: "${CLICKHOUSE_PASSWORD}"

grafana/provisioning/dashboards/dashboards.yaml :

apiVersion: 1

providers:
  - name: Default
    orgId: 1
    folder: Observability
    type: file
    disableDeletion: false
    updateIntervalSeconds: 30
    allowUiUpdates: true
    options:
      path: /var/lib/grafana/dashboards

Les fichiers JSON de dashboards placés dans ./grafana/dashboards/ sont automatiquement importés. Des dashboards communautaires sont disponibles sur grafana.com/grafana/dashboards (ex : Docker Dashboard ID 10585).

Source : Grafana Provisioning Docs

Explorer les logs

Avec le mapping OTel activé, l’Explore mode de Grafana permet de naviguer dans les logs ClickHouse :

  1. Sélectionner la data source ClickHouse
  2. Passer en mode “Logs”
  3. Filtrer par ServiceName, SeverityText, ou rechercher dans le Body
  4. Cliquer sur un log pour voir tous ses attributs (http.host, net.peer.ip, etc.)

Pour les requêtes avancées, utiliser le query editor SQL directement dans Grafana.


5. Caddy : logging structuré JSON

Pour 200+ sites derrière un reverse proxy, le JSON structuré est essentiel. Le Common Log Format (CLF) ne contient pas le header Host — impossible de distinguer quel site a reçu quelle requête.

Caddy émet du JSON structuré par défaut via la bibliothèque Zap (zero-allocation). Configuration minimale dans le Caddyfile :

{
    log {
        output stdout
        format json {
            time_format iso8601
        }
        # Désactiver les logs des health checks
        exclude net/http.request.uri "/health" "/healthz"
    }
}

Un log d’accès Caddy contient nativement :

{
  "level": "info",
  "ts": "2026-03-03T14:05:23.456Z",
  "msg": "handled request",
  "request": {
    "remote_ip": "192.168.1.42",
    "remote_port": "54321",
    "client_ip": "192.168.1.42",
    "method": "GET",
    "host": "mon-site.example.com",
    "uri": "/blog/article",
    "headers": { "User-Agent": ["..."] }
  },
  "status": 200,
  "duration": 0.001234,
  "size": 15678
}

Le champ request.host permet de filtrer les logs par site — exactement ce qu’il faut pour 200+ sites.

Filtrage pour la vie privée (masquer les IPs, supprimer les cookies) :

log {
    output stdout
    format filter {
        fields {
            request>headers>Cookie delete
            request>headers>Authorization delete
            request>remote_ip ip_mask {
                ipv4 16
                ipv6 32
            }
        }
        wrap json
    }
}

[Sources : Caddy Logging Docs, Better Stack — Logging in Caddy]

Alternatives : pour Nginx, configurer un log_format json_access avec escape=json et les variables nécessaires ($host, $remote_addr, $request_time…). Pour Traefik, activer accessLog.format=json avec filtrage par status codes et contrôle des champs/headers. [Sources : Nginx ngx_http_log_module, Traefik Access Logs]


6. Le docker-compose.yml complet

docker-compose.yml

services:
  # ============================================
  # ClickHouse — stockage des logs et traces
  # ============================================
  clickhouse:
    image: clickhouse/clickhouse-server:24.12
    container_name: clickhouse
    ports:
      - "8123:8123" # HTTP
      - "9000:9000" # Native TCP
    volumes:
      - clickhouse_data:/var/lib/clickhouse
      - clickhouse_logs:/var/log/clickhouse-server
    environment:
      CLICKHOUSE_DB: otel
      CLICKHOUSE_USER: default
      CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-changeme}
      CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
    ulimits:
      nofile:
        soft: 262144
        hard: 262144
    deploy:
      resources:
        limits:
          memory: 4G
        reservations:
          memory: 2G
    healthcheck:
      test: ["CMD", "clickhouse-client", "--query", "SELECT 1"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - observability
    restart: unless-stopped

  # ============================================
  # OpenTelemetry Collector — collecte et routage
  # ============================================
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.117.0
    container_name: otel-collector
    command: ["--config=/etc/otelcol/config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otelcol/config.yaml:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
    ports:
      - "4317:4317" # OTLP gRPC
      - "4318:4318" # OTLP HTTP
      - "8889:8889" # Prometheus exporter
    environment:
      - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-changeme}
      - OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production
    depends_on:
      clickhouse:
        condition: service_healthy
    networks:
      - observability
    restart: unless-stopped

  # ============================================
  # cAdvisor — métriques conteneur
  # ============================================
  cadvisor:
    image: gcr.io/cadvisor/cadvisor:v0.49.1
    container_name: cadvisor
    ports:
      - "8080:8080"
    volumes:
      - /:/rootfs:ro
      - /var/run:/var/run:rw
      - /sys:/sys:ro
      - /var/lib/docker:/var/lib/docker:ro
      - /dev/disk:/dev/disk:ro
    privileged: true
    networks:
      - observability
    restart: unless-stopped

  # ============================================
  # Prometheus — stockage des métriques
  # ============================================
  prometheus:
    image: prom/prometheus:v2.50.0
    container_name: prometheus
    command:
      - "--config.file=/etc/prometheus/prometheus.yml"
      - "--storage.tsdb.path=/prometheus"
      - "--storage.tsdb.retention.time=15d"
      - "--web.enable-lifecycle"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus_data:/prometheus
    ports:
      - "9090:9090"
    depends_on:
      - otel-collector
      - cadvisor
    networks:
      - observability
    restart: unless-stopped

  # ============================================
  # Grafana — visualisation
  # ============================================
  grafana:
    image: grafana/grafana:11.4.0
    container_name: grafana
    environment:
      - GF_INSTALL_PLUGINS=grafana-clickhouse-datasource
      - GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
      - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-changeme}
    ports:
      - "3000:3000"
    volumes:
      - grafana_data:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
      - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
    depends_on:
      - prometheus
      - clickhouse
    networks:
      - observability
    restart: unless-stopped

networks:
  observability:
    driver: bridge

volumes:
  clickhouse_data:
  clickhouse_logs:
  prometheus_data:
  grafana_data:

Arborescence des fichiers

observability/
├── docker-compose.yml
├── .env                              # CLICKHOUSE_PASSWORD, GRAFANA_PASSWORD
├── otel-collector-config.yaml        # Config complète (voir section 1)
├── prometheus.yml                    # Config Prometheus (voir section 3)
└── grafana/
    ├── provisioning/
    │   ├── datasources/
    │   │   └── datasources.yaml      # ClickHouse + Prometheus (voir section 4)
    │   └── dashboards/
    │       └── dashboards.yaml       # Provider de dashboards (voir section 4)
    └── dashboards/
        └── *.json                    # Fichiers JSON de dashboards

Démarrage

# Créer le .env
echo "CLICKHOUSE_PASSWORD=un_mot_de_passe_fort" > .env
echo "GRAFANA_USER=admin" >> .env
echo "GRAFANA_PASSWORD=un_autre_mot_de_passe" >> .env

# Démarrer la stack
docker compose up -d

# Vérifier que tout tourne
docker compose ps

# Accéder à Grafana
# http://localhost:3000

7. Réduction du bruit

Pour des sites statiques, la majorité des logs sont des accès aux assets (CSS, JS, images, fonts) et des health checks. Filtrer ce bruit est essentiel pour réduire les coûts de stockage et rendre les logs exploitables.

Au niveau du collecteur (filter processor)

Le filter processor configuré plus haut supprime les logs avant qu’ils n’atteignent ClickHouse :

  • Assets statiques (*.css, *.js, *.png, *.jpg, *.svg, *.woff2…)
  • Health checks (/health, /healthz)
  • 304 Not Modified (le navigateur avait déjà la ressource en cache)

Au niveau de Caddy

On peut aussi exclure directement dans le Caddyfile pour ne même pas émettre ces logs :

log {
    output stdout
    format json
    # Ne pas logger les requêtes sur les assets
    exclude net/http.request.uri_path "/assets/*" "*.css" "*.js" "*.png" "*.jpg" "*.svg" "*.woff2"
}

Au niveau de ClickHouse (TTL par sévérité)

Pour les logs qui arrivent quand même, la rétention différenciée par sévérité permet de garder les erreurs longtemps et de supprimer rapidement les logs informatifs :

ALTER TABLE otel.otel_logs
  MODIFY TTL
    toDateTime(Timestamp) + INTERVAL 3 DAY DELETE
      WHERE SeverityText IN ('TRACE', 'DEBUG'),
    toDateTime(Timestamp) + INTERVAL 14 DAY DELETE
      WHERE SeverityText = 'INFO',
    toDateTime(Timestamp) + INTERVAL 90 DAY DELETE
      WHERE SeverityText IN ('WARN', 'ERROR');

8. Aller plus loin

ClickStack

Si la configuration manuelle semble trop lourde, ClickStack est une stack d’observabilité tout-en-un basée sur ClickHouse, lancée en mai 2025. Elle inclut ClickHouse + OTel Collector pré-configuré + HyperDX (UI) + MongoDB (état applicatif). C’est un “Datadog self-hosted” clé en main. Le tradeoff : on remplace Grafana par HyperDX et on ajoute une dépendance MongoDB. Source : ClickStack Architecture

Alerting Grafana

Grafana supporte l’alerting unifié sur les deux sources de données. Exemples :

  • Alerte CPU : rate(container_cpu_usage_seconds_total[5m]) > 0.8 → notification Slack
  • Alerte erreurs : requête SQL sur otel.otel_logs comptant les erreurs 5xx dans les 5 dernières minutes
  • Alerte conteneur down : absent(container_start_time_seconds{name="caddy"}) → le conteneur critique n’est plus là

Traces distribuées

Pour les applications que vous contrôlez (pas les sites statiques, mais des APIs, des backends), l’OTel Collector est déjà configuré pour recevoir des traces via OTLP (port 4317/4318) et les exporter vers ClickHouse. Il suffit d’instrumenter l’application avec un SDK OpenTelemetry et les traces apparaîtront dans Grafana avec corrélation automatique logs ↔ traces via le TraceId.

Scaling

Pour dépasser les capacités d’un single-node ClickHouse :

  1. Passer de MergeTree à ReplicatedMergeTree dans le schéma
  2. Ajouter cluster_name dans la config de l’exporter OTel
  3. Déployer plusieurs nœuds ClickHouse avec ClickHouse Keeper (remplaçant de ZooKeeper)
  4. Pour Prometheus : considérer Mimir (stockage long-terme distribué) ou Thanos

Sources

OpenTelemetry

ClickHouse

Prometheus

Grafana

Caddy

Communauté et tutoriels