OpenTelemetry na prática

Mais recentemente, dois padrões - OpenTracing e OpenCensus - finalmente se fundiram em um. Um novo padrão para rastreamento e monitoramento distribuído apareceu - OpenTelemetry. Mas apesar do fato de que o desenvolvimento de bibliotecas está em pleno andamento, não há muita experiência real em usá-lo.



Ilya Kaznacheev, que desenvolve há oito anos e trabalha como desenvolvedor de back-end na MTS, está pronto para compartilhar como usar OpenTelemetry em projetos Golang. Na conferência Golang Live 2020, ele falou sobre como configurar o uso de um novo padrão de rastreamento e monitoramento e torná-lo amigo da infraestrutura já existente no projeto.





OpenTelemetry é um padrão relativamente recente, no final do ano passado. Ao mesmo tempo, ele recebeu ampla distribuição e suporte de muitos fornecedores de software para rastreamento e monitoramento.



Observabilidade, ou observabilidade, é um termo da teoria de controle que determina o quanto se pode julgar o estado interno de um sistema por suas manifestações externas. Na arquitetura do sistema, isso significa um conjunto de abordagens para monitorar o estado do sistema em tempo de execução. Essas abordagens incluem registro, rastreamento e monitoramento.







Existem muitas soluções de fornecedores para rastreamento e monitoramento. Até recentemente, havia dois padrões abertos: OpenTracing da CNCF, que apareceu em 2016, e Open Census, do Google, que apareceu em 2018.



Esses são dois padrões muito bons que competiram entre si por um tempo, até que em 2019 eles decidiram se fundir em um novo padrão chamado OpenTelemetry.







Este padrão inclui rastreamento e monitoramento distribuídos. É compatível com os dois primeiros. Além do mais, OpenTracing e Open Census interromperam o suporte nos últimos dois anos, o que nos aproxima de mudar para OpenTelemetry.



Casos de uso



O padrão pressupõe amplas oportunidades para combinar tudo com tudo e é, na verdade, uma camada ativa entre as fontes de métricas e rastreios e seus consumidores.

Vamos dar uma olhada nos cenários principais.



Para rastreamento distribuído, você pode configurar diretamente uma conexão com a Jaeger ou qualquer serviço que esteja usando.







Se o rastreamento for transmitido diretamente, você pode usar config e apenas substituir a biblioteca.



Caso seu aplicativo já esteja usando OpenTracing, você pode usar o OpenTracing Bridge, um wrapper que converterá as solicitações da API OpenTracing para a API OpenTelemetry no nível superior.







Para coletar métricas, você também pode configurar o Prometheus para acessar diretamente a porta de métricas do seu aplicativo.







Isso é útil se você tiver uma infraestrutura simples e coletar métricas diretamente. Mas o padrão também oferece mais flexibilidade.



O cenário principal para usar o padrão é coletar métricas e rastreios por meio de um coletor, que também é iniciado por um aplicativo ou contêiner separado em sua infraestrutura. Além disso, você pode pegar um recipiente pronto e instalá-lo em casa.



Para isso, basta configurar o exportador no formato OTLP no aplicativo. É um esquema grpc para transmissão de dados em formato OpenTracing. Do lado do coletor, você pode configurar o formato e os parâmetros para exportar métricas e rastreios para usuários finais ou para outros formatos. Por exemplo, no OpenCensus.







O coletor permite conectar um grande número de tipos de fontes de dados e muitos coletores de dados na saída.







Portanto, o padrão OpenTelemetry fornece compatibilidade com muitos padrões de código aberto e fornecedores.



O manifold padrão é expansível. Portanto, a maioria dos fornecedores já tem exportadores prontos para suas próprias soluções, se houver. Você pode usar OpenTelemetry mesmo se coletar métricas e traços de algum fornecedor proprietário. Isso resolve o problema de dependência do fornecedor. Mesmo que algo ainda não tenha aparecido diretamente para o OpenTelemetry, pode ser encaminhado através do OpenCensus.



