Corrigé type de l'ADR de migration
Exemple détaillé de relevé de décisions d'architecture pour la migration d'Elasticsearch vers ClickHouse.
Remarque : il s'agit d'une réponse raisonnable compte tenu des contraintes propres à l'atelier. Votre ADR peut légitimement différer ; l'essentiel est que votre raisonnement repose sur l'état d'ES que vous avez documenté dans l'exercice 2A.
ADR : stratégie de migration de la plateforme d'observabilité (Elasticsearch → ClickHouse)
Auteur : équipe de migration Date : 21 avril 2026 État : proposé
Contexte
Notre plateforme d'observabilité repose sur Elasticsearch 8.15, Kibana et Filebeat, avec deux charges de travail :
- Charge de travail 1 — journaux collectés par des agents (Filebeat) : 3 flux de données (
logs-web_access-lab,logs-application-lab,logs-infrastructure-lab), environ 62 millions de documents et environ 16 Go de stockage sur les shards primaires en 5 jours. Quatre pipelines d'ingestion effectuent une recherche GeoIP, l'analyse de l'agent utilisateur, l'analyse grok des journaux syslog et la dérivation de la gravité. - Charge de travail 2 — microservices instrumentés avec OTel : 16 services provenant d'OpenTelemetry Demo, instrumentés avec des SDK OTel dans 10 langages et émettant des traces, métriques et journaux OTLP par l'intermédiaire d'Elastic APM Server dans les index
traces-apm-*/logs-apm.*/metrics-apm.*.
Principales difficultés :
- Le coût du stockage augmente de façon linéaire : le parcours chaud → tiède → suppression d'ILM est complexe à exploiter, mais il ne réduit réellement le coût qu'au niveau froid (que nous n'utilisons pas ici). Les index inversés de chaque champ exercent une pression sur le tas JVM.
- Deux parcours de collecte (Filebeat + agents Elastic APM) utilisent deux schémas propriétaires (ECS et OTel), ce qui double la maintenance des schémas.
- Les alertes Kibana offrent une expressivité SQL limitée : ni jointures, ni fonctions de fenêtre, ni agrégations complexes.
- Aucun contrôle des coûts n'est intégré pour les champs à forte cardinalité (
remote_addr,trace_id), qui sont toujours indexés.
Cible : ClickHouse Cloud et HyperDX comme interface d'observabilité.
Décision 1 : approche de migration
Choix : exécution parallèle pendant 2 semaines, suivie d'une bascule progressive par flux de données.
Raisonnement :
Une bascule directe est trop risquée : nous avons 2 règles d'alerte actives qui ne doivent pas régresser, et nos tableaux de bord sont utilisés par l'équipe d'astreinte. Une exécution parallèle (double écriture dans ES et ClickHouse) nous permet de comparer le nombre de documents, de contrôler quotidiennement l'équivalence des requêtes et d'éprouver la cible avec du trafic réel. Après 7 jours sans écart, nous basculons un flux de données à la fois (logs-infrastructure-lab en premier, puisqu'il présente le volume de requêtes le plus faible et permet le retour arrière le plus simple), tout en conservant les anciens tableaux de bord ES en lecture seule pendant 2 semaines supplémentaires à titre de filet de sécurité.
Stratégie de retour arrière : échec de la cible pendant l'exécution parallèle → rediriger l'exportateur otlphttp du collecteur vers Elastic APM Server ; les écritures continuent dans ES. Échec de la cible après la bascule, pendant la période de lecture seule de 2 semaines → faire repointer la source de données HyperDX/Grafana de ClickHouse vers ES pour le flux concerné.
Décision 2 : stratégie pour les agents
Choix : OTel Collector direct pour la charge de travail 2 (déjà native) ; passerelle Filebeat → Vector → OTel Collector pour la charge de travail 1.
Raisonnement :
- La charge de travail 2 émet déjà en OTLP. Il suffit de faire pointer
otelcol-demovers un nouveau pipeline d'exportation ClickHouse, sans aucune reconfiguration des agents. - Les trois générateurs de journaux de la charge de travail 1 utilisent déjà Filebeat. Remplacer immédiatement chaque instance Filebeat par l'OTel Collector représente un changement risqué sur un pipeline actif. Vector constitue un intermédiaire sûr : Filebeat continue d'envoyer les données à Vector par l'entrée Beats ; Vector transpose les noms d'attributs ECS vers les conventions sémantiques OTel (
host.hostname→host.name,service→service.name, etc.), puis émet les données en OTLP. Il offre également un emplacement où mettre en mémoire tampon et dupliquer le trafic pendant l'exécution parallèle. - Une fois la bascule complète, nous pouvons retirer Vector et remplacer Filebeat par un récepteur
filelogléger de l'OTel Collector sur chaque hôte. Il s'agit d'un remplacement d'une ligne et peu risqué après la bascule. - Charge de travail 2 (OTel Demo) : aucun changement ; le
otelcol-demoexistant reçoit un second exportateur qui pointe vers ClickHouse (au moyen du composant contribclickhouseexporter), aux côtés de celui d'APM Server.
Décision 3 : stratégie de schéma
| Type de journal | Approche | ORDER BY proposé | Justification |
|---|---|---|---|
logs-web_access-lab | Personnalisé avec colonnes matérialisées | (ServiceName, Status, toUnixTimestamp(Timestamp)) | Les tableaux de bord les plus utilisés filtrent par service et état. Les colonnes matérialisées Status, RequestPath, RunTime, CountryName permettent une analyse colonnaire rapide. |
logs-application-lab | Personnalisé avec colonnes matérialisées + filtre de Bloom sur TraceId | (ServiceName, SeverityText, toUnixTimestamp(Timestamp)) | Les recherches de corrélation de traces (WHERE TraceId = ?) font appel à un index de saut ; garder TraceId hors de la clé primaire préserve la localité des analyses par plage temporelle. |
logs-infrastructure-lab | Personnalisé (préanalysé par OTel) | (Hostname, Process, toUnixTimestamp(Timestamp)) | Les deux premières colonnes présentent une faible cardinalité (environ 10 hôtes et 10 processus), pour une excellente compression et une élimination efficace par hôte. |
Traces APM (traces-apm-*) | Schéma OTel par défaut + filtre de Bloom sur TraceId ajouté après la création | (ServiceName, Timestamp, TraceId) | Le schéma OTel par défaut correspond directement au clickhouseexporter de l'OTel Collector, mais l'exportateur n'ajoute PAS d'index de saut sur TraceId ; nous le faisons nous-mêmes (voir ci-dessous). |
Raisonnement général :
Nous évitons délibérément ici le motif table Null + MV. Il ajoute un niveau d'indirection et un coût processeur à l'ingestion qui ne se justifient que lorsqu'un flux brut est dupliqué vers plusieurs destinations agrégées. Pour cette migration, nous écrivons directement dans la MergeTree cible ; les dérivations coûteuses (recherche géographique, expression régulière de l'agent utilisateur) résident dans des colonnes matérialisées, de sorte qu'elles sont calculées à l'insertion et réutilisées par toutes les requêtes.
Étape obligatoire après la création d'otel_traces : le schéma par défaut de clickhouseexporter ne comporte pas d'index de saut sur TraceId. Les recherches par identifiant de trace (requête 5 de l'exercice 2B) analyseraient sinon toute la table. Exécutez ces instructions une fois que l'exportateur a créé la table :
ALTER TABLE otel_traces ADD INDEX trace_id_bf TraceId TYPE bloom_filter(0.01) GRANULARITY 4;
ALTER TABLE otel_traces MATERIALIZE INDEX trace_id_bf; -- backfill the index on existing granulesNous faisons de même pour SpanId si les recherches entre spans deviennent un parcours fréquent.
Évolution du schéma — notre choix : approche hybride : Map par défaut, promotion des champs fréquents lors d'une revue périodique.
Tous les attributs inconnus aboutissent par défaut dans la Map LogAttributes / SpanAttributes (comportement de l'exportateur). Une fois par sprint, l'équipe plateforme exécute la requête suivante sur les données de la semaine écoulée :
SELECT mapKeys(LogAttributes) AS keys, count() AS n
FROM otel_logs ARRAY JOIN mapKeys(LogAttributes) AS key
WHERE Timestamp > now() - INTERVAL 7 DAY
GROUP BY keys
ORDER BY n DESC
LIMIT 50;Toute clé qui (a) apparaît dans au moins 30 % des lignes ET (b) est référencée par au moins 2 tableaux de bord ou alertes est promue au moyen de :
ALTER TABLE otel_logs ADD COLUMN <NewCol> String MATERIALIZED LogAttributes['<key>'];La promotion relève de l'équipe plateforme (et non de chaque service), afin de maintenir un schéma réduit et cohérent.
Pourquoi ne pas recourir à une promotion automatique avec un générateur DDL ? Exécuter des ALTER en réponse à des signaux reçus à l'ingestion est risqué : un attribut bruyant mal classé (request_id, UUID ressemblant à un identifiant de trace) devient une colonne à forte cardinalité et dégrade la compression. Le rythme de contrôle humain constitue une assurance peu coûteuse et évite un gonflement involontaire du schéma.
Pourquoi ne pas utiliser le type de colonne JSON ? Map(LowCardinality(String), String) est l'option plus ancienne et plus largement déployée, dont le comportement est prévisible pour les motifs d'accès mapContains / LogAttributes['key']. Le type JSON pourrait être plus performant à grande échelle, mais nous ne l'avons pas encore évalué sur cette charge de travail. Il faudra réexaminer ce choix si l'espace des clés de Map dépasse environ 200 clés distinctes, ce qui commencerait à nuire à l'efficacité du dictionnaire LowCardinality.
Conséquence pour la charge de travail 2 (OTel Demo) : clickhouseexporter traite nativement les attributs dynamiques. Les nouveaux attributs de span émis par un service apparaissent dans SpanAttributes dès leur première occurrence, sans qu'il soit nécessaire de modifier le collecteur ou le schéma. Le processus de promotion est le même que pour les journaux.
Décision 4 : transposition des pipelines d'ingestion
| Processeur ES | Équivalent dans l'environnement cible | Pourquoi |
|---|---|---|
geoip sur remote_addr | Dictionnaire (disposition IP_TRIE sur un CSV GeoLite2) + dictGet() dans une colonne matérialisée | Les collecteurs restent sans état. Une mise à jour du dictionnaire dans le système cible ne nécessite qu'une instruction DDL ; il est inutile de redéployer chaque nœud Vector/Filebeat. |
user_agent sur user_agent | Processeur user_agent de l'OTel Collector | Le processeur correspond directement à l'implémentation d'Elastic et émet des attributs analysés (user_agent.name, user_agent.os.name, etc.). Le schéma du système cible reste minimal. |
Analyse syslog grok | Opérateur regex_parser de l'OTel Collector dans le récepteur filelog | L'analyse n'a lieu qu'une fois, en périphérie. ClickHouse reçoit des lignes déjà structurées. Une expression régulière exécutée dans CH coûterait davantage de processeur à l'échelle de l'ingestion et serait plus difficile à faire évoluer. |
Dérivation de la gravité par script | Colonne MATERIALIZED multiIf(...) | La logique (état HTTP → gravité, mot-clé → gravité) est simple en SQL, ne s'exécute qu'une fois lors de l'insertion et reste auprès du schéma, ce qui empêche toute divergence avec la table. |
set event.ingested = _ingest.timestamp | Colonne DEFAULT now() | Même sémantique, un seul mot-clé. Aucun processeur requis. |
dissect sur le champ message de l'application | Suppression — il trouve rarement une correspondance en pratique et les champs qu'il pourrait extraire ne sont pas interrogés. | Processeur inutile repéré lors de l'exercice 2A. |
Quels processeurs peuvent être entièrement supprimés ? Le processeur dissect est inutile. Le pipeline set event.ingested est redondant dès lors que nous utilisons une colonne DEFAULT now(). Deux pipelines disparaissent ainsi de la migration.
Décision 5 : cycle de vie des données
Politique ILM ES actuelle (lab-observability-policy) :
- Chaud :
rolloverà 5 Go ou 1 jour, priorité 100 - Tiède (à 2 jours) :
shrinkà 1 shard,forcemergeà 1 segment, priorité 50 - Suppression : à 30 jours
Verdict phase par phase :
| Action ILM | Encore nécessaire ? | Remplacement |
|---|---|---|
rollover à 5 Go / 1 j | Non — ClickHouse utilise une table unique avec des partitions par plage de dates ; aucun index successif à gérer. | Partitionnement par toYYYYMM(Timestamp) |
shrink à 1 shard à 2 j | Non — les shards logiques se mettent à l'échelle automatiquement ; aucune intervention sur un shard physique n'est requise. | Sans objet |
forcemerge à 1 segment à 2 j | Non — le processus de fusion en arrière-plan de MergeTree s'en charge automatiquement et en continu. | Sans objet |
set_priority 100 → 50 | Non — ClickHouse Cloud ne comporte pas de notion de priorité entre niveaux de nœuds. | Sans objet |
| Migration vers le niveau froid/gelé | Non — ClickHouse Cloud stocke toutes les données dans un stockage objet doté d'un cache local automatique en lecture. Il n'existe aucun niveau « froid » vers lequel migrer : les anciennes données restent simplement dans le même stockage objet et sont mises en cache à la demande. | Sans objet |
delete à 30 j | Oui — il s'agit de la seule action du cycle de vie qui conserve une utilité sémantique. | TTL … DELETE |
Durée de conservation : 30 jours (identique à ES).
Clause TTL (par table) :
TTL toDateTime(Timestamp) + INTERVAL 30 DAY DELETERaisonnement :
Cinq des six actions ILM disparaissent. Il s'agit de la principale simplification opérationnelle apportée par la migration : ILM représentait plus de 200 lignes de politique, une surveillance des transitions de phase et des tableaux de bord sur l'état des index ; tout cela est remplacé par une clause TTL par table. Finis les tickets demandant pourquoi un index reste bloqué dans la phase tiède.
Décision 6 : migration des alertes
Choix de l'outil : alertes Grafana avec la source de données ClickHouse pour les deux règles.
Raisonnement :
- Grafana dispose d'une source de données ClickHouse de premier ordre et d'un moteur d'alertes éprouvé (planification, déduplication, silences et routage vers Slack/PagerDuty). Inutile d'en créer un.
- HyperDX propose des alertes, mais elles sont moins complètes que celles de Grafana pour les règles associant seuil numérique et fenêtre temporelle.
- Les MV précalculées constituent une solution de remplacement valable pour les requêtes d'alerte coûteuses ; pour ces deux règles, la requête est peu coûteuse et le SQL exécuté dans Grafana convient.
Taux d'erreurs élevé — mise en œuvre :
-- Returns a single row IFF 5xx rate exceeded 5% in the last 5 minutes.
SELECT
countIf(Status >= 500) AS errors,
count() AS total,
(errors / total) * 100 AS error_rate_pct
FROM otel_logs_web_access
WHERE Timestamp >= now() - INTERVAL 5 MINUTE
HAVING total > 100 -- suppress false alerts on low volume
AND error_rate_pct > 5.0;Planification : évaluation toutes les minutes et attente de 2 évaluations positives consécutives avant déclenchement (afin d'éviter une alerte pour un pic unique).
Battement de cœur d'un service — mise en œuvre :
-- Returns one row per service that has emitted no logs in the last 3 minutes.
WITH known_services AS (
SELECT DISTINCT ServiceName FROM otel_logs_application
WHERE Timestamp >= now() - INTERVAL 1 DAY
)
SELECT ks.ServiceName AS service
FROM known_services ks
LEFT JOIN (
SELECT ServiceName, max(Timestamp) AS last_seen
FROM otel_logs_application
WHERE Timestamp >= now() - INTERVAL 10 MINUTE
GROUP BY ServiceName
) recent ON recent.ServiceName = ks.ServiceName
WHERE recent.last_seen IS NULL
OR recent.last_seen < now() - INTERVAL 3 MINUTE;Planification : évaluation toutes les minutes. Chaque ligne émise déclenche une instance d'alerte distincte, routée selon le nom du service.
Nouvelle possibilité apportée par ClickHouse :
- Jointures dans les requêtes d'alerte (impossibles dans Kibana). La règle de battement de cœur ci-dessus joint un ensemble de « services connus » à un ensemble de services « vus récemment ». Dans ES, nous devrions gérer hors du système la liste des services connus.
- Fonctions de fenêtre et
sequenceMatchpour la détection complexe d'anomalies, par exemple : « alertes qui se déclenchent lorsqu'un service connaît 3 pics consécutifs de réponses 5xx en 10 minutes ». - CTE + sous-requêtes : conditions d'alerte plus riches sans recourir à des scripts Watcher.
Décision 7 : stratégie pour les données historiques
Choix : repartir de zéro. ClickHouse reçoit uniquement les nouvelles données ; ES reste en lecture seule pendant 90 jours, puis un instantané est créé et le système est désactivé.
Raisonnement :
- La valeur de l'historique des tendances est limitée pour cette charge de travail. Les journaux et les traces servent principalement à la gestion des incidents (dernières 24 h) et à l'examen hebdomadaire des tendances. Une conservation de 30 j suffit ; les 90 j de lecture seule d'ES couvrent tout besoin de « revenir un trimestre en arrière » pendant la transition.
- Le risque lié aux outils de réimportation est réel.
elasticdumpou un script employant l'API de défilement peuvent transférer environ 62 millions de documents, mais la déduplication reste fragile : tout nouvel essai ou échec partiel introduit des doublons dansotel_logs. Une déduplication avecReplacingMergeTreeest possible, mais elle ne fait que reporter le problème et complique la sémantique des requêtes pendant la réimportation. - Le coût de stockage de la duplication d'environ 62 millions de documents n'est pas négligeable pendant la bascule et n'apporte aucune valeur opérationnelle après celle-ci, une fois ES en lecture seule.
- La période de lecture seule d'ES de 90 jours constitue une assurance peu coûteuse. Si nous découvrons une régression dans un tableau de bord ou avons besoin d'un historique plus long pour une investigation, ES reste interrogeable. HyperDX prend en charge plusieurs sources de données ; nous pouvons donc diriger de façon transparente les requêtes portant sur les « 30 derniers jours » vers ClickHouse et les plus anciennes vers ES.
Plan de lecture seule d'ES :
| Jour | Action |
|---|---|
| 0 (bascule) | Arrêter les écritures Filebeat. Arrêter les écritures d'APM Server. Laisser le cluster ES actif. Faire passer la source de données par défaut d'HyperDX/Grafana d'ES à ClickHouse. |
| 0 à 90 | Les tableaux de bord utilisent CH pour les « 30 derniers jours ». Les recherches historiques sur de longues périodes sont dirigées vers ES, source de données HyperDX secondaire. |
| 90 | POST _snapshot/backup_repo/final_snapshot → S3. Supprimer le cluster ES. L'instantané pourra être restauré sur une instance ES temporaire si le besoin se présente. |
Si une réimportation devient nécessaire par la suite (solution de repli) :
Réutilisez la passerelle Vector de la décision 2 :
elasticdump \
--input=http://es:9200/logs-web_access-lab \
--output=http://vector:8686/_bulk \
--type=data \
--limit=10000-
Débit attendu : environ 50 000 documents/s en continu (Vector envoie des lots à l'exportateur ClickHouse).
-
Durée estimée de la réimportation complète : environ 62 millions de documents, soit environ 20 minutes de bout en bout.
-
Stratégie de déduplication : importez la réexécution ES dans une table intermédiaire (avec le même schéma qu'
otel_logs), puis fusionnez-la dans la table active avecSELECT DISTINCT ONafin d'éliminer les nouvelles tentatives. Une solution simple, sans modification du moteur :-- 1. Staging table with the live table's schema CREATE TABLE otel_logs_staging AS otel_logs; -- 2. Re-point Vector at otel_logs_staging and run the elasticdump replay. -- 3. Merge, keeping one row per identifying tuple INSERT INTO otel_logs SELECT DISTINCT ON (ServiceName, Timestamp, Body) * FROM otel_logs_staging; DROP TABLE otel_logs_staging;Pour une réimportation ponctuelle d'environ 62 millions de lignes durant 20 minutes, cette solution est préférable à un moteur intermédiaire
ReplacingMergeTree: elle comporte moins d'éléments mobiles, ne nécessite pas de coordonner le moment des fusions en arrière-plan et rend explicite la clé de déduplication au niveau deDISTINCT ON. -
Pour les réimportations plus longues ou incrémentales (par exemple la réexécution progressive de plusieurs mois de données), une table intermédiaire
ReplacingMergeTreeavec une colonne de versionMATERIALIZED ContentHashdansORDER BYconstitue l'outil approprié. Consultez la documentation ClickHouse surReplacingMergeTree; ce DDL sort du cadre de l'atelier. -
Contrôle d'exhaustivité : comparez le nombre de documents par jour entre ES (
_countavec une requêterange) et CH (count() WHERE toDate(Timestamp) = ...). Attendez-vous à un écart ≤ 0,01 % dû au moment de l'ingestion.
Risques et questions en suspens
- Vector comme passerelle ajoute une étape et un domaine de panne supplémentaires pendant l'exécution parallèle. Ce compromis est acceptable pendant 2 semaines ; nous voulons le retirer avant de déclarer la bascule complète.
- Fréquence de rafraîchissement du dictionnaire GeoIP : MaxMind GeoLite2 est mis à jour chaque semaine ; il nous faut une tâche cron/Airflow pour récupérer le dernier CSV et exécuter
SYSTEM RELOAD DICTIONARYchaque nuit. - Champs à forte cardinalité (par exemple
trace.id,remote_addr) : nous devons confirmer les performances du filtre de Bloom et de l'index de saut sur une fenêtre glissante de 30 jours. Nous prévoyons un test de charge avecclickhouse-benchmarkà 30 fois le volume quotidien actuel avant la bascule. - Migration des tableaux de bord Kibana vers HyperDX/Grafana : HyperDX ingère aisément les schémas natifs OTel, mais nous devrons reconstruire manuellement les 6 tableaux de bord. Budget : une journée d'ingénieur par tableau de bord.
- Modèle de coûts de ClickHouse Cloud : nous devons estimer le dimensionnement d'après le volume après compression (réduction attendue d'environ 10 fois), le nombre de requêtes par seconde et le niveau de mise à l'échelle du calcul.