Resposta-modelo do ADR de migração
Um registro de decisão de arquitetura completo para a migração do Elasticsearch para o ClickHouse.
Observação: Esta é uma resposta razoável, considerando as restrições específicas do laboratório. Seu ADR pode ser diferente — o importante é que sua justificativa se baseie no estado do ES documentado no Exercício 2A.
ADR: Estratégia de migração de observabilidade (Elasticsearch → ClickHouse)
Autor: Equipe de migração Data: 2026-04-21 Status: Proposto
Contexto
Nossa stack de observabilidade executa Elasticsearch 8.15 + Kibana + Filebeat, com duas cargas de trabalho:
- Carga de trabalho 1 — Logs coletados por agente (Filebeat): 3 fluxos de dados (
logs-web_access-lab,logs-application-lab,logs-infrastructure-lab), ~62 milhões de documentos e ~16 GB de armazenamento em shards primários ao longo de 5 dias. Quatro pipelines de ingestão realizam consulta GeoIP, análise de user-agent e syslog com grok e derivação de severidade. - Carga de trabalho 2 — Microsserviços instrumentados com OTel: 16 serviços da OpenTelemetry Demo, instrumentados com SDKs OTel em 10 linguagens, emitindo traces / métricas / logs OTLP pelo Elastic APM Server para os índices
traces-apm-*/logs-apm.*/metrics-apm.*.
Principais dificuldades:
- O custo de armazenamento cresce linearmente — o fluxo hot → warm → delete do ILM é operacionalmente complexo, mas não reduz o custo de fato até a camada cold (que não usamos aqui). Há pressão sobre o heap da JVM pelos índices invertidos em todos os campos.
- Dois caminhos de coleta (Filebeat + agentes do Elastic APM) usam dois esquemas proprietários (ECS vs. OTel) — o dobro da manutenção de esquemas.
- Os alertas do Kibana têm expressividade SQL limitada — sem joins, funções de janela ou agregações profundas.
- Não há controle de custo integrado para campos de alta cardinalidade (
remote_addr,trace_id), que são sempre indexados.
Destino: ClickHouse Cloud + HyperDX como interface de observabilidade.
Decisão 1: Abordagem de migração
Escolha: Execução paralela por 2 semanas, seguida de transição em fases por fluxo de dados.
Justificativa:
Uma transição abrupta é arriscada demais — temos 2 regras de alerta ativas que não podem regredir, e nossos dashboards são usados pela equipe de plantão. Uma execução paralela (gravação dupla no ES e no ClickHouse) permite comparar contagens de documentos, executar diariamente verificações de paridade de consultas e testar o destino com tráfego real. Após 7 dias de paridade sem problemas, transferimos um fluxo de dados por vez (logs-infrastructure-lab primeiro — menor volume de consultas e reversão mais fácil), mantendo os dashboards antigos do ES somente para leitura por mais 2 semanas como proteção.
Estratégia de reversão: Falha no destino durante a execução paralela → redirecionar o exportador otlphttp do coletor de volta ao Elastic APM Server; o ES continua recebendo gravações. Falha no destino após a transição, dentro da janela de 2 semanas somente para leitura → trocar a fonte de dados do HyperDX/Grafana do ClickHouse de volta para o ES no fluxo afetado.
Decisão 2: Estratégia de agentes
Escolha: OTel Collector direto para a Carga de trabalho 2 (já nativa); ponte Filebeat → Vector → OTel Collector para a Carga de trabalho 1.
Justificativa:
- A Carga de trabalho 2 já emite OTLP. Basta redirecionar o
otelcol-demoda demonstração para um novo pipeline de exportação do ClickHouse — nenhuma reconfiguração de agente. - Os três geradores de logs da Carga de trabalho 1 já usam Filebeat. Substituir todas as instâncias do Filebeat pelo OTel Collector agora seria uma mudança de alto risco em um pipeline ativo. O Vector é o intermediário seguro: o Filebeat continua enviando ao Vector pela entrada Beats, o Vector transforma os nomes de atributos ECS nas convenções semânticas do OTel (
host.hostname→host.name,service→service.nameetc.) e emite OTLP adiante. Isso também oferece um ponto para armazenar em buffer e bifurcar o tráfego durante a execução paralela. - Após a transição completa, podemos desativar o Vector e substituir o Filebeat por um receptor
filelogenxuto do OTel Collector em cada host. É uma troca de uma linha e baixo risco após a transição. - Carga de trabalho 2 (OTel Demo): nenhuma alteração — o
otelcol-demoexistente recebe um segundo exportador apontando para o ClickHouse (pelo componente contribclickhouseexporter), além daquele que aponta para o APM Server.
Decisão 3: Estratégia de esquema
| Tipo de log | Abordagem | ORDER BY proposto | Justificativa |
|---|---|---|---|
logs-web_access-lab | Personalizada com colunas materializadas | (ServiceName, Status, toUnixTimestamp(Timestamp)) | Dashboards mais acessados filtram por serviço + status. Status, RequestPath, RunTime e CountryName materializados aceleram a varredura colunar. |
logs-application-lab | Personalizada com colunas materializadas + filtro de Bloom em TraceId | (ServiceName, SeverityText, toUnixTimestamp(Timestamp)) | Buscas de correlação de traces (WHERE TraceId = ?) usam um índice de salto; manter TraceId fora da chave primária preserva a localidade da varredura por intervalo de tempo. |
logs-infrastructure-lab | Personalizada (pré-analisada pelo OTel) | (Hostname, Process, toUnixTimestamp(Timestamp)) | As duas primeiras colunas têm baixa cardinalidade (~10 hosts, ~10 processos) — excelente compactação e poda de consultas por host. |
Traces APM (traces-apm-*) | Esquema OTel padrão + filtro de Bloom em TraceId após a criação | (ServiceName, Timestamp, TraceId) | O esquema OTel padrão é mapeado diretamente pelo clickhouseexporter do OTel Collector, mas o exportador NÃO adiciona um índice de salto em TraceId — nós mesmos o adicionamos (veja abaixo). |
Justificativa geral:
Evitamos intencionalmente o padrão de tabela Null + MV aqui. Ele adiciona uma camada de indireção e custo de CPU durante a ingestão, justificáveis apenas ao duplicar um fluxo bruto em vários destinos agregados. Nesta migração, gravamos diretamente no MergeTree de destino; derivações caras (consulta geográfica, regex de user-agent) ficam em colunas materializadas para serem calculadas na inserção e reutilizadas em todas as consultas.
Etapa obrigatória após a criação de otel_traces: o esquema padrão do clickhouseexporter não inclui um índice de salto em TraceId. Sem ele, as buscas por ID de trace (Consulta 5 do Exercício 2B) fariam uma varredura completa. Execute uma vez depois que o exportador criar a tabela:
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 granulesFazemos o mesmo para SpanId caso as buscas entre spans se tornem um caminho muito acessado.
Evolução do esquema — nossa escolha: Híbrida — Map por padrão, promoção de campos mais usados por revisão periódica.
Por padrão, todos os atributos desconhecidos chegam ao Map LogAttributes / SpanAttributes (comportamento do exportador). Uma vez por sprint, a equipe de plataforma executa a consulta a seguir nos dados da última semana:
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 chave que (a) aparece em ≥ 30 % das linhas E (b) é referenciada em ≥ 2 dashboards ou alertas é promovida com:
ALTER TABLE otel_logs ADD COLUMN <NewCol> String MATERIALIZED LogAttributes['<key>'];A promoção é uma decisão da equipe de plataforma (não de cada serviço), para manter o esquema pequeno e consistente.
Por que não promover automaticamente por um gerador de DDL? Executar ALTERs em resposta a sinais da ingestão é arriscado — um atributo ruidoso classificado incorretamente (request_id, UUIDs semelhantes a IDs de trace) vira uma coluna de alta cardinalidade e prejudica a compactação. A cadência com análise humana é uma proteção barata e evita o crescimento involuntário do esquema.
Por que não usar o tipo de coluna JSON? Map(LowCardinality(String), String) é a opção mais antiga e amplamente implantada, com comportamento previsível para os padrões de acesso mapContains / LogAttributes['key']. O tipo JSON pode ter melhor desempenho em escala, mas ainda não o avaliamos nesta carga de trabalho específica. Reconsidere caso o espaço de chaves do Map ultrapasse ~200 chaves distintas, o que começaria a afetar a eficiência do dicionário LowCardinality.
O que isso significa para a Carga de trabalho 2 (OTel Demo): o clickhouseexporter processa atributos dinâmicos de forma nativa — novos atributos de span emitidos por um serviço aparecem em SpanAttributes na primeira vez em que são vistos, sem necessidade de alterar o coletor ou o esquema. O fluxo de promoção é idêntico ao caso dos logs.
Decisão 4: Tradução do pipeline de ingestão
| Processador do ES | Equivalente na stack de destino | Motivo |
|---|---|---|
geoip em remote_addr | Dicionário (layout IP_TRIE sobre CSV GeoLite2) + dictGet() em coluna materializada | Mantém os coletores sem estado. As atualizações do dicionário no backend são uma única DDL; não precisamos reimplantar cada nó do Vector/Filebeat. |
user_agent em user_agent | Processador user_agent do OTel Collector | O processador corresponde diretamente à implementação da Elastic e emite atributos analisados (user_agent.name, user_agent.os.name etc.). O esquema do backend permanece mínimo. |
Análise de syslog com grok | Operador regex_parser do OTel Collector no receptor filelog | Analisa uma vez na borda. O ClickHouse recebe linhas já estruturadas. Regex no lado do CH consumiria mais CPU em escala de ingestão e seria mais difícil de ajustar. |
Derivação de severidade com script | Coluna MATERIALIZED multiIf(...) | A lógica (status HTTP → severidade, palavra-chave → severidade) é trivial em SQL, executada uma vez na inserção e permanece junto ao esquema, evitando divergências da tabela. |
set event.ingested = _ingest.timestamp | Coluna DEFAULT now() | Mesma semântica, uma palavra-chave. Nenhum processador é necessário. |
dissect na message do aplicativo | Remover — na prática, raramente corresponde, e os campos que seriam extraídos não são consultados. | Processador sem utilidade identificado durante o Exercício 2A. |
Quais processadores podem ser eliminados por completo? O processador dissect é peso morto. O pipeline set event.ingested é redundante quando usamos uma coluna DEFAULT now(). Isso elimina 2 pipelines da migração.
Decisão 5: Ciclo de vida dos dados
Política ILM atual do ES (lab-observability-policy):
- Hot:
rolloveraos 5 GB ou 1 dia, prioridade 100 - Warm (aos 2 dias):
shrinkpara 1 shard,forcemergepara 1 segmento, prioridade 50 - Delete: aos 30 dias
Veredito por fase:
| Ação do ILM | Ainda é necessária? | Substituição |
|---|---|---|
rollover aos 5 GB / 1 d | Não — o ClickHouse tem uma tabela com partições por intervalo de datas; não há índices rotativos para gerenciar. | Particionar por toYYYYMM(Timestamp) |
shrink para 1 shard aos 2 d | Não — shards lógicos escalam automaticamente; não é necessária nenhuma intervenção em shards físicos. | N/A |
forcemerge para 1 segmento aos 2 d | Não — o processo de mesclagem em segundo plano do MergeTree cuida disso de forma automática e contínua. | N/A |
set_priority 100 → 50 | Não — o ClickHouse Cloud não tem o conceito de prioridade por camada de nós. | N/A |
| Migração para camada cold/frozen | Não — o ClickHouse Cloud armazena todos os dados em armazenamento de objetos, com cache local automático de leitura. Não existe uma camada "cold" para a qual migrar; dados antigos permanecem no mesmo armazenamento de objetos e entram no cache sob demanda. | N/A |
delete aos 30 d | Sim — continua sendo a única ação do ciclo de vida com finalidade semântica. | TTL … DELETE |
Período de retenção: 30 dias (igual ao ES atual).
Cláusula TTL (por tabela):
TTL toDateTime(Timestamp) + INTERVAL 30 DAY DELETEJustificativa:
Cinco das seis ações do ILM desaparecem. Esta é a maior simplificação operacional da migração — o ILM tinha mais de 200 linhas de política + monitoramento das transições de fase + dashboards do estado dos índices, tudo substituído por uma cláusula TTL por tabela. Acabam os chamados perguntando "por que este índice está preso na fase warm?".
Decisão 6: Migração de alertas
Escolha da ferramenta: Grafana Alerting com a fonte de dados do ClickHouse para ambas as regras.
Justificativa:
- O Grafana tem uma fonte de dados de primeira classe para ClickHouse e um mecanismo de alertas maduro (agendamento, desduplicação, silêncios e roteamento para Slack/PagerDuty). Não precisamos criar um próprio.
- O HyperDX oferece alertas, mas eles têm menos recursos que o Grafana Alerting para regras de limite numérico + janela de tempo.
- MVs pré-calculadas são uma alternativa válida para consultas de alerta caras; nestas duas regras, a consulta é barata, portanto SQL no lado do Grafana é suficiente.
Alta taxa de erros — implementação:
-- 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;Agendamento: Avaliar a cada 1 minuto, aguardando 2 avaliações consecutivas antes de disparar (evita avisos por picos isolados).
Heartbeat do serviço — implementação:
-- 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;Agendamento: Avaliar a cada 1 minuto. Cada linha emitida aciona uma instância de alerta separada, roteada pelo nome do serviço.
Novos recursos possibilitados pelo ClickHouse:
- Joins em consultas de alerta (o Kibana não faz isso). A regra de heartbeat acima une um conjunto de "serviços conhecidos" a um conjunto de "vistos recentemente". No ES, precisaríamos manter a lista de serviços conhecidos separadamente.
- Funções de janela e
sequenceMatchpara detecção de anomalias complexas — "avisos disparados quando um serviço tem 3 picos consecutivos de 5xx em 10 minutos". - CTEs + subconsultas — condições de alerta mais sofisticadas sem recorrer a scripts do Watcher.
Decisão 7: Estratégia para dados históricos
Escolha: Começar do zero. O ClickHouse recebe apenas dados novos; o ES permanece somente para leitura por 90 dias, depois é capturado em snapshot e desativado.
Justificativa:
- O valor do histórico de tendências é limitado para esta carga. Logs e traces são usados principalmente na resposta a incidentes (últimas 24 h) e na revisão semanal de tendências. Uma retenção de 30 d é suficiente; 90 d de ES somente para leitura atendem a qualquer necessidade de "consultar o último trimestre" durante a transição.
- O risco das ferramentas de backfill é real. O
elasticdumpou um script da API de scroll pode mover ~62 milhões de documentos, mas a desduplicação é frágil — qualquer nova tentativa ou falha parcial introduz duplicatas emotel_logs. UsarReplacingMergeTreepara desduplicar é possível, mas adia o problema e complica a semântica das consultas durante a janela de replay. - O custo de armazenamento da duplicação de ~62 milhões de documentos é significativo durante a janela de transição e não agrega valor operacional depois que o ES fica somente para leitura.
- A janela de 90 dias com o ES somente para leitura é uma proteção barata. Se descobrirmos uma regressão em um dashboard ou precisarmos de um histórico maior em uma investigação, o ES continuará consultável. O HyperDX aceita várias fontes de dados, permitindo rotear os "últimos 30 dias" ao ClickHouse e consultas mais antigas ao ES de modo transparente.
Plano para o ES somente para leitura:
| Dia | Ação |
|---|---|
| 0 (transição) | Interromper gravações do Filebeat. Interromper gravações do APM Server. Manter o cluster ES em execução. Trocar a fonte de dados padrão do HyperDX/Grafana de ES → ClickHouse. |
| 0 – 90 | Os dashboards fornecem os "últimos 30 dias" pelo CH. Consultas históricas de longo alcance são roteadas ao ES como fonte de dados secundária do HyperDX. |
| 90 | POST _snapshot/backup_repo/final_snapshot → S3. Desativar o cluster ES. O snapshot pode ser restaurado em uma instância temporária do ES se algum dia for necessário. |
Se o backfill se tornar necessário mais tarde (plano de contingência):
Reutilize a ponte do Vector da Decisão 2:
elasticdump \
--input=http://es:9200/logs-web_access-lab \
--output=http://vector:8686/_bulk \
--type=data \
--limit=10000-
Taxa esperada: ~50 mil documentos/s de forma sustentada (o Vector envia lotes ao exportador do ClickHouse).
-
Tempo estimado do replay completo: ~62 milhões de documentos ≈ 20 minutos de ponta a ponta.
-
Estratégia de desduplicação: ingerir o replay do ES em uma tabela de staging (mesmo esquema de
otel_logs) e depois mesclar na tabela ativa comSELECT DISTINCT ONpara consolidar linhas de novas tentativas. Simples, sem alterar o mecanismo:-- 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;Para um replay único de ~62 milhões de linhas em 20 minutos, isso supera um mecanismo de staging
ReplacingMergeTree: menos componentes, nenhum momento de mesclagem em segundo plano para coordenar e a chave de desduplicação fica explícita no local doDISTINCT ON. -
Para backfills incrementais ou de maior duração (por exemplo, replay progressivo de meses de dados), uma tabela de staging
ReplacingMergeTreecom uma coluna de versãoMATERIALIZED ContentHashnoORDER BYé a ferramenta adequada — consulte a documentação do ClickHouse sobreReplacingMergeTree; essa DDL está fora do escopo deste laboratório. -
Verificação de completude: compare as contagens diárias de documentos entre ES (
_countcom consultarange) e CH (count() WHERE toDate(Timestamp) = ...). Espere uma discrepância ≤ 0,01 % decorrente do momento da ingestão.
Riscos e questões em aberto
- Vector como ponte adiciona um salto e um domínio de falha durante a execução paralela. É aceitável por 2 semanas; queremos desativá-lo antes de declarar a transição completa.
- Cadência de atualização do dicionário GeoIP — o MaxMind GeoLite2 é atualizado semanalmente; precisamos de um job cron/Airflow para obter o CSV mais recente e executar
SYSTEM RELOAD DICTIONARYtodas as noites. - Campos de alta cardinalidade (por exemplo,
trace.id,remote_addr) — precisamos confirmar o desempenho do filtro de Bloom + índice de salto em uma janela móvel de 30 dias. Planejamos realizar teste de carga comclickhouse-benchmarkusando 30× o volume diário atual antes da transição. - Migração dos dashboards do Kibana → HyperDX/Grafana — o HyperDX ingere esquemas nativos do OTel sem problemas, mas teremos de reconstruir os 6 dashboards manualmente. Orçamento: 1 dia de engenharia por dashboard.
- Modelo de custos no ClickHouse Cloud — precisamos de uma estimativa de dimensionamento baseada no volume após a compactação (redução esperada de ~10×), QPS das consultas e camada de expansão da computação.