Tracing distribuído com Jaeger

Neste artigo, você vai aprender:

  • Os principais conceitos sobre OpenTracing;
  • Tudo sobre Jeager e seu ecossistema;
  • Como implementar rastreamento distribuído em microsserviços.

Trace pra que te quero?

Uma das certezas da vida é a de que todo sistema falha; e quando isso acontece, é preciso estar preparado.

A maioria das falhas que acontecem no universo dos microsserviços ocorrem durante as interações entre os serviços. O que não falta é subsistema responsável por resolver um problema muito específico se comunicando com outro subsistema, que depende de outro serviço externo, e assim por diante.

A decomposição de funcionalidades de negócio em serviços independentes permite que pequenos times atuem com autonomia, cada um com sua tecnologia e seu método de trabalho. No entanto, com a interdependência entre os microsserviços, e a quantidade de subsistemas que se comunicam, entender o comportamento de uma requisição até o fim do seu processamento se torna algo desafiador.

É fácil imaginar o volume de troca de dados e a quantidade de interações entre esses sistemas, por isso, uma visão dessas interações ajuda a gerenciar melhor a arquitetura, e sinaliza quando há necessidade de reparo ou otimização do desempenho.

A capacidade de observação (observability) em uma arquitetura de microsserviços torna mais fácil ver o que está acontecendo, quando os serviços interagem entre si, tornando possível manter uma solução mais eficiente, resiliente e segura.

Algumas métricas ajudam a medir o bom funcionamento de uma solução baseada em microsserviços. As mais conhecidas são:

  • Taxa de sucesso (Success rates)
  • Volume de requisição (Request volume)
  • Duração da requisição (Request duration)
  • Tamanho da requisição (Request size)
  • Taxa de erros por requisição (Request error rate)
  • Latência (Latency)

Como o rastreamento distribuído pode ajudar?

Entender o comportamento dos serviços por todo caminho percorrido por uma requisição não é fácil. Quem já perdeu horas revirando logs, tentando encontrar a causa de um erro em produção sabe bem o que é.

Para resolver a dificuldade de rastreamento e monitoramento distribuído em vários sistemas foi que surgiu o padrão OpenTracing. Na verdade, podemos dizer que OpenTracing é um tópico relacionado ao rastreamento distribuído.

O tracing distribuído rastreia uma única solicitação em todo o seu percurso, da origem ao destino, ao contrário das formas tradicionais de rastreamento, que apenas seguem uma requisição por meio de um único domínio de aplicação. Em outras palavras, podemos dizer que o rastreamento distribuído “costura” as múltiplas requisições que envolvem os diversos sistemas que se relacionam.

A junção é geralmente feita por um ou mais identificadores usados para correlacionar eventos de log estruturados e registrados em todos os sistemas, armazenados em um local central.

Conhecendo o Jaeger

O Jaeger usa o rastreamento distribuído para seguir o caminho de uma solicitação por diferentes microsserviços.
Antes de colocar a mão na massa e ver como ele funciona na prática, precisamos conhecer algumas definições:

Span

É a unidade que compõe o trace. É ele que possui informações do contexto que está sendo instrumentado.

Trace

Representa dados e/ou caminho de execução através do sistema. É a rastreabilidade de todos os microsserviços envolvidos para atender uma solicitação.

