DevOps Admin

Dominando Jaeger e Zipkin: Distributed Tracing em Microsserviços em Projetos Reais Já leu

Introdução ao Distributed Tracing A arquitetura de microsserviços revolucionou a forma como desenvolvemos aplicações, permitindo escalabilidade e independência entre serviços. No entanto, ela introduziu um desafio crítico: como rastrear uma requisição quando ela atravessa múltiplos serviços? Quando um usuário faz uma requisição que passa por 5, 10 ou 20 serviços diferentes, entender o que aconteceu em cada etapa e onde surgem gargalos se torna extremamente complexo. O Distributed Tracing resolve este problema capturando toda a jornada de uma requisição através dos serviços. Diferentemente dos logs tradicionais, que registram eventos isolados, o tracing fornece uma visão holística de como a requisição flui pela arquitetura. O Jaeger e o Zipkin são os dois principais frameworks open-source para implementar distributed tracing em produração, cada um com suas características distintas e casos de uso específicos. Por que Distributed Tracing é essencial em microsserviços Em um monólito, você pode simplesmente analisar logs de uma única aplicação. Em microsserviços, a mesma operação de negócio envolve múltiplos

Introdução ao Distributed Tracing

A arquitetura de microsserviços revolucionou a forma como desenvolvemos aplicações, permitindo escalabilidade e independência entre serviços. No entanto, ela introduziu um desafio crítico: como rastrear uma requisição quando ela atravessa múltiplos serviços? Quando um usuário faz uma requisição que passa por 5, 10 ou 20 serviços diferentes, entender o que aconteceu em cada etapa e onde surgem gargalos se torna extremamente complexo.

O Distributed Tracing resolve este problema capturando toda a jornada de uma requisição através dos serviços. Diferentemente dos logs tradicionais, que registram eventos isolados, o tracing fornece uma visão holística de como a requisição flui pela arquitetura. O Jaeger e o Zipkin são os dois principais frameworks open-source para implementar distributed tracing em produração, cada um com suas características distintas e casos de uso específicos.

Por que Distributed Tracing é essencial em microsserviços

Em um monólito, você pode simplesmente analisar logs de uma única aplicação. Em microsserviços, a mesma operação de negócio envolve múltiplos serviços, cada um rodando em containers diferentes, possivelmente em máquinas diferentes. Sem tracing distribuído, investigar um problema de performance ou uma falha se torna uma caça ao tesouro. O distributed tracing permite que você visualize o tempo gasto em cada serviço, identifique gargalos, detecte falhas intermitentes e correlacione eventos em diferentes serviços automaticamente.

Conceitos Fundamentais de Distributed Tracing

Trace, Span e Baggage

Um trace é o registro completo de uma requisição passando por toda a arquitetura. Pense nele como uma unidade de trabalho lógica que pode envolver múltiplos serviços. Cada trace é identificado por um trace_id único e imutável.

Um span é uma operação individual dentro de um trace. Se um trace é a jornada completa, um span é cada parada nessa jornada. Cada span possui: um span_id único, um trace_id (herdado do trace pai), um parent_span_id (se aplicável), timestamps de início e fim, e tags/logs adicionais. Os spans formam uma relação hierárquica em forma de árvore, permitindo visualizar a estrutura exata da execução.

Baggage é metadados que viajam junto com o trace através de todos os serviços. Exemplos: user_id, request_id customizado, tenant_id. O baggage permite correlacionar logs e métricas de diferentes serviços ao mesmo contexto de negócio.

Trace (user_id: 123, request_id: abc-xyz)
├── Span: HTTP GET /api/usuarios/123 (serviço A)
│   ├── Span: Query database (serviço A)
│   └── Span: HTTP GET /api/detalhes/123 (serviço B)
│       ├── Span: Cache lookup (serviço B)
│       └── Span: Query database (serviço B)
└── Span: Response assembly (serviço A)

Context Propagation

Para que diferentes serviços "saibam" que fazem parte do mesmo trace, o contexto (trace_id, span_id, baggage) deve ser propagado entre eles. Isso geralmente é feito através de headers HTTP. Os padrões mais comuns são:

  • Jaeger Headers: uber-trace-id, jaeger-baggage
  • W3C Trace Context (padrão moderno): traceparent, tracestate, baggage
  • Zipkin Headers: X-B3-TraceId, X-B3-SpanId, X-B3-ParentSpanId

Quando o serviço A faz uma chamada HTTP para o serviço B, inclui esses headers. O serviço B extrai o contexto, cria seus próprios spans como filhos, e continua propagando para o serviço C. Assim, toda a cadeia permanece conectada.

Jaeger: Arquitetura, Instalação e Uso Prático

Arquitetura do Jaeger

O Jaeger é mantido pela Cloud Native Computing Foundation e oferece uma arquitetura modular com três componentes principais:

  1. Jaeger Client: biblioteca que instrumento seu código para gerar spans
  2. Jaeger Agent: daemon que coleta spans dos clientes (porta UDP 6831 por padrão)
  3. Jaeger Backend: processa, armazena e disponibiliza a UI para consulta

A comunicação flui assim: sua aplicação → Jaeger Client → Jaeger Agent → Jaeger Collector → storage (Elasticsearch, Badger, etc.) → Jaeger Query UI.

Jaeger suporta múltiplas linguagens (Go, Java, Python, Node.js, C++, etc.) através de bibliotecas cliente. Para Java, usamos o jaeger-client, para Python jaeger-client, e assim por diante.

Instalação e Configuração

A forma mais prática de começar é com Docker Compose. Crie um arquivo docker-compose.yml:

version: '3'

services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "6831:6831/udp"  # Jaeger agent (Thrift compact)
      - "16686:16686"     # Jaeger UI
      - "14268:14268"     # Jaeger collector HTTP
    environment:
      - COLLECTOR_ZIPKIN_HOST_PORT=:9411

Suba com docker-compose up -d. A UI estará em http://localhost:16686.

Instrumentação em Python

Vamos criar dois microsserviços simples em Flask com tracing do Jaeger. Primeiro, instale as dependências:

pip install flask jaeger-client opentelemetry-api opentelemetry-sdk

Serviço A (porta 5000):

from flask import Flask, request
import requests
from jaeger_client import Config
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry import trace

app = Flask(__name__)

# Configurar Jaeger
def init_jaeger_tracer(service_name):
    config = Config(
        config={
            'sampler': {'type': 'const', 'param': 1},
            'local_agent': {'reporting_host': 'localhost', 'reporting_port': 6831},
            'logging': True,
        },
        service_name=service_name,
    )
    return config.initialize_tracer()

tracer = init_jaeger_tracer('servico-a')

# Instrumentar Flask e requests automaticamente
FlaskInstrumentor().instrument_app(app)
RequestsInstrumentor().instrument()

@app.route('/processar/<usuario_id>')
def processar(usuario_id):
    # Criar span manual se necessário
    with tracer.start_active_span('buscar-dados-usuario') as scope:
        scope.span.set_tag('usuario_id', usuario_id)

        # Chamar serviço B
        response = requests.get(f'http://localhost:5001/dados/{usuario_id}')
        dados = response.json()

    return {'resultado': 'sucesso', 'dados': dados}

if __name__ == '__main__':
    app.run(port=5000, debug=False)

Serviço B (porta 5001):

from flask import Flask
from jaeger_client import Config
from opentelemetry.instrumentation.flask import FlaskInstrumentor

app = Flask(__name__)

def init_jaeger_tracer(service_name):
    config = Config(
        config={
            'sampler': {'type': 'const', 'param': 1},
            'local_agent': {'reporting_host': 'localhost', 'reporting_port': 6831},
            'logging': True,
        },
        service_name=service_name,
    )
    return config.initialize_tracer()

tracer = init_jaeger_tracer('servico-b')
FlaskInstrumentor().instrument_app(app)

@app.route('/dados/<usuario_id>')
def obter_dados(usuario_id):
    with tracer.start_active_span('query-database') as scope:
        scope.span.set_tag('usuario_id', usuario_id)
        # Simular operação de banco
        dados = {'id': usuario_id, 'nome': 'João Silva', 'email': 'joao@example.com'}

    return dados

if __name__ == '__main__':
    app.run(port=5001, debug=False)

Agora, inicie ambos os serviços em terminais diferentes:

python servico_a.py
python servico_b.py

Faça uma requisição:

curl http://localhost:5000/processar/123

Acesse http://localhost:16686 e procure pelo trace. Você verá toda a cadeia: Serviço A → Serviço B, com tempos de execução de cada span.

Zipkin: Diferenças, Instalação e Uso Prático

Características do Zipkin

Zipkin é um projeto mais maduro (origem no Twitter) focado em simplicidade e performance. Enquanto Jaeger oferece maior flexibilidade e features avançadas, Zipkin destaca-se por ser leve e direto. Zipkin também suporta múltiplas linguagens e usa o padrão B3 de headers de propagação de contexto.

A arquitetura é similar: clientes geram spans, enviam para o Zipkin (via HTTP ou Kafka), dados são armazenados e consultados na UI. Zipkin oferece suporte nativo a Elasticsearch, MySQL e Cassandra como backends.

Instalação com Docker Compose

Crie um docker-compose.yml para Zipkin:

version: '3'

services:
  zipkin:
    image: openzipkin/zipkin:latest
    ports:
      - "9410:9410"   # Zipkin UI (diferente de Jaeger!)
      - "9411:9411"   # Zipkin collector HTTP

Levante com docker-compose up -d. A UI estará em http://localhost:9410.

Instrumentação em Java (Spring Boot)

Para Java com Spring Boot, o processo é ainda mais simples. Adicione ao pom.xml:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-sleuth-zipkin</artifactId>
</dependency>

E configure no application.yml:

spring:
  zipkin:
    base-url: http://localhost:9411/
    sender:
      type: web
  sleuth:
    sampler:
      probability: 1.0  # Coletar 100% dos traces (ajuste em produção)

Serviço A (porta 8080):

package com.exemplo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class ServicoA {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }

    public static void main(String[] args) {
        SpringApplication.run(ServicoA.class, args);
    }
}

@RestController
class ControladorA {

    private final RestTemplate restTemplate;

    public ControladorA(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @GetMapping("/processar/{usuarioId}")
    public Map<String, Object> processar(@PathVariable String usuarioId) {
        // O Spring Sleuth automaticamente adiciona headers B3 a cada requisição
        String dados = restTemplate.getForObject(
            "http://localhost:8081/dados/" + usuarioId,
            String.class
        );

        return Map.of("resultado", "sucesso", "dados", dados);
    }
}

Serviço B (porta 8081):

package com.exemplo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
public class ServicoB {
    public static void main(String[] args) {
        SpringApplication.run(ServicoB.class, args);
    }
}

@RestController
class ControladorB {

    @GetMapping("/dados/{usuarioId}")
    public Map<String, Object> obterDados(@PathVariable String usuarioId) {
        // Simular consulta ao banco
        return Map.of(
            "id", usuarioId,
            "nome", "Maria Santos",
            "email", "maria@example.com"
        );
    }
}

Configure as portas em application.yml de cada serviço:

server:
  port: 8080  # ou 8081 para Serviço B

Após iniciar ambos, faça:

curl http://localhost:8080/processar/123

Acesse http://localhost:9410 e procure pelos traces. Você verá a chamada de A para B rastreada automaticamente, pois o Spring Sleuth injeta os headers B3 transparentemente.

Comparação Prática: Jaeger vs Zipkin

Características-chave

Aspecto Jaeger Zipkin
Origem Uber (CNCF) Twitter
Maturidade Mais recente, inovador Mais estabelecido
Sampling Mais flexível (adaptive sampling) Básico (fixed rate)
Storage Elasticsearch, Badger, Cassandra Elasticsearch, MySQL, Cassandra
Headers Jaeger, W3C Trace Context B3 (padrão)
Curva de aprendizado Moderada Suave
Performance Excelente com Badger Excelente em geral
UI Avançada, com gráficos complexos Simples e intuitiva
Deploy Mais componentizado Mais monolítico

Quando usar cada um

Use Jaeger se: você precisa de sampling adaptativo baseado em taxa de erro, quer máxima flexibilidade na propagação de contexto (W3C), trabalha em ambiente Kubernetes/CNCF ou precisa de features avançadas como trace comparisons e node graphs.

Use Zipkin se: você quer simplicidade, já trabalha com Spring Boot/Spring Cloud, precisa de uma solução leve, ou prefere uma UI mais minimalista que focam em essencial.

Na prática, ambos resolvem o problema de distributed tracing de forma excelente. A escolha muitas vezes depende do seu ecossistema existente e preferências de arquitetura.

Padrões Avançados e Boas Práticas

Sampling em Produção

Coletar 100% dos traces em produção é caro e desnecessário. Implementar sampling adequado é crítico. Jaeger oferece várias estratégias:

Constant Sampling: coleta uma fração fixa (ex: 10% de todos os traces)

Probabilistic Sampling: coleta aleatoriamente com uma probabilidade

Rate Limiting Sampling: coleta até um máximo de spans por segundo

Remote Sampling: o servidor Jaeger decide qual percentage usar dinamicamente

Para Zipkin com Spring Boot:

spring:
  sleuth:
    sampler:
      probability: 0.1  # 10% dos traces

Para maior controle com Jaeger e Python:

config = Config(
    config={
        'sampler': {
            'type': 'probabilistic',
            'param': 0.1,  # 10%
        },
    },
    service_name='meu-servico',
)

Baggage para Contexto de Negócio

Propagar contexto de negócio como user_id ou tenant_id é essencial. Em Java com Sleuth:

import org.springframework.cloud.sleuth.Tracer;

@RestController
public class MeuController {

    private final Tracer tracer;

    public MeuController(Tracer tracer) {
        this.tracer = tracer;
    }

    @GetMapping("/comprar")
    public void comprar(@RequestParam String usuarioId) {
        // Adicionar ao baggage para propagar em todas as chamadas subsequentes
        tracer.getBaggage("usuario_id").set(usuarioId);

        // Chamadas subsequentes incluirão usuario_id automaticamente
        servicoB.processar();
    }
}

Em Python com Jaeger:

with tracer.start_active_span('minha-operacao') as scope:
    scope.span.set_tag('usuario_id', usuario_id)
    scope.span.set_tag('tenant_id', tenant_id)
    # Estes tags ficarão visíveis na UI e nos logs

Logs estruturados integrados com tracing

A verdadeira potência emerge quando logs estruturados incluem trace_id. Assim, você correlaciona logs com traces:

Python com Python Logging:

import logging
from pythonjsonlogger import jsonlogger
from jaeger_client import Config

# Configurar JSON logging
logHandler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter()
logHandler.setFormatter(formatter)
logger = logging.getLogger()
logger.addHandler(logHandler)
logger.setLevel(logging.INFO)

config = Config(
    config={
        'sampler': {'type': 'const', 'param': 1},
        'local_agent': {'reporting_host': 'localhost', 'reporting_port': 6831},
    },
    service_name='meu-servico',
)
tracer = config.initialize_tracer()

@app.route('/operacao')
def operacao():
    span = tracer.start_span('operacao-principal')

    # Adicionar trace_id ao contexto de logging
    logger.info('Iniciando operação', extra={
        'trace_id': span.context.trace_id,
        'span_id': span.context.span_id,
    })

    # ... seu código ...

    span.finish()

Resultado nos logs: {"trace_id": 123456, "span_id": 789, "message": "Iniciando operação"}. Agora você pode filtrar logs por trace_id na mesma ferramenta que filtra traces.

Conclusão

Três aprendizados principais que você deve levar consigo:

  1. Distributed Tracing é não-negociável em microsserviços: sem ele, você está navegando às cegas. Traces conectam eventos isolados em um contexto único, permitindo investigação rápida de problemas e identificação de gargalos reais.

  2. Jaeger e Zipkin diferem em filosofia, não em core function: Jaeger é mais flexível e componentizado (melhor para ambientes complexos), Zipkin é mais leve e direto (melhor para começar simples). Ambos resolvem o problema. Escolha com base no seu ecossistema, não por modismo.

  3. Sampling e baggage são a base de um tracing efetivo em produção: coletar tudo é inviável e caro. Implementar sampling correto (10-25% em produção) reduz custos drasticamente. Propagar contexto de negócio (user_id, tenant_id) através de baggage permite correlacionar eventos entre serviços em nível de lógica de negócio, não apenas técnico.

Referências


Artigos relacionados