O coletor em si é muito fácil de configurar por meio da configuração YAML banal: os







receptores são especificados aqui. Seu aplicativo pode ter alguma outra fonte (Kafka, etc.):







Exportadores - destinatários de dados.

Processadores - métodos para processar dados dentro do coletor:







E pipelines, que definem diretamente como cada fluxo de dados que flui dentro de um coletor será tratado:







Vejamos um exemplo ilustrativo.







Digamos que você tenha um microsserviço ao qual já tenha parafusado o OpenTelemetry e configurado. E mais um serviço com fragmentação semelhante.



Até agora, tudo é fácil. Mas há:



  • serviços legados executados por meio do OpenCensus;
  • um banco de dados que envia dados em seu próprio formato (por exemplo, diretamente para o Prometheus, como o PostgreSQL faz);
  • algum outro serviço que funciona em um contêiner e fornece métricas em seu próprio formato. Você não quer reconstruir esse contêiner e bagunçar os carros laterais para reformatar as métricas. Você só quer pegá-los e enviá-los.
  • hardware do qual você também coleta métricas e deseja usá-las de alguma forma.


Todas essas métricas podem ser combinadas em um coletor.







Ele já oferece suporte a muitas fontes de métricas e rastreios que são usados ​​em aplicativos existentes. E caso você esteja usando algo exótico, pode implementar seu próprio plugin. Mas é improvável que isso seja necessário na prática. Porque os aplicativos que exportam métricas ou traços, de uma forma ou de outra, usam alguns padrões comuns ou padrões abertos como o OpenCensus.



Agora queremos usar essas informações. Você pode especificar Jaeger como um exportador de rastreios e enviar métricas para o Prometheus ou algo compatível. Digamos que a VictoriaMetrics favorita de todos.



Mas e se de repente decidíssemos mudar para a AWS e usar o rastreador de raios-X local? Sem problemas. Isso pode ser encaminhado por meio do OpenCensus, que possui um exportador de Raios-X.



Assim, a partir dessas peças você pode montar toda a sua infraestrutura para métricas e rastreamentos.



A teoria acabou. Vamos falar sobre como usar o rastreamento na prática.



Instrumentação do aplicativo Golang: rastreio



Primeiro, você precisa criar uma extensão de raiz, a partir da qual a árvore de chamada crescerá.



ctx := context.Background()
tr := global.Tracer("github.com/me/otel-demo")
ctx, span := tr.Start(ctx, "root")
span.AddEvent(ctx, "I am a root span!")
doSomeAction(ctx, "12345")
span.End()
      
      





Este é o nome do seu serviço ou biblioteca. Dessa forma, no rastreamento, você pode definir os períodos que estão dentro de seu aplicativo e aqueles que foram para as bibliotecas importadas.



Em seguida, um intervalo de raiz é criado com o nome:



ctx, span := tr.Start(ctx, "root")
      
      





Escolha um nome que descreva claramente o nível de rastreamento. Por exemplo, pode ser o nome de um método (ou classe e método) ou uma camada de arquitetura. Por exemplo, camada de infraestrutura, camada lógica, camada de banco de dados, etc.



Os dados abrangidos também são colocados em contexto:



ctx, span := tr.Start(ctx, "root")
span.AddEvent(ctx, "I am a root span!")
doSomeAction(ctx, "12345")

      
      





Portanto, você precisa passar os métodos que deseja rastrear no contexto.



Span representa um processo em um nível específico na árvore de chamadas. Você pode colocar atributos, logs e status de erro nele, se ocorrer. Span deve ser fechado no final. Quando fechado, sua duração é calculada.



ctx, span := tr.Start(ctx, "root")
span.AddEvent(ctx, "I am a root span!")
doSomeAction(ctx, "12345")
span.End()
      
      





É assim que nossa extensão se parece no Jaeger: você







pode expandi-la e ver os logs e atributos.



Então, você pode obter a mesma extensão do contexto se não quiser definir um novo. Por exemplo, você deseja escrever uma camada de arquitetura em uma extensão e sua camada está espalhada por vários métodos e vários níveis de chamada. Você pega, escreve e então fecha.