(fonte: https://www.jaegertracing.io/docs/1.21/architecture)

Como o rastreamento é feito?

Como comentei anteriormente, o Jeager faz a junção das solicitações repassando identificadores que correlacionam as entradas. Ele utiliza métodos de Inject e Extract para propagar dados de rastreamento entre os serviços, simplesmente injetando e extraindo dados de cabeçalho HTTP.

Um serviço instrumentado pelo Jaeger cria spans ao receber novas solicitações e anexa informações de contexto (id de rastreamento, id de span e baggage) à chamada ao próximo serviço. Apenas os ids e baggages são propagados com os pedidos; todas as outras entradas, como dados de profiling, nome da operação, tempo, tags e logs, não são propagadas. Em vez disso, são transmitidas fora do processo para o back-end do Jaeger, de forma assíncrona, em segundo plano, para evitar a sobrecarga do sistema.

Propagação de um contexto
(fonte: https://www.jaegertracing.io/docs/1.21/architecture)

Ecossistema do Jaeger

O Jaeger possui vários componentes que funcionam juntos para coletar, armazenar e visualizar spans e traces, cada qual com a sua finalidade:

Agent 

É um daemon de rede que detecta spans enviados em pacotes UDP. O agente deve estar no mesmo host da aplicação instrumentada. Normalmente, isso é implementado por meio de um sidecar em ambientes de containers, como o Kubernetes.

Collector

Responsável por receber os spans e os colocar em uma pipeline de processamento.
Esses coletores precisam de um back-end de armazenamento plugável. Atualmente, o Jaeger oferece suporte a Cassandra, Elasticsearch e Kafka.

Query

Serviço que recupera traces armazenados e os expõe através de uma interface amigável.Através da UI do Jaeger, é muito fácil analisar uma requisição do início ao fim da solicitação.

Ela representa visualmente o fluxo da chamada, assim não precisamos adivinhar e perder horas revirando logs para encontrar eventuais problemas.

Ingester

Serviço que lê mensagens de um tópico do Kafka e as escreve em outro meio de armazenamento (Cassandra, Elasticsearch).

Agora que vimos os principais conceitos de rastreamento distribuído e conhecemos o ecossistema do Jeager, é hora de colocar a mão na massa.

Jaeger com ASP.NET Core e .NET 5.0

Vamos implementar dois microsserviços para demonstrar o funcionamento do Jeager com rastreamento de uma solicitação do início ao fim.

O Serviço A irá receber uma solicitação e, em seguida, irá efetuar uma nova solicitação ao Serviço B. Após obter a resposta, o Serviço A irá tratar os dados recebidos e retornar os mesmos dados obtidos do Serviço B para o solicitante.

Preparando o ambiente

Antes de implementar os microsserviços, vamos configurar o Jaeger no ambiente local.

All-in-one é um executável projetado para testes locais rápidos. Ele inicia a IU Jaeger, Collector, Query e Agent, com um componente de armazenamento em memória.

A maneira mais simples de iniciar o Jaeger All-in-one é utilizando a imagem disponível no Docker Hub.
Então, vamos começar criando o arquivo docker-compose.yaml para orquestrar nossos containers, começando pelo serviço do Jaeger.

version: '3.7'

services:           
    jaeger: 
        image: jaegertracing/all-in-one:latest
        ports:
                - "5775:5775/udp"
                - "6831:6831/udp"
                - "6832:6832/udp"
                - "5778:5778"
                - "16686:16686"
                - "14268:14268"
                - "9411:9411"

Criando o Serviço A

Agora, vamos criar o projeto Order.API, que será a primeira aplicação a receber a solicitação. Criaremos um projeto ASP.NET Core, que terá apenas o recurso que irá chamar a segunda aplicação, no caso, a Payment.API.

Antes disso, vamos criar uma class library para configurar os serviços de tracing e instrumentar as tarefas de tracing das duas aplicações. Vamos chamar o projeto de Common, e ele será referenciado nos dois microsserviços.

Primeiro, vamos adicionar as bibliotecas do OpenTracing e do Jeager ao projeto:

$ dotnet add package add Jaeger
$ dotnet add package add OpenTracing
$ dotnet add package add OpenTracing.Contrib.NetCore

Adicionadas as bibliotecas, vamos criar o método AddJaeger para configurar e registrar o serviço de tracing que será usado pelas aplicações.

public static class Extension
{
    public static IServiceCollection AddJaeger(this IServiceCollection services)
    {
       services.AddSingleton<ITracer>(serviceProvider =>
        {
            var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();

            var senderConfig = new SenderConfiguration(loggerFactory)
             .WithAgentHost(Environment.GetEnvironmentVariable("JAEGER_AGENT_HOST"))
             .WithAgentPort(Convert.ToInt32(Environment.GetEnvironmentVariable("JAEGER_AGENT_PORT")));

            SenderConfiguration.DefaultSenderResolver = new SenderResolver(loggerFactory)
                .RegisterSenderFactory<ThriftSenderFactory>();

            var config = Configuration.FromEnv(loggerFactory);

            var samplerConfiguration = new SamplerConfiguration(loggerFactory)
                .WithType(ConstSampler.Type)
                .WithParam(1);

            var reporterConfiguration = new ReporterConfiguration(loggerFactory)
                .WithSender(senderConfig)
                .WithLogSpans(true);

            var tracer = config
                .WithSampler(samplerConfiguration)
                .WithReporter(reporterConfiguration)
                .GetTracer();

            GlobalTracer.Register(tracer);

            return tracer;
        });

        return services;
    }
}

As variáveis de ambiente JAEGER_AGENT_HOST e JAEGER_AGENT_PORT armazenam o host e porta do Agent do Jeager. Elas serão configuradas no container das aplicações.

Também vamos implementar um HttpHandler que irá propagar o SpanContext para o segundo serviço que será solicitado. Vamos chamá-lo de InjectOpenTracingHeaderHandler.

public class InjectOpenTracingHeaderHandler : DelegatingHandler
{
    private readonly ITracer tracer;

    public InjectOpenTracingHeaderHandler(ITracer tracer)
    {
        this.tracer = tracer;
    }

    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        if (request.Method == HttpMethod.Get)
        {
            var span = this.tracer.ScopeManager.Active.Span
                .SetTag(Tags.SpanKind, Tags.SpanKindClient)
                .SetTag(Tags.HttpMethod, "GET")
                .SetTag(Tags.HttpUrl, request.RequestUri.ToString());

            var dictionary = new Dictionary<string, string>();

            this.tracer.Inject(span.Context, BuiltinFormats.HttpHeaders, new TextMapInjectAdapter(dictionary));

            foreach (var entry in dictionary)
                request.Headers.Add(entry.Key, entry.Value);
        }

        return base.SendAsync(request, cancellationToken);
    }
}

Em seguida, criaremos os métodos de extensão para configurar o handler na aplicação.

public static class Extension
{
        public static IServiceCollection AddOpenTracingHandler(this IServiceCollection services)
        {
            services.AddTransient<InjectOpenTracingHeaderHandler>();

            return services;
        }

        public static IHttpClientBuilder WithOpenTracingHeaderHandler(this IHttpClientBuilder httpClientBuilder)
        {
            httpClientBuilder.AddHttpMessageHandler<InjectOpenTracingHeaderHandler>();

            return httpClientBuilder;
        }
}

Depois de implementados os métodos, vamos chamá-los no StartUp da aplicação.

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();
        services.AddOpenTracing();
        services.AddOpenTracingHandler();
        services.AddJaeger();
        
        services.AddHttpClient("payment.api", c =>
        {
            c.BaseAddress = new Uri("http://payment_api:80/api/");
        })
        .WithOpenTracingHeaderHandler();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseRouting();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });            
    }
}

Por fim, criamos o recurso da Order.API. Ele irá apenas chamar a Payment.API, nosso Serviço B.

[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
    private readonly ITracer tracer;
    private readonly IHttpClientFactory httpClientFactory;

    public ValuesController(
        ITracer tracer,
        IHttpClientFactory httpClientFactory
        )
    {
        this.tracer = tracer;
        this.httpClientFactory = httpClientFactory;
    }

    [HttpGet]
    public async Task<ActionResult<IEnumerable<string>>> Get()
    {
        var client = httpClientFactory.CreateClient("payment.api");

        using (tracer.BuildSpan("WaitingForValues").StartActive(finishSpanOnDispose: true))
        {
            return JsonConvert.DeserializeObject<List<string>>(
                await client.GetStringAsync("values")
            );
        }
    }
}

Criando o Serviço B

A implementação do Serviço B também será bem simples. Primeiro, vamos implementar o controller da api. Para simular maior tempo de processamento, vamos configurar um Thread.Seleep de 2 segundos. Iremos visualizar o tempo de duração da solicitação durante a análise do trace.

[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
    private readonly ITracer tracer;

    public ValuesController(ITracer tracer)
    {
        this.tracer = tracer;
    }

    // GET api/values
    [HttpGet]
    public ActionResult<IEnumerable<string>> Get()
    {
        using (var scope = StartServerSpan(tracer, "Processing"))
        {
            Thread.Sleep(2000);
            return new[] { "Payment done!" };
        }
    }

    public static IScope StartServerSpan(ITracer tracer, string operationName)
    {
        var spanBuilder = tracer
            .BuildSpan(operationName)
            .WithTag(Tags.SpanKind, Tags.SpanKindServer)
            .StartActive(true);;

        return spanBuilder;
    }
}

Agora, vamos modificar a classe StartUp da aplicação para configurar o rastreamento com Jaeger.

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();
        services.AddOpenTracing();
        services.AddJaeger();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseRouting();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });     
    }
}

Containerizando as aplicações

Criados os serviços, vamos criar o Dockerfile da Order.API.

FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS build
WORKDIR /app

RUN dotnet --version

COPY ./Order.API/*.csproj ./Order.API/
COPY ./Common/*.csproj ./Common/

RUN dotnet restore ./Order.API/*.csproj
COPY . ./
RUN dotnet publish ./Order.API/*.csproj -c Release -o /out

FROM mcr.microsoft.com/dotnet/aspnet:5.0-alpine
COPY --from=build /out /out
WORKDIR /out
EXPOSE 80

ENTRYPOINT ["dotnet", "Order.API.dll"]

Em seguida, o Dockerfile da Payment.API.

FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS build
WORKDIR /app

RUN dotnet --version

COPY ./Payment.API/*.csproj ./Payment.API/
COPY ./Common/*.csproj ./Common/

RUN dotnet restore ./Payment.API/*.csproj
COPY . ./
RUN dotnet publish ./Payment.API/*.csproj -c Release -o /out

FROM mcr.microsoft.com/dotnet/aspnet:5.0-alpine
COPY --from=build /out /out
WORKDIR /out
EXPOSE 80

ENTRYPOINT ["dotnet", "Payment.API.dll"]

Para finalizar, vamos incluir os containers dos serviços no docker-compose.yaml.

version: '3.7'

services:           
    order_api:
        build: 
                context: src
                dockerfile: Order.API/Dockerfile
        ports: 
                - "5001:80"
        environment: 
                - JAEGER_SERVICE_NAME=order_api
                - COLLECTOR_ZIPKIN_HTTP_PORT=9411
                - JAEGER_AGENT_HOST=jaeger
                - JAEGER_AGENT_PORT=6831     
                - JAEGER_SAMPLER_TYPE=const
                - JAEGER_SAMPLING_ENDPOINT=jaeger:5778
        depends_on:
                - jaeger
    payment_api:
        build: 
                context: src
                dockerfile: Payment.API/Dockerfile
        ports: 
                - "5002:80"
        environment: 
                - JAEGER_SERVICE_NAME=payment_api
                - COLLECTOR_ZIPKIN_HTTP_PORT=9411
                - JAEGER_AGENT_HOST=jaeger
                - JAEGER_AGENT_PORT=6831
                - JAEGER_SAMPLER_TYPE=const
                - JAEGER_SAMPLING_ENDPOINT=jaeger:5778
        depends_on:
                - jaeger
    jaeger: 
        image: jaegertracing/all-in-one:latest
        ports:
                - "5775:5775/udp"
                - "6831:6831/udp"
                - "6832:6832/udp"
                - "5778:5778"
                - "16686:16686"
                - "14268:14268"
                - "9411:9411"

Para subir os containers das aplicações e o container dos serviços do Jaeger, basta executar a seguinte instrução no prompt de comando:

$ docker-compose up

Em seguida, fazer uma chamada GET ao serviço recurso /vaues da Order.API para gerar as entradas de trace.

$ curl -X GET http://localhost:5001/api/values

Visualizando traces

Isso é tudo que precisamos para configurar o rastreamento das aplicações. Você começará a ver as entradas aparecerem logo que os serviços forem recebendo requisições. Os traces podem ser vistos através do endereço http://localhost:16686.

Nos detalhes do trace, é possível visualizar uma linha do tempo contendo o tempo de duração e dados de span coletados em cada serviço:

Projeto no Github

O código fonte está disponível no Github em https://github.com/leocosta/DiscountMonitor.

Conclusão

OpenTracing é uma ótima solução para sistemas distribuídos e o Jaeger é uma das implementações mais eficientes para esse tipo de solução. O que você tem usado para rastreamento distribuído em suas aplicações?

Referências

https://www.jaegertracing.io
https://opentracing.io/
https://dzone.com/articles/observability-or-quotknowing-what-your-microservic

Receba meu conteúdo no Telegram

Se você deseja receber outros conteúdos direto no seu celular, entre no meu canal no Telegram.
Lá eu compartilho dicas para você dominar definitivamente a escrita do código limpo.

Hey,

o que você achou deste conteúdo? Conte nos comentários.

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *