Rastreamento com OpenTelemetry e Jaeger

Rastreio, um componente crítico, acompanha pedidos através de sistemas complexos. Esta visibilidade revela engarrafamentos e erros, permitindo resoluções mais rápidas. Em um post anterior da nossa série de serviços web Go, exploramos a importância da observabilidade. Hoje, iremos concentrar-nos no rastreio. O Jaeger coleta, armazena e visualiza traços de sistemas distribuídos. Ele fornece insights cruciais sobre o fluxo de pedidos entre serviços. Conectando o Jaeger com o OpenTelemetry, os desenvolvedores podem unificar seu método de rastreamento, garantindo visibilidade consistente e abrangente. Esta integração simplifica a diagnose de problemas de desempenho e melhora a confiabilidade do sistema. Neste post, vamos configurar o Jaeger, integrá-lo com o OpenTelemetry em nosso aplicativo e explorar a visualização de traços para maiores insights.

Motivação

O que estamos trabalhando para é um painel do Jaeger que parece com isto:

Enquanto nós percorremos diferentes partes do app (no frontend do Onehub), os traços de pedidos variados são coletados (a partir do ponto em que o grpc-gateway é atingido) com um resumo de cada um deles. Até podemos fazer drilldown em um dos traços para uma visão mais detalhada. Vejamos o primeiro POST request (para criar/enviar uma mensagem em um tópico):

Aqui nós vemos todos os componentes que o request Criar toca, juntamente com seus tempos de entrada/saida e o tempo levado para e de dentro dos métodos. Muito poderoso, de fato.

Agora vamos começar

Resumo rápido: Para ver isso em ação e validar o resto do blog:

  1. A fonte deste artigo está no PART11_TRACING branch.
  2. Construa todas as coisas necessárias (depois que você tiver checado o branch):
make build

  1. Nós dividimos o docker-compose em duas partes (isto será explicado posteriormente), então certifique-se de que você tenha duas janelas abertas.
  • Terminal 1: make updb dblogs
  • Terminal 2: make up logs
  1. Navegue em localhost:7080, e vá para o ar.

Visão de nível alto

Nosso sistema atualmente:

Com instrumentação com OpenTelemetry, nosso sistema evoluirá para:

Como mencionamos anteriormente, é muito oneroso para cada serviço usar clientes separados para enviar para determinados fornecedores. Em vez disso, com um coletor OTel executando separadamente, podemos garantir que todos (interessados) os serviços simplesmente possam enviar métricas/logs/rastreios para este coletor, que em seguida poderá exportá-los para varios backends conforme necessário — neste caso, Jaeger para rastreios.

Vamos começar.

Configurar o Coletor OTel

O primeiro passo é adicionar nosso coletor OTel executando no ambiente Docker, juntamente com Jaeger, para que eles sejam acessíveis.

Nota: Nós dividimos nosso original docker-compose.yml completo em duas partes:

  1. db-docker-compose.yml: Contém todos os componentes de banco de dados e infraestrutura (não-aplicação) relacionados, como bancos de dados (Postgres, Typesense) e serviços de monitoramento (coletor OTel, Jaeger, Prometheus, etc).
  2. docker-compose.yml: Contém todos os serviços relacionados à aplicação (Nginx, gRPC Gateway, dbsync, frontend, etc.)

As duas ambientes docker-compose estão conectados por uma rede compartilhada (onehubnetwork) através da qual os serviços nesses ambientes podem se comunicar uns com os outros. Com essa separação, somente precisamos reiniciar um subconjunto de serviços após as mudanças, acelerando nossa velocidade de desenvolvimento.

Voltando para nossa configuração: no nosso db-docker-compose.yml, adicionamos os seguintes serviços:

YAML

 

O que é suficientemente simples, isso configura dois serviços no nosso ambiente Docker:

  1. otel-collector: O ponto final de todas as sinais (metrics/logs/traces) enviadas pelos vários serviços sendo monitorados (nós vamos continuar adicionando a esta lista ao longo do tempo), ele usa a imagem padrão do OTel juntamente com nossa configuração customizada do OTel (abaixo) que descreve vários pipelines de observabilidade (isto é, como as sinais devem ser recebidas, processadas e exportadas de várias formas).
  2. jaeger: Nossa instância do Jaeger que vai receber e armazenar rastreamentos (exportados pelo otel-collector), esse hospeda tanto o armazenamento quanto o painel de instrumentos (UI) que vamos exportar no prefixo de caminho HTTP /jaeger para que seja acessível via nginx.
  3. prometheus: Embora não necessário para este post, também vamos exportar metríticas para que possa ser rastreado por Prometheus. Não discutiremos isso em detalhe neste post.

