Elasticsearch MigrationClickHouse Workshops

Respuesta modelo del ADR de migración

Registro de decisiones de arquitectura completo para la migración de Elasticsearch a ClickHouse.

Nota: Esta es una respuesta razonable dadas las restricciones del laboratorio. Tu ADR puede diferir; lo importante es que la justificación se base en el estado de ES documentado en el Ejercicio 2A.


ADR: estrategia de migración de observabilidad (Elasticsearch → ClickHouse)

Autor: Equipo de migración Fecha: 2026-04-21 Estado: Propuesto

Contexto

Nuestra pila ejecuta Elasticsearch 8.15 + Kibana + Filebeat con dos cargas:

  • Carga 1 — Logs recopilados por agentes (Filebeat): 3 flujos (logs-web_access-lab, logs-application-lab, logs-infrastructure-lab), unos 62 millones de documentos y 16 GB en shards primarios durante 5 días. Cuatro pipelines realizan búsqueda GeoIP, análisis de user-agent y syslog con grok y derivación de severidad.
  • Carga 2 — Microservicios instrumentados con OTel: 16 servicios de OpenTelemetry Demo con SDK de OTel en 10 lenguajes que emiten trazas, métricas y logs OTLP mediante Elastic APM Server a los índices traces-apm-* / logs-apm.* / metrics-apm.*.

Principales problemas:

  • El coste de almacenamiento crece linealmente; hot → warm → delete de ILM es complejo y no reduce el coste hasta cold, que no usamos. Los índices invertidos de todos los campos presionan el heap de JVM.
  • Dos rutas de recopilación (Filebeat + agentes de Elastic APM) usan dos esquemas (ECS frente a OTel), duplicando el mantenimiento.
  • Las alertas de Kibana tienen SQL limitado: sin joins, ventanas ni agregaciones profundas.
  • No hay control de coste integrado para campos de alta cardinalidad (remote_addr, trace_id), siempre indexados.

Destino: ClickHouse Cloud + HyperDX como interfaz de observabilidad.


Decisión 1: enfoque de migración

Elección: Ejecución paralela durante 2 semanas y transición por fases para cada flujo.

Justificación:

Una transición abrupta es demasiado arriesgada: 2 reglas de alerta activas no pueden sufrir regresiones y los dashboards los usa el equipo de guardia. Una ejecución paralela (escritura doble en ES y ClickHouse) permite comparar recuentos, comprobar diariamente la paridad de consultas y probar el destino con tráfico real. Tras 7 días de paridad, migramos un flujo cada vez (logs-infrastructure-lab primero: menos consultas y reversión sencilla) y conservamos los dashboards de ES solo para lectura otras 2 semanas.

Reversión: Si falla el destino durante la ejecución paralela, se redirige el exportador otlphttp a Elastic APM Server; ES sigue recibiendo datos. Si falla tras la transición dentro de la ventana de 2 semanas, se cambia la fuente de HyperDX/Grafana de ClickHouse a ES para el flujo afectado.


Decisión 2: estrategia de agentes

Elección: OTel Collector directo para la Carga 2; puente Filebeat → Vector → OTel Collector para la Carga 1.

Justificación:

  • La Carga 2 ya emite OTLP. Basta redirigir otelcol-demo a un pipeline exportador de ClickHouse, sin reconfigurar agentes.
  • Los tres generadores de la Carga 1 ya usan Filebeat. Sustituirlos inmediatamente es arriesgado. Vector es un intermediario seguro: Filebeat le envía mediante Beats, Vector transforma ECS a convenciones OTel (host.hostname → host.name, service → service.name, etc.) y emite OTLP. También aporta búfer y bifurcación durante la ejecución paralela.
  • Tras la transición se puede retirar Vector y sustituir Filebeat por un receptor filelog ligero de OTel Collector por host.
  • Carga 2 (OTel Demo): sin cambios; otelcol-demo recibe un segundo exportador hacia ClickHouse (componente contrib clickhouseexporter) junto al de APM Server.

Decisión 3: estrategia de esquema

Tipo de logEnfoqueORDER BY propuestoJustificación
logs-web_access-labPersonalizado con columnas materializadas(ServiceName, Status, toUnixTimestamp(Timestamp))Los dashboards filtran por servicio + estado. Status, RequestPath, RunTime, CountryName materializados aceleran la exploración columnar.
logs-application-labPersonalizado con columnas materializadas + filtro de Bloom en TraceId(ServiceName, SeverityText, toUnixTimestamp(Timestamp))Las búsquedas de correlación (WHERE TraceId = ?) usan un índice de salto; dejar TraceId fuera de la clave preserva la localidad temporal.
logs-infrastructure-labPersonalizado (preanalizado por OTel)(Hostname, Process, toUnixTimestamp(Timestamp))Ambas columnas iniciales tienen baja cardinalidad (~10 hosts y procesos), con excelente compresión y poda.
Trazas APM (traces-apm-*)Esquema OTel predeterminado + filtro de Bloom posterior en TraceId(ServiceName, Timestamp, TraceId)El esquema se mapea directamente desde clickhouseexporter, pero este NO añade un índice de salto en TraceId; lo añadimos nosotros.

Justificación general:

Evitamos el patrón de tabla Null + MV. Añade indirección y CPU durante la ingesta, justificadas al duplicar un flujo bruto hacia varios destinos agregados. Aquí escribimos directamente al MergeTree; las derivaciones caras viven en columnas materializadas, calculadas una vez al insertar.

Paso obligatorio tras crear otel_traces: el esquema de clickhouseexporter no incluye índice de salto en TraceId; sin él, las búsquedas por ID (Consulta 5 del Ejercicio 2B) explorarían todo. Ejecuta una vez:

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 granules

Hacemos lo mismo para SpanId si las búsquedas entre spans se vuelven frecuentes.

Evolución — elección: Híbrida: Map por defecto y promoción periódica de campos usados.

Todos los atributos desconocidos llegan al Map LogAttributes / SpanAttributes. Una vez por sprint, el equipo ejecuta:

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;

Toda clave que aparezca en ≥ 30 % de filas Y se use en ≥ 2 dashboards o alertas se promueve:

ALTER TABLE otel_logs ADD COLUMN <NewCol> String MATERIALIZED LogAttributes['<key>'];

La promoción la decide el equipo de plataforma, no cada servicio, para mantener un esquema pequeño y coherente.

¿Por qué no promover automáticamente con un generador DDL? Ejecutar ALTER ante señales de ingesta es arriesgado: un atributo ruidoso mal clasificado (request_id, UUID similares a trace-id) se convierte en una columna de alta cardinalidad y arruina la compresión. La revisión humana evita el crecimiento involuntario.

¿Por qué no el tipo JSON? Map(LowCardinality(String), String) es una opción más desplegada y predecible para mapContains / LogAttributes['key']. JSON podría rendir mejor a escala, pero aún no se ha medido en esta carga. Hay que reconsiderarlo si el Map supera unas 200 claves distintas, lo que perjudicaría el diccionario LowCardinality.

Carga 2 (OTel Demo): clickhouseexporter gestiona atributos dinámicos: los atributos nuevos aparecen en SpanAttributes la primera vez, sin cambiar collector ni esquema. La promoción es igual que para logs.


Decisión 4: traducción del pipeline de ingesta

Procesador ESEquivalente de destinoMotivo
geoip en remote_addrDiccionario (diseño IP_TRIE sobre CSV GeoLite2) + dictGet() en columna materializadaMantiene los collectors sin estado; actualizar el diccionario es una DDL y no exige redesplegar nodos.
user_agent en user_agentProcesador user_agent de OTel CollectorSe corresponde con Elastic y emite atributos analizados (user_agent.name, user_agent.os.name, etc.).
Análisis syslog con grokOperador regex_parser en el receptor filelogAnaliza una vez en el borde; ClickHouse recibe filas estructuradas.
Derivación con scriptColumna MATERIALIZED multiIf(...)La lógica es trivial en SQL, se ejecuta una vez y permanece junto al esquema.
set event.ingested = _ingest.timestampColumna DEFAULT now()Misma semántica sin procesador.
dissect en messageEliminarlo; apenas coincide y los campos no se consultan.Procesador inactivo identificado en el Ejercicio 2A.

¿Qué puede eliminarse? dissect es peso muerto y set event.ingested es redundante con DEFAULT now(): se eliminan 2 pipelines.


Decisión 5: ciclo de vida de datos

Política ILM actual (lab-observability-policy):

  • Hot: rollover a 5 GB o 1 día, prioridad 100
  • Warm (2 días): shrink a 1 shard, forcemerge a 1 segmento, prioridad 50
  • Delete: 30 días

Veredicto por fase:

Acción ILM¿Necesaria?Sustitución
rollover a 5 GB / 1 dNo; una tabla con particiones por fecha.Particionar por toYYYYMM(Timestamp)
shrink a 1 shard a los 2 dNo; los shards lógicos escalan automáticamente.N/A
forcemerge a 1 segmento a los 2 dNo; MergeTree combina en segundo plano.N/A
set_priority 100 → 50No; Cloud no tiene prioridad por capas de nodos.N/A
Migración cold/frozenNo; todos los datos están en objetos con caché local automática.N/A
delete a los 30 dSí; única acción con finalidad semántica.TTL … DELETE

Retención: 30 días, igual que ES.

Cláusula TTL por tabla:

TTL toDateTime(Timestamp) + INTERVAL 30 DAY DELETE

Justificación:

Cinco de seis acciones desaparecen. Más de 200 líneas de política, supervisión de fases y dashboards de estado se sustituyen por una cláusula TTL por tabla. Se acabaron los incidentes «¿por qué está este índice atascado en warm?».


Decisión 6: migración de alertas

Herramienta: Grafana Alerting con la fuente de ClickHouse para ambas reglas.

Justificación:

  • Grafana tiene fuente de primera clase y un motor maduro (programación, deduplicación, silencios y Slack/PagerDuty).
  • HyperDX tiene alertas, pero menos funciones para umbrales numéricos + ventanas.
  • Las MV precalculadas sirven para consultas caras; estas dos son baratas, por lo que SQL en Grafana basta.

Alta tasa de errores — implementación:

-- 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;

Programación: Cada 1 minuto; debe cumplirse en 2 evaluaciones consecutivas.

Heartbeat del servicio — implementación:

-- 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;

Programación: Cada 1 minuto. Cada fila genera una alerta separada, dirigida por nombre de servicio.

Nuevas capacidades:

  • Joins en alertas: la regla une servicios conocidos con vistos recientemente; en ES habría que mantener la lista fuera.
  • Funciones de ventana y sequenceMatch para anomalías complejas, como tres picos 5xx consecutivos en 10 minutos.
  • CTE + subconsultas para condiciones más ricas sin scripts Watcher.

Decisión 7: estrategia de datos históricos

Elección: Empezar de cero. ClickHouse solo recibe datos nuevos; ES queda solo para lectura 90 días, después se toma snapshot y se desactiva.

Justificación:

  • El valor histórico es limitado. Logs y trazas sirven para incidentes (24 h) y tendencias semanales. 30 d bastan y 90 d de ES cubren búsquedas trimestrales durante la transición.
  • El backfill tiene riesgo. elasticdump o scroll pueden mover ~62 millones de documentos, pero los reintentos introducen duplicados en otel_logs. ReplacingMergeTree aplaza el problema y complica consultas.
  • Duplicar ~62 millones cuesta durante la transición sin aportar valor posterior.
  • 90 días de ES solo lectura son un seguro barato. HyperDX admite varias fuentes y puede dirigir los últimos 30 días a ClickHouse y lo anterior a ES.

Plan de ES solo para lectura:

DíaAcción
0 (transición)Detener Filebeat y APM Server. Mantener ES. Cambiar la fuente predeterminada de HyperDX/Grafana de ES → ClickHouse.
0 – 90Dashboards sirven «últimos 30 días» desde CH; búsquedas antiguas van a ES como fuente secundaria.
90POST _snapshot/backup_repo/final_snapshot → S3. Desactivar ES; restaurable en una instancia temporal.

Si después hace falta backfill:

Reutiliza el puente Vector de la Decisión 2:

elasticdump \
  --input=http://es:9200/logs-web_access-lab \
  --output=http://vector:8686/_bulk \
  --type=data \
  --limit=10000
  • Velocidad prevista: ~50 mil documentos/s.

  • Duración: ~62 millones ≈ 20 minutos.

  • Desduplicación: ingiere el replay en staging con el mismo esquema que otel_logs y combina mediante SELECT DISTINCT ON:

    -- 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;

    En un replay único, supera a staging con ReplacingMergeTree: menos piezas, sin coordinar merges y clave explícita en DISTINCT ON.

  • Para backfills largos o incrementales, usa staging ReplacingMergeTree con una columna de versión MATERIALIZED ContentHash en ORDER BY; esa DDL queda fuera del laboratorio.

  • El motor ReplacingMergeTree solo compensa cuando el replay deja de ser una operación única.

  • Integridad: compara recuentos diarios entre ES (_count con range) y CH (count() WHERE toDate(Timestamp) = ...). Espera una discrepancia ≤ 0,01 % por tiempos de ingesta.


Riesgos y preguntas abiertas

  • Vector como puente añade un salto y dominio de fallo durante 2 semanas; debe retirarse antes de completar la transición.
  • Actualización GeoIP: MaxMind GeoLite2 cambia semanalmente; necesitamos cron/Airflow para obtener CSV y ejecutar SYSTEM RELOAD DICTIONARY cada noche.
  • Campos de alta cardinalidad (trace.id, remote_addr): hay que probar filtro de Bloom + índice de salto en una ventana de 30 días con clickhouse-benchmark a 30× el volumen diario.
  • Dashboards Kibana → HyperDX/Grafana: habrá que reconstruir 6 manualmente; presupuesto de 1 día de ingeniería por dashboard.
  • Coste de ClickHouse Cloud: estimación según volumen comprimido (reducción ~10×), QPS y capa de expansión de cómputo.

En esta página

ES