func doSomeAction(ctx context.Context, requestID string) {
      span := trace.SpanFromContext(ctx)
      span.AddEvent(ctx, "I am the same span!")
      ...
}
      
      





Observe que não é necessário fechá-lo aqui, pois ele será fechado com o mesmo método em que foi criado. Estamos apenas tirando do contexto.



Escrevendo uma mensagem no span raiz:







às vezes você precisa criar um novo span filho para que exista separadamente.



func doSomeAction(ctx context.Context, requestID string) {
   ctx, span := global.Tracer("github.com/me/otel-demo").
      Start(ctx, "child")
   defer span.End()
   span.AddEvent(ctx, "I am a child span!")
   ...
}
      
      





Aqui temos um rastreador global denominado biblioteca. Esta chamada pode ser encapsulada em algum método ou você pode usar uma variável global, porque será a mesma em todo o serviço.



Em seguida, um intervalo filho é criado a partir do contexto, e um nome é atribuído a ele, semelhante a como fizemos no início:



   Start(ctx, "child")
      
      





Lembre-se de fechar o span no final do método em que foi criado.



  ctx, span := global.Tracer("github.com/me/otel-demo"). 
      Start(ctx, "child") 
   defer span.End()
      
      





Escrevemos mensagens nele que se enquadram no período infantil.







Aqui você pode ver que as mensagens são exibidas hierarquicamente e o intervalo filho está sob o pai. Espera-se que seja mais curto porque foi uma chamada síncrona.



Mostra os atributos que podem ser escritos no período:



func doSomeAction(ctx context.Context, requestID string) {
      ...
      span.SetAttributes(label.String("request.id", requestID))
      span.AddEvent(ctx, "request validation ok")
   span.AddEvent(ctx, "entities loaded", label.Int64("count", 123))
      span.SetStatus(codes.Error, "insertion error")
}
      
      





Por exemplo, nosso pedido chegou aqui. id:







Você pode adicionar eventos:



   span.AddEvent(ctx, "request validation ok")
      
      





Além disso, você pode adicionar um rótulo aqui. Isso funciona da mesma maneira que um registro estruturado na forma de logrus:



span.AddEvent(ctx, "entities loaded", label.Int64("count", 123))
      
      





Aqui vemos nossa mensagem no registro de amplitude. Você pode expandi-lo e ver os rótulos. Em nosso caso, a contagem de rótulos foi adicionada aqui:







então será conveniente usá-la ao filtrar em uma pesquisa.



Se ocorrer um erro, você pode adicionar um status ao período. Nesse caso, ele será marcado como inválido.



  span.SetStatus(codes.Error, "insertion error")
      
      





O padrão costumava usar códigos de erro do OpenCensus e eram do grpc. Agora apenas OK, ERROR e UNSET são deixados. OK é o padrão, ERROR é adicionado em caso de erro.



Aqui você pode ver que o rastreamento do erro está marcado com um ícone vermelho. Há um código de erro e uma mensagem sobre ele:







Não devemos esquecer que o rastreio não substitui os logs. O ponto principal é rastrear o fluxo de informações por meio de um sistema distribuído, e para isso é necessário colocar rastreamentos nas solicitações de rede e poder lê-los a partir daí. Microsserviços de



rastreamento



OpenTelemetry já tem muitas implementações definidas de interceptores e middleware para vários frameworks e bibliotecas. Eles podem ser encontrados no repositório: github.com/open-telemetry/opentelemetry-go-contrib



Lista de estruturas para as quais existem interceptores e middleware:



  • beego
  • vá descansar
  • Gin
  • gocql
  • mux
  • eco
  • http
  • grpc
  • sarama
  • memcache
  • Mongo
  • macaron


Vamos ver como usar isso usando um cliente e servidor http padrão como exemplo.



cliente de middleware



No cliente, simplesmente adicionamos um interceptor como meio de transporte, após o qual nossas solicitações são enriquecidas com trace.id e as informações necessárias para continuar o rastreamento.



client := http.Client{
      Transport: otelhttp.NewTransport(http.DefaultTransport),
}
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)
      
      





servidor de middleware



Um pequeno middleware com o nome da biblioteca é adicionado ao servidor:



http.Handle("/", otelhttp.NewHandler(
      http.HandlerFunc(get), "root"))
err := http.ListenAndServe(addr, nil)
      
      





Então, como de costume: obtenha o intervalo do contexto, trabalhe com ele, escreva algo nele, crie spans filho, feche-os etc.



É assim que uma simples solicitação se parece, passando por três serviços:







A captura de tela mostra a hierarquia das chamadas, divisão em serviços, sua duração, sequência. Você pode clicar em cada um deles e ver informações mais detalhadas.



E é assim que o erro se parece:







É fácil rastrear onde aconteceu, quando e quanto tempo se passou.

No intervalo, você pode ver informações detalhadas sobre o contexto em que o erro ocorreu:







Além disso, os campos que se referem a todo o período (vários id de solicitação, campos-chave na tabela da solicitação, alguns outros metadados que você deseja inserir) podem ser aninhados no intervalo quando ele é criado. A grosso modo, você não precisa copiar e colar todos esses campos em todos os locais onde lida com um erro. Você pode gravar dados sobre ele para abranger.



função de middleware



Aqui está um pequeno bônus: como fazer middleware para que você possa usá-lo como um middleware global para coisas como Gorilla e Gin:



middleware := func(h http.Handler) http.Handler {
      return otelhttp.NewHandler(h, "root")
}
      
      





Instrumentação de aplicativos Golang: monitoramento



É hora de falar sobre monitoramento.



A conexão com o sistema de monitoramento é configurada da mesma forma que para o rastreamento.



As medidas são divididas em dois tipos:



1. Síncrona, quando o usuário passa explicitamente os valores no momento da chamada:



  • Contador
  • UpDownCounter
  • ValueRecorder


int64, float64



2. Assíncrono, que o SDK lê no momento da coleta de dados do aplicativo:



  • SumObserver
  • UpDownSumObserver
  • ValueObserver


int64, float64



As próprias métricas são:



  • Aditivo e monótono (contador, SumObserver) que soma números positivos e não diminuem.
  • Aditivo, mas não monótono (UpDownCounter, UpDownSumObserver), que pode somar números positivos e negativos.
  • Não aditivo (ValueRecorder, ValueObserver) que simplesmente registra uma sequência de valores. Por exemplo, algum tipo de distribuição.


No início do programa é criado um medidor global, ao qual é indicado o nome da biblioteca ou serviço.



meter := global.Meter("github.com/ilyakaznacheev/otel-demo")
floatCounter := metric.Must(meter).NewFloat64Counter(
         "float_counter",
         metric.WithDescription("Cumulative float counter"),
   ).Bind(label.String("label_a", "some label"))
defer floatCounter.Unbind()
      
      





Em seguida, uma métrica é criada:



floatCounter := metric.Must(meter).NewFloat64Counter(
         "float_counter",
         metric.WithDescription("Cumulative float counter"),
   ).Bind(label.String("label_a", "some label"))
      
      





Ela recebeu um nome:



   "float_counter",
      
      





Descrição:




         metric.WithDescription("Cumulative float counter"),
      
      





Um conjunto de rótulos pelos quais você pode filtrar as solicitações. Por exemplo, ao construir painéis no Grafana:




    ).Bind(label.String("label_a", "some label"))

      
      





No final do programa, você também precisa chamar Unbind para cada métrica, o que irá liberar recursos e fechá-lo corretamente:




defer floatCounter.Unbind()

      
      





Gravar as alterações é simples:



var (
counter metric.BoundFloat64Counter
udCounter metric.BoundFloat64UpDownCounter
valueRecorder metric.BoundFloat64ValueRecorder
)
...
counter.Add(ctx, 1.5)
udCounter.Add(ctx, -2.5)
valueRecorder.Record(ctx, 3.5)

      
      





Esses são números positivos para Counter, quaisquer números para UpDownCounter que serão somados e também quaisquer números para ValueRecorder. Para todos os tipos de instrumentos, Go oferece suporte a int64 e float64.



Isso é o que obtemos na saída:



# HELP float_counter Cumulative float counter
# TYPE float_counter counter
float_counter{label_a="some label"} 20
      
      





Esta é nossa métrica com um comentário e um determinado rótulo. Em seguida, você pode obtê-lo diretamente por meio do Prometheus ou exportá-lo por meio do coletor OpenTelemetry e usá-lo sempre que precisarmos.



Instrumentação de aplicativos Golang: Bibliotecas



A última coisa que quero dizer é a capacidade que o padrão fornece para bibliotecas de instrumentação.



Anteriormente, ao usar o OpenCensus e o OpenTracing, você não podia instrumentar suas bibliotecas individuais, especialmente as de código aberto. Porque, neste caso, você acabou ficando preso a um fornecedor. Qualquer pessoa que tenha trabalhado de perto com rastreamento provavelmente prestou atenção ao fato de que grandes bibliotecas de clientes, ou grandes APIs para serviços em nuvem, ocasionalmente travam com erros difíceis de explicar.



O rastreamento seria muito útil aqui. Principalmente na produtividade, quando você tem algum tipo de situação obscura e eu gostaria muito de saber por que aconteceu. Mas tudo o que você tem é uma mensagem de erro da biblioteca importada.



OpenTelemetry resolve esse problema.







Como o SDK e a API são separados no padrão, o Metrics Tracing API pode ser usado independentemente do SDK e das configurações de exportação de dados específicas. Além disso, você pode primeiro instrumentar seus métodos e só então configurar a exportação desses dados para o exterior.

Dessa forma, você pode instrumentar a biblioteca importada sem se preocupar com como e para onde os dados serão exportados. Isso funcionará para bibliotecas internas e de código aberto.



Não há necessidade de se preocupar com o aprisionamento do fornecedor, nem sobre como essas informações serão usadas ou se serão usadas. Bibliotecas e aplicativos são instrumentados com antecedência, e a configuração de exportação de dados é especificada quando o aplicativo é inicializado.



Assim, você pode ver que as definições de configuração são definidas no aplicativo SDK. Em seguida, você precisa lidar com os exportadores de rastreamento e métricas. Pode ser um exportador via OTLP se você estiver exportando para o coletor OpenTelemetry. Em seguida, todos os rastreamentos e métricas necessários entram no contexto e são propagados pela árvore de chamadas por outro método.



O aplicativo herda o restante dos spans do span raiz, simplesmente usando a API OpenTelemetry e os dados que estão no contexto. Nesse caso, as bibliotecas importadas recebem os métodos de contexto como entrada, tente ler as informações sobre a extensão da raiz a partir deste método. Se não estiver lá, eles criam os seus próprios e então instruem a lógica. Desta forma, você pode instrumentar sua biblioteca primeiro.



Além disso, você pode instrumentar tudo, mas não configurar os exportadores de dados, apenas implantá-los.



Isso pode funcionar para você na produção e, até que a infraestrutura seja estabelecida, você não terá o rastreamento e o monitoramento configurados. Então você os configura, implanta um coletor lá, alguns aplicativos para coletar esses dados e tudo funcionará para você. Você não precisa alterar nada diretamente nos próprios métodos.



Portanto, se você tiver uma biblioteca de código aberto, poderá instrumentá-la usando OpenTelemetry. Então as pessoas que o usam irão configurar o OpenTelemetry e usar esses dados.



Concluindo, gostaria de dizer que o padrão OpenTelemetry é promissor. Talvez, finalmente, este seja o mesmo padrão universal que todos nós queríamos ver.



Nossa empresa usa ativamente o padrão OpenCensus para rastrear e monitorar o cenário de microsserviços da empresa. Está planejado implementar OpenTelemetry após seu lançamento.



All Articles