Algumas coisas para notar:

  • Embora não necessário para este post, estamos passando os detalhes de conexão com o POSTGRES (como variáveis de ambiente) para o otel-collector para que possa rastrear metríticas de saúde do Postgres.
  • Jaeger (a partir de v1.35) suporta OTLP de forma nativa.
  • A beleza do OTLP é que os coletores OTel podem ser encadeados, formando uma rede de coletores/processadores/encaminhadores/roteadores, etc.
  • O OTLP pode ser fornecido via um ponto de extremidade GRPC ou HTTP (usando as portas 4317 e 4318, respectivamente).
  • Por padrão, os serviços OTLP são iniciados em localhost:4317/4318. Isso está bem se o Jaeger estiver sendo executado no mesmo host/pod onde os serviços (que estão sendo monitorados) estão rodando. No entanto, já que o Jaeger está sendo executado em um pod separado, eles têm que ser vinculados a endereços externos (0.0.0.0). Isto não estava claro na documentação e resultou em muito confusão.
  • COLLECTOR_OTLP_ENABLED: true é agora o padrão e não precisa ser especificado explicitamente.

Configuração do OTel

O OTel também precisa ser configurado com receptores específicos, processadores e exportadores. Vamos fazer isso em configs/otel-collector.yaml.

Adicionando Receptores

Nós precisamos informar o coletor OTel quais receptores devem ser ativados. Isto é especificado na seção receivers:

YAML

 

Isto ativa um receptor OTLP nas portas 4317 e 4318 (grpc, http respectivamente). Existem muitos tipos de receptores que podem ser iniciados. Como exemplo, também adicionamos um receptor “postgresql” que irá coletar ativamente metríticas do Postgres (embora isso não seja relevante para este post). Os receptores podem também ser baseados em pull ou push. Receptores baseados em pull coletam periodicamente alvos específicos (por exemplo, postgres), enquanto os receptores baseados em push ouvem e “recebem” metríticas/logs/rastreamentos das aplicações usando o OTel client SDK.

É isso mesmo. Agora, nosso coletor está pronto para receber (ou coletar) as metríticas apropriadas.

Adicionar Processadores

Processadores no OTel são uma maneira de transformar, mapear, agrupar, filtrar e/ou enriquecer sinais recebidos antes de exportá-los. Por exemplo, processadores podem amostrar metríticas, filtrar logs ou até agrupar sinais para melhor eficiência. Por padrão, nenhum processador é adicionado (fazendo o coletor um passa-fita). Ignoraremos isso por agora.

Adicionar Exporters

Agora é hora de identificar onde queremos que os sinais sejam exportados para backends que são melhores adaptados aos respectivos sinais. Assim como os receptores, os exportadores também podem ser baseados em pull ou push. Os exportadores baseados em push são usados para emitir sinais para outro receptor que age em modo push. Esses são de saída. Os exportadores baseados em pull expõem pontos finais que podem ser raspados por outros receptores baseados em pull (ex.: prometheus). Vamos adicionar um exportador de cada tipo: um para rastreamento e um para que o Prometheus possa raspar dele (apesar do Prometheus não ser o assunto deste post):

YAML

 

Aqui temos um exportador para o Jaeger que está rodando o coletor OTLP, conforme indicado por otlp/jaeger. Este exportador irá enviar regularmente rastreamentos para o Jaeger. Também estamos adicionando um “raspador” de ponto final no porto 9090, que o Prometheus vai raspar regularmente.

O exportador “debug” é simplesmente usado para copiar sinais para entradas padrão de saída/erro.

Defina Pipelines

As seções de receptor, processador e exportador simplesmente definem os módulos que serão habilitados pelo coletor. Eles ainda não são chamados. Para chamar/ativar realmente, eles devem ser referenciados como “pipelines”. As pipelines definem como os sinais fluem e são processados pelo coletor. As nossas definições de pipeline (na seção services) esclarecerão isso:

YAML

 

Aqui estamos definindo duas pipelines. Observe quão semelhantes as pipelines são, mas permitem dois modos de exportação diferentes (Jaeger e Prometheus). Agora estamos vendo o poder de OTel e na criação de pipelines nele.

  1. traces:

  • Receba sinais de clientes SDKs
  • Nenhuma processação
  • Exportar rastreamentos para console e Jaeger
  1. metrics:
  • Receber sinais de SDKs do cliente
  • Sem processamento
  • Exportar métricas para a console e Prometheus (exposando um endpoint para coleta).

Exporters de painéis via Nginx

O Jaeger fornece um painel para visualizar dados de rastreamento sobre todas nossas solicitações. Isto pode ser visualizado em um navegador ativando o seguinte em nosso configuração de Nginx. Novamente, não sendo o assunto deste post – estamos também expondo a interface do Prometheus via nginx na préfixo de caminho HTTP /prometheus.

YAML

 

Visualizar Rastreamentos no Jaeger

A interface do Jaeger UI é bastante abrangente e possui várias funcionalidades que você pode explorar. Navegue até o Jaeger UI no navegador. Você verá uma interface abrangente para pesquisar e analisar rastreamentos. Confira e familiarize-se com as seções principais, incluindo a barra de pesquisa e a lista de rastreamentos. Você pode pesquisar vários rastreamentos por critérios de pesquisa e filtrar por serviço, durações de tempo, componentes, etc.

Analisar as linhas de tempo dos rastreamentos em diferentes solicitações para entender a sequência de operações. Cada espaço representa uma unidade de trabalho, mostrando horários de início e fim, duração e metadados relacionados. Esta visão detalhada é muito útil para identificar bottlenecks de desempenho e erros dentro do rastreamento.

Integrar o SDK do cliente

Até agora, nós configuramos nossos sistemas para visualizar, consumir sinais, etc.然而,我们的服务仍然没有更新以向OTel发送信号。在这里,我们将整合各种代码部分的(Golang)客户端SDK。SDK的文档是了解一些概念的绝佳起点。

我们将处理的关键概念描述如下。

资源

资源是产生信号的实体。在我们这个案例中,资源的作用域是托管服务的二进制文件。目前,我们整个Onehub服务的资源只有一个,但稍后可以进行拆分。

这定义在cmd/backend/obs.go中。请注意,客户端SDK并没有让我们明确进入资源定义的细节。标准助手(sdktrace.WithResource)让我们通过推断最有用的部分(如进程名称、容器名称等)在运行时创建资源定义。

我们只需要覆盖一件事:在docker-compose.ymlonehub服务中设置OTEL_RESOURCE_ATTRIBUTES: service.name=onehub.backend,service.version=0.0.1环境变量。

Propagação de Contextos

Propagação de contexto é um tópico muito importante em observabilidade. Os diversos pilares são potencialmente poderosos quando conseguimos correlacionar sinais de cada um deles para identificar problemas no nosso sistema. Pense em contextos como bits de dados extras que podem ser associados aos sinais: ou seja, podem ser “unidos” de algum modo único para relacionar os vários sinais a um determinado grupo (digamos uma solicitação).

Fornecedores/Exportadores

Para cada sinal, o OTel fornece uma interface de Fornecedor (por exemplo, TracerProvider para exportar spans/rastreios, MeterProvider para exportar métricas, LoggerProvider para exportar logs, e assim por diante). Para cada uma dessas interfaces, pode haver várias implementações, por exemplo, um fornecedor de depuração para enviar para fluxos stdout/err, um fornecedor OTel para exportar para outro ponto final OTel (em uma cadeia), ou até mesmo diretamente por meio de uma variedade de exportadores. No entanto, na nossa situação, queremos adiar a escolha de qualquer fornecedor para fora de nossos serviços e, em vez disso, enviar todos os sinais para o coletor OTel rodando no nosso ambiente.

Para abstrair isso, criaremos um tipo “OTELSetup” que mantém o controle dos vários fornecedores que possamos querer usar ou substituir. Em cmd/backend/obs.go, temos:

Go

 

Este é um simples envoltorio que mantém controle de aspectos comuns necessários pelo SDK OTel. Aqui temos provedores (Logger, Tracer e Metric) além de formas de fornecer contexto (para rastreamento). O recurso abrangente usado por todos os provedores também é especificado aqui. As funções de encerramento são interessantes. Elas são funções chamadas pelos provedores quando o exportador subjacente termina (gradativamente ou devido a uma saída). O próprio envoltorio aceita um gênerico, portanto, instanciadores específicos deste Setup podem usar seus próprios dados customizados.

