Rastreamento End-To-End com OpenTelemetry
August 31, 2022
Se você implementa ou não microsserviços (e provavelmente não deveria), seu sistema é muito provavelmente composto por vários componentes. O sistema mais simples provavelmente é feito de um proxy reverso, uma aplicação e um banco de dados. Nesse caso, o monitoramento não é apenas uma boa ideia; é uma necessidade. Quanto maior o número de componentes pelos quais uma requisição pode passar, mais forte é a necessidade.
No entanto, o monitoramento é apenas o começo da jornada. Quando as requisições começam a falhar em massa, você precisa de uma visão agregada em todos os componentes. Isso é chamado de tracing (rastreamento), e é um dos pilares da observabilidade; os outros dois são métricas e logs.
Neste post, vou focar apenas em traces e descrever como você pode começar sua jornada na observabilidade.
A especificação W3C Trace Context
Uma solução de tracing deve fornecer um formato padrão para funcionar em pilhas de tecnologia heterogêneas. Esse formato precisa aderir a uma especificação, seja formal ou de facto.
É importante entender que uma especificação raramente surge do nada. Em geral, o mercado já possui algumas implementações distintas. Na maioria das vezes, uma nova especificação leva a uma implementação adicional, como o famoso quadrinho do XKCD descreve:
Às vezes, no entanto, um milagre acontece: o mercado adere à nova especificação. Aqui, o Trace Context é uma especificação do W3C, e parece ter feito o truque:
Esta especificação define cabeçalhos HTTP padrão e um formato de valor para propagar informações de contexto que permitem cenários de rastreamento distribuído. A especificação padroniza como as informações de contexto são enviadas e modificadas entre serviços. As informações de contexto identificam exclusivamente requisições individuais em um sistema distribuído e também definem um meio para adicionar e propagar informações de contexto específicas do provedor. -- https://www.w3.org/TR/trace-context/
Dois conceitos críticos emergem do documento:
- Um trace segue o caminho de uma requisição que abrange vários componentes
- Um span está vinculado a um único componente e ligado a outro span por uma relação de pai-filho

