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-demoa 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
filelogligero de OTel Collector por host. - Carga 2 (OTel Demo): sin cambios;
otelcol-demorecibe un segundo exportador hacia ClickHouse (componente contribclickhouseexporter) junto al de APM Server.
Decisión 3: estrategia de esquema
| Tipo de log | Enfoque | ORDER BY propuesto | Justificación |
|---|---|---|---|
logs-web_access-lab | Personalizado 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-lab | Personalizado 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-lab | Personalizado (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 granulesHacemos 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 ES | Equivalente de destino | Motivo |
|---|---|---|
geoip en remote_addr | Diccionario (diseño IP_TRIE sobre CSV GeoLite2) + dictGet() en columna materializada | Mantiene los collectors sin estado; actualizar el diccionario es una DDL y no exige redesplegar nodos. |
user_agent en user_agent | Procesador user_agent de OTel Collector | Se corresponde con Elastic y emite atributos analizados (user_agent.name, user_agent.os.name, etc.). |
Análisis syslog con grok | Operador regex_parser en el receptor filelog | Analiza una vez en el borde; ClickHouse recibe filas estructuradas. |
Derivación con script | Columna MATERIALIZED multiIf(...) | La lógica es trivial en SQL, se ejecuta una vez y permanece junto al esquema. |
set event.ingested = _ingest.timestamp | Columna DEFAULT now() | Misma semántica sin procesador. |
dissect en message | Eliminarlo; 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:
rollovera 5 GB o 1 día, prioridad 100 - Warm (2 días):
shrinka 1 shard,forcemergea 1 segmento, prioridad 50 - Delete: 30 días
Veredicto por fase:
| Acción ILM | ¿Necesaria? | Sustitución |
|---|---|---|
rollover a 5 GB / 1 d | No; una tabla con particiones por fecha. | Particionar por toYYYYMM(Timestamp) |
shrink a 1 shard a los 2 d | No; los shards lógicos escalan automáticamente. | N/A |
forcemerge a 1 segmento a los 2 d | No; MergeTree combina en segundo plano. | N/A |
set_priority 100 → 50 | No; Cloud no tiene prioridad por capas de nodos. | N/A |
| Migración cold/frozen | No; todos los datos están en objetos con caché local automática. | N/A |
delete a los 30 d | Sí; ú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 DELETEJustificació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
sequenceMatchpara 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.
elasticdumpo scroll pueden mover ~62 millones de documentos, pero los reintentos introducen duplicados enotel_logs.ReplacingMergeTreeaplaza 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ía | Acción |
|---|---|
| 0 (transición) | Detener Filebeat y APM Server. Mantener ES. Cambiar la fuente predeterminada de HyperDX/Grafana de ES → ClickHouse. |
| 0 – 90 | Dashboards sirven «últimos 30 días» desde CH; búsquedas antiguas van a ES como fuente secundaria. |
| 90 | POST _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_logsy combina medianteSELECT 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 enDISTINCT ON. -
Para backfills largos o incrementales, usa staging
ReplacingMergeTreecon una columna de versiónMATERIALIZED ContentHashenORDER BY; esa DDL queda fuera del laboratorio. -
El motor
ReplacingMergeTreesolo compensa cuando el replay deja de ser una operación única. -
Integridad: compara recuentos diarios entre ES (
_countconrange) 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 DICTIONARYcada 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 conclickhouse-benchmarka 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.