O repositório contém duas implementações deste:

Nós vamos instanciar o segundo no nosso aplicativo. Não vamos entrar em detalhes das implementações específicas, já que elas foram tiradas das exemplos no SDK com pequenas correções e refatorações. Especificamente, olhe para o exemplo otel-collector para inspiração.

Inicialize os OTelProviders.

A essência de habilitar o coletor em nossos serviços é que algum tipo de “contexto” relacionado a OTel é iniciado em todos os pontos de “entrada”. Se este contexto for criado no início, ele será enviado para todos os alvos chamados aqui, que depois serão propagados subsequentemente (enquanto fizermos o que é certo).

Tomando a simples chamada de API ListTopics (api/vi/topics), nossa solicitação segue o seguinte caminho e volta:

[ Browser ] ---> [ Nginx ] ---> [ gRPC Gateway ] ---> [ gRPC Service ] ---> [ Database ]

No nosso caso, os pontos de entrada aqui são no início quando o gRPC Gateway recebe uma solicitação de API de Nginx (nós poderíamos começar a rastrear eles a partir do ponto onde a solicitação HTTP atinge o Nginx mesmo para destacar latências no Nginx, mas vamos adiar isso por um pouco).

O que é necessário é:

  • O gRPC Gateway recebe uma solicitação.
  • Cria uma instância de “context.Context” personalizada específica de OTel.
  • Cria uma conexão personalizada com o serviço gRPC correspondente (por exemplo, TopicService) passando este contexto em vez do padrão.
  • O serviço correspondente então usa este contexto quando emite as traçadas.

Vamos passar por isso passo a passo.

Inicializar e Preparar o SDK OTel para Uso

No main.go, vamos primeiro inicializar a conexão coletor:

Go

 

  • Linhas 8-16: Criamos uma conexão com o otel-collector executando em nosso ambiente Docker.
  • Linhas 17-21: Em seguida, configuramos o OTel com provedores de rastreamento e métricas para que o nosso coletor possa agora enviar todos os rastreamentos e métricas (lembramos que anteriormente definimos os receptores na configuração do OTel).
  • Linhas 23-25: Configuramos finalizadores para limpar conexões/provedores do OTel, etc., ao encerrar.
  • Linhas 27: Configuramos nossa base de dados e conexões como antes.
  • Linhas 29 +: Anteriormente, simplesmente iniciavamos os serviços GRPC e Gateway em segundo plano e era isso. Não nos importávamos muito com o retorno ou estado de saída dos serviços. Para um sistema mais resistente, é importante ter melhor visão dos ciclos de vida dos serviços que estamos iniciando. Então agora passamos o canal de “callback” para cada um dos serviços que estamos iniciando. Quando os servidores saem, os métodos respectivos chamam de volta nestes canais disponíveis para indicar que saíram com gracilidade. Nossa binário inteiro sairá quando qualquer um destes serviços sair.

Por exemplo, vejamos como o nosso serviço de gateway está aproveitando este canal.

Ao invés de iniciar o servidor HTTP (para o grpc-gateway) como:

1    http.ListenAndServe(gw_addr, mux)

Temos agora:

Go

 

Atenção para linhas 9-14 onde o encerramento do servidor é observado em um goroutine separado e linha 15 onde, se houvesse um erro quando o servidor saiu, é enviado de volta via o canal de “notificação” que foi passado como um argumento para este método.

Agora as variáveis partes de nossos serviços agora têm acesso a uma “conexão OTLP ativa” para usar sempre que sinais forem enviados.

OTel Middleware para gRPC Gateway

Acima, a instância de http.Server usada para iniciar o gRPC Gateway está usando um manipulador personalizado: o http.Handler no pacote OTel HTTP. Este manipulador pega uma instância existente de http.Handler, decorada com o contexto OTel e garante sua propagação para qualquer outro downstream que é chamado.

Go

 

O nosso manipulador HTTP é simples:

  • Linha 4: Criamos um novo envoltório específico de OTel para lidar com pedidos HTTP.
  • Linha 5: Configuramos a opção SpanFormatter para que as pistas possam ser identificadas de forma única pelo método e pelas rotas de pedido HTTP. Sem esse SpanNameFormatter, o nome padrão de nossas pistas no gateway seria simplesmente "gateway", resultando em todas as pistas se parecendo com isso:

Envolvendo o Gateway para chamadas gRPC com OTel

Por padrão, a biblioteca gRPC Gateway cria um “contexto simples” quando criando/gerenciando conexões com os serviços gRPC subjacentes. Afinal, o gateway não sabe nada sobre OTel. Neste modo, uma conexão (do usuário/navegador) para o gRPC Gateway e a conexão do gateway para o serviço gRPC serão tratadas como duas pistas diferentes.

Então, é importante remover a responsabilidade de criar conexões gRPC do Gateway e, em vez disso, fornecer uma conexão queja já consciente de OTel. Vamos fazer isso agora.

Antes da integração com o OTel, nós registrávamos um manipulador de Gateway para nossos gRPCs com:

Go

 

Agora passar uma conexão diferente é simples:

Go

 

O que nós fizemos foi primeiro criar um client (Linha 6) que age como uma fábrica de conexões para nosso servidor gRPC. O cliente é muito simples. Apenas o gRPC ClientHandler (otelgrpc.NewClientHandler) está sendo usado para criar a conexão. Isto garante que o contexto na atual trace que começou em um novo pedido HTTP agora é propagado para o servidor gRPC via este manipulador.

É isso aí. Agora nós deveríamos começar a ver o novo pedido para o Gateway e o pedido Gateway->gRPC em uma só trace consolidada, em vez de duas traces diferentes.

Iniciar e Encerrar Spans

Estamos quase lá. Até agora:

  • Nós habilitamos o coletor OTel e o Jaeger para receber e armazenar dados de trace (span) (em docker-compose).
  • Nós configuramos o coletor básico OTel (executando como um pod separado) como nosso “fornecedor” de tracers, métricas e logs (isto é, a integração de OTel em nossa aplicação usaria este ponto final para depositar todas as sinais).
  • Nós envolvemos o manipulador HTTP do Gateway para ser habilitado para OTel, para que tracas e seus contextos sejam criados e propagados.
  • Nós substituímos o cliente (gRPC) no gateway para que agora envolva o contexto OTel de nossa configuração OTel em vez de usar um contexto padrão.
  • Nós criamos instâncias globais de tracer/medidor/logger para que possamos enviar sinais reais usando eles.

Agora precisamos de emitir spans para todos os “locais interessantes” em nosso código. Tomemos o método ListTopics por exemplo (em services/topics.go):

Go

 

Nós chamamos o banco de dados para buscar os tópicos e os retornamos. Similarmente ao método de acesso ao banco de dados (em datastore/topicds.go):

Go

 

Aqui, nós seríamos principalmente interessados em quanto tempo é gasto em cada um destes métodos. Simplesmente criamos spans em cada um e pronto. As nossas adições aos métodos de serviço e armazenamento de dados (respectivamente) são:

  • services/topics.go:
Go

 

  • datastore/topicds.go:
Go

 

O padrão geral é:

1. Criar um span:

ctx, span := Tracer.Start(ctx, "<span name>")

Aqui, o contexto fornecido (ctx) é “envolvido” e um novo contexto é retornado. Nós podemos (e devemos) passar este novo contexto envolvido para métodos adicionais. Nós fazemos exatamente isso quando chamamos o método de armazenamento de dados ListTopics.

2. Encerrar o span:

defer span.End()

Encerrar um span (quando o método retorna) garante que os tempos de finalização certos/códigos, etc., forem registrados. Nós também podemos fazer outras coisas como adicionar tags e statuses a isso, se necessário, para carregar mais informações para auxiliar na depuração.

É isso aí. Você pode ver suas belas traces no Jaeger e adquirir mais e mais insights sobre o desempenho das suas requisições, de ponta a ponta!

Conclusão

Nós abordamos muitos assuntos neste post e ainda apenas tocámos a superfície de todos os detalhes por trás do OTel e da traçagem. Em vez de sobrecarregar este post (já sobrecarregado), vamos apresentar conceitos mais novos e detalhes intrínsecos em posts futuros. Por enquanto, experimente isso em seus próprios serviços e tente brincar com outros exportadores e receptores no repositório otel-contrib.

Source:
https://dzone.com/articles/tracing-with-opentelemetry-and-jaeger