No momento da escrita deste post, a especificação é uma recomendação do W3C, que é o estágio final.
O Trace Context já tem muitas implementações. Uma delas é o OpenTelemetry.
OpenTelemetry como o padrão ouro
Quanto mais próximo você está da parte operacional de TI, maiores são as chances de você já ter ouvido falar do OpenTelemetry:
OpenTelemetry é uma coleção de ferramentas, APIs e SDKs. Use-o para instrumentar, gerar, coletar e exportar dados de telemetria (métricas, logs e traces) para ajudá-lo a analisar o desempenho e o comportamento do seu software. O OpenTelemetry está geralmente disponível em várias linguagens e é adequado para uso. -- https://opentelemetry.io/
O OpenTelemetry é um projeto gerenciado pela CNCF. Antes do OpenTelemetry, existiam dois projetos:
- OpenTracing, focado em traces, como o nome sugere
- OpenCensus, cujo objetivo era gerenciar métricas e traces
Ambos os projetos se fundiram e adicionaram logs. O OpenTelemetry agora oferece um conjunto de "camadas" focadas em observabilidade:
- APIs de instrumentação em várias linguagens
- Implementações canônicas, novamente em diferentes linguagens
- Componentes de infraestrutura, como coletores
- Formatos de interoperabilidade, como o Trace Context do W3C
Observe que, embora o OpenTelemetry seja uma implementação do Trace Context, ele faz mais. O Trace Context se limita ao HTTP, enquanto o OpenTelemetry permite que spans atravessem componentes não-web, como Kafka. Isso está fora do escopo deste post.
O caso de uso
Meu caso de uso favorito é uma loja de comércio eletrônico, então vamos mantê-lo. Neste caso, a loja é projetada em torno de microsserviços, cada um acessível via uma API REST e protegido por um API Gateway. Para simplificar a arquitetura para o post, vou usar apenas dois microsserviços: catalog
gerencia produtos, e pricing
lida com o preço dos produtos.
Quando um usuário chega ao aplicativo, a página inicial busca todos os produtos, obtém seus respectivos preços e os exibe.
Para tornar as coisas mais interessantes, catalog
é uma aplicação Spring Boot codificada em Kotlin, enquanto pricing
é uma aplicação Python Flask.
O tracing deve nos permitir seguir o caminho de uma requisição através do gateway, ambos os microsserviços e, se possível, os bancos de dados.
Traces no gateway
O ponto de entrada é a parte mais interessante do tracing, pois deve gerar o ID do trace: neste caso, o ponto de entrada é o gateway. Vou usar o Apache APISIX para implementar a demonstração:
O Apache APISIX fornece recursos avançados de gerenciamento de tráfego, como Balanceamento de Carga, Upstream Dinâmico, Lançamento Canário, Circuit Breaker, Autenticação, Observabilidade, etc. -- https://apisix.apache.org/
O Apache APISIX é baseado em uma arquitetura de plugins e oferece um plugin OpenTelemetry:
O plugin
opentelemetry
pode ser usado para relatar dados de tracing de acordo com a especificação OpenTelemetry. O plugin suporta apenas OLTP codificado em binário sobre HTTP. -- https://apisix.apache.org/docs/apisix/plugins/opentelemetry/
Vamos configurar o plugin opentelemetry
:
apisix:
enable_admin: false #1
config_center: yaml #1
plugins:
- opentelemetry #2
plugin_attr:
opentelemetry:
resource:
service.name: APISIX #3
collector:
address: jaeger:4318 #4
- Execute o Apache APISIX no modo standalone para facilitar a demonstração. É uma boa prática em produção de qualquer forma
- Configure
opentelemetry
como um plugin global - Defina o nome do serviço. É o nome que aparecerá no componente de exibição de traces
- Envie os traces para o serviço
jaeger
. A próxima seção descreverá isso.
Queremos rastrear todas as rotas, então, em vez de adicionar o plugin a cada rota, devemos configurar o plugin como global:
global_rules:
- id: 1
plugins:
opentelemetry:
sampler:
name: always_on #1
- O tracing tem um impacto no desempenho. Quanto mais rastreamos, mais impactamos. Portanto, devemos equilibrar cuidadosamente o impacto no desempenho versus os benefícios da observabilidade. Para a demonstração, no entanto, queremos rastrear todas as requisições.
Coletando, armazenando e exibindo traces
Embora o Trace Context seja uma especificação do W3C e o OpenTelemetry seja um padrão de facto, existem muitas soluções no mercado para coletar, armazenar e exibir traces. Cada solução pode fornecer todas as três capacidades ou apenas parte delas. Por exemplo, a stack Elastic lida com armazenamento e exibição, mas você deve contar com outra coisa para a coleta. Por outro lado, Jaeger e Zipkin fornecem uma suíte completa para atender a todas as três capacidades.
Jaeger e Zipkin são anteriores ao OpenTelemetry, então cada um tem seu formato de transporte de traces. No entanto, eles fornecem integração com o formato OpenTelemetry.
No escopo deste post, a solução exata não é relevante, pois precisamos apenas das capacidades. Escolhi o Jaeger porque ele fornece uma imagem Docker all-in-one: cada capacidade tem seu componente, mas todos estão embutidos na mesma imagem, o que torna a configuração muito mais fácil.
As portas relevantes da imagem são as seguintes:
Porta | Protocolo | Componente | Função |
---|---|---|---|
16686 | HTTP | query | serve frontend |
4317 | HTTP | collector | aceita OpenTelemetry Protocol (OTLP) sobre gRPC, se habilitado |
4318 | HTTP | collector | aceita OpenTelemetry Protocol (OTLP) sobre HTTP, se habilitado |
A parte do Docker Compose fica assim:
services:
jaeger:
image: jaegertracing/all-in-one:1.37 #1
environment:
- COLLECTOR_OTLP_ENABLED=true #2
ports:
- "16686:16686" #3
- Use a imagem
all-in-one
- Muito importante: habilite o coletor no formato OpenTelemetry
- Exponha a porta da interface do usuário
Agora que configuramos a infraestrutura, podemos nos concentrar em habilitar traces em nossas aplicações.
Traces em aplicações Flask
O serviço pricing
é uma aplicação simples Flask. Ele oferece um único endpoint para buscar o preço de um único produto do banco de dados.
@app.route('/price/<product_str>') #1-2
def price(product_str: str) -> Dict[str, object]:
product_id = int(product_str)
price: Price = Price.query.get(product_id) #3
if price is None:
return jsonify({'error': 'Product not found'}), 404
else:
low: float = price.value - price.jitter #4
high: float = price.value + price.jitter #4
return {
'product_id': product_id,
'price': round(uniform(low, high), 2) #4
}
- Endpoint
- A rota requer o ID do produto
- Busca dados do banco de dados usando SQLAlchemy
- Motores de precificação reais nunca retornam o mesmo preço ao longo do tempo; vamos randomizar o preço um pouco por diversão
Aviso: Buscar um único preço por chamada é altamente ineficiente. Requer tantas chamadas quanto produtos, mas torna o trace mais interessante. Na vida real, a rota deve ser capaz de aceitar vários IDs de produtos e buscar todos os preços associados em uma única requisição-resposta.
Agora é a hora de instrumentar a aplicação. Duas opções estão disponíveis: instrumentação automática e instrumentação manual. A automática é de baixo esforço e uma vitória rápida; a manual requer tempo de desenvolvimento focado. Eu aconselharia começar com a automática e adicionar a manual apenas se necessário.
Precisamos adicionar alguns pacotes Python:
opentelemetry-distro[otlp]==0.33b0
opentelemetry-instrumentation
opentelemetry-instrumentation-flask
Precisamos configurar alguns parâmetros:
pricing:
build: ./pricing
environment:
OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317 #1
OTEL_RESOURCE_ATTRIBUTES: service.name=pricing #2
OTEL_METRICS_EXPORTER: none #3
OTEL_LOGS_EXPORTER: none #3
- Envie os traces para o Jaeger
- Defina o nome do serviço. É o nome que aparecerá no componente de exibição de traces
- Não estamos interessados em logs nem em métricas
Agora, em vez de usar o comando padrão flask run
, nós o envolvemos:
opentelemetry-instrument flask run
Apenas com isso, já coletamos spans de chamadas de métodos e rotas do Flask.
Podemos adicionar spans adicionais manualmente, se necessário, e.g.:
from opentelemetry import trace
@app.route('/price/<product_str>')
def price(product_str: str) -> Dict[str, object]:
product_id = int(product_str)
with tracer.start_as_current_span("SELECT * FROM PRICE WHERE ID=:id", attributes={":id": product_id}) as span: #1
price: Price = Price.query.get(product_id)
# ...
- Adicione um span adicional com o rótulo e atributo configurados
Traces em aplicações Spring Boot
O serviço catalog
é uma aplicação Spring Boot Reativa desenvolvida em Kotlin. Ele oferece dois endpoints:
- Um para buscar um único produto
- Outro para buscar todos os produtos
Ambos primeiro consultam o banco de dados de produtos e, em seguida, consultam o serviço pricing
acima para obter o preço.
Assim como no Python, podemos aproveitar a instrumentação automática e manual. Vamos começar com a fruta mais baixa, a instrumentação automática. Na JVM, conseguimos isso através de um agente:
java -javaagent:opentelemetry-javaagent.jar -jar catalog.jar
Assim como no Python, ele cria spans para cada chamada de método e ponto de entrada HTTP. Ele também instrumenta chamadas JDBC, mas temos uma stack Reativa e, portanto, usamos R2DBC. Para registro, há uma issue no GitHub aberta para adicionar suporte.
Precisamos configurar o comportamento padrão:
catalog:
build: ./catalog
environment:
APP_PRICING_ENDPOINT: http://pricing:5000/price
OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317 #1
OTEL_RESOURCE_ATTRIBUTES: service.name=orders #2
OTEL_METRICS_EXPORTER: none #3
OTEL_LOGS_EXPORTER: none #3
- Envie os traces para o Jaeger
- Defina o nome do serviço. É o nome que aparecerá no componente de exibição de traces
- Não estamos interessados em logs nem em métricas
Assim como no Python, podemos aumentar o nível adicionando instrumentação manual. Duas opções estão disponíveis, programática e baseada em anotações. A primeira é um pouco complicada, a menos que introduzamos o Spring Cloud Sleuth. Vamos adicionar anotações.
Precisamos de uma dependência adicional:
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-instrumentation-annotations</artifactId>
<version>1.17.0-alpha</version>
</dependency>
Cuidado, o artefato foi recentemente realocado de io.opentelemetry:opentelemetry-extension-annotations
.
Agora podemos anotar nosso código:
@WithSpan("ProductHandler.fetch") //1
suspend fun fetch(@SpanAttribute("id") id: Long): Result<Product> { //2
val product = repository.findById(id)
return if (product == null) Result.failure(IllegalArgumentException("Product $id not found"))
else Result.success(product)
}
- Adicione um span adicional com o rótulo configurado
- Use o parâmetro como um atributo, com a chave definida como
id
e o valor o valor do parâmetro em tempo de execução
O resultado
Agora podemos brincar com nossa demonstração simples para ver o resultado:
curl localhost:9080/products
curl localhost:9080/products/1
As respostas não são interessantes, mas vamos olhar para a interface do usuário do Jaeger. Encontramos ambos os traces, um por chamada:
Podemos mergulhar nos spans de um único trace:
Observe que podemos inferir o fluxo de sequência sem o diagrama UML acima. Ainda melhor, a sequência exibe as chamadas internas de um componente.
Cada span contém atributos que a instrumentação automática adicionou e os que adicionamos manualmente:
Conclusão
Neste post, mostrei o tracing seguindo uma requisição através de um API gateway, duas aplicações baseadas em diferentes pilhas de tecnologia e seus respectivos bancos de dados. Apenas arranhei a superfície do tracing: no mundo real, o tracing provavelmente envolveria componentes não relacionados ao HTTP, como Kafka e filas de mensagens.
Ainda assim, a maioria dos sistemas depende do HTTP de uma forma ou de outra. Embora não seja trivial configurar, também não é muito difícil. Rastrear requisições HTTP através de componentes é um bom começo em sua jornada em direção à observabilidade do seu sistema.
O código-fonte completo para este post pode ser encontrado no GitHub.
Para ir mais longe: