Back-End

23 fev, 2017

Evolução de rastreamento distribuído na engenharia Uber

Publicidade

O rastreamento distribuído está rapidamente se tornando um componente imprescindível nas ferramentas que as organizações usam para monitorar suas complexas arquiteturas baseadas em microsserviços. Na Engenharia Uber, nosso sistema de rastreamento distribuído de código aberto Jaeger viu a adoção interna em grande escala ao longo de 2016, integrada em centenas de microsserviços e agora registrando milhares de rastreamentos a cada segundo. À medida que começamos o novo ano, aqui está a história de como chegamos aqui, do investigar soluções off-the-shelf como Zipkin, até o porquê mudamos a arquitetura de pull para push e como o rastreamento distribuído continuará a evoluir em 2017.

De Monolith para microsserviços

À medida que o negócio da Uber crescia exponencialmente, a complexidade da arquitetura de software também aumentava. Há pouco mais de um ano, no outono de 2015, tínhamos cerca de 500 microsserviços. No início de 2017, temos mais de dois mil. Isso se deve, em parte, ao crescente número de recursos empresariais, como UberEATS e UberRUSH, além de funções internas como detecção de fraude, mineração de dados e processamento de mapas. A outra razão pela qual a complexidade aumentou foi um afastamento das grandes aplicações monolíticas para uma arquitetura distribuída de microsserviços.

Como muitas vezes acontece, passar para um ecossistema de microsserviços traz seus próprios desafios. Entre eles está a perda de visibilidade no sistema e as complexas interações que agora ocorrem entre os serviços. Engenheiros da Uber sabem que a nossa tecnologia tem um impacto direto nos meios de subsistência das pessoas. A confiabilidade do sistema é primordial, mas não é possível sem capacidade de observação. Ferramentas de monitoramento tradicionais, como métricas e logs distribuídos, ainda têm seu lugar, mas muitas vezes não conseguem fornecer visibilidade entre os serviços. É aqui que o rastreamento distribuído prospera.

Rastreando os começos da Uber

O primeiro sistema de rastreamento amplamente usado na Uber era chamado de Merckx, em homenagem ao ciclista mais rápido do mundo durante seu tempo. O Merckx rapidamente respondeu a perguntas complexas sobre o backend monolítico do Python da Uber. Ele tornou consultas como “encontre para mim pedidos onde o usuário estava conectado e a solicitação levou mais de dois segundos e apenas alguns bancos de dados foram usados e uma transação foi mantida aberta por mais de 500 ms” possíveis. Os dados de perfil foram organizados em uma árvore de blocos, com cada bloco representando uma determinada operação ou uma chamada remota, semelhante à noção de “span” na API OpenTracing. Os usuários podiam executar consultas ad hoc contra o fluxo de dados no Kafka usando ferramentas de linha de comando. Eles também podiam usar uma interface do usuário da web para exibir resumos predefinidos que resumem o comportamento de alto nível dos endpoints da API e tarefas de Celery.

O Merckx modelou o gráfico de chamadas como uma árvore de blocos, com cada bloco representando uma operação dentro da aplicação, como uma chamada de banco de dados, um RPC ou mesmo uma função de biblioteca como a análise de JSON.

A instrumentação do Merckx foi aplicada automaticamente a várias bibliotecas de infraestrutura em Python, incluindo clientes e servidores HTTP, consultas SQL, chamadas Redis e até serialização JSON. A instrumentação registrou determinadas métricas de desempenho e metadados sobre cada operação, como a URL para uma chamada HTTP ou uma consulta SQL para chamadas de banco de dados. Também capturou informações como quanto tempo as transações do banco de dados permaneceram abertas e quais fragmentos de banco de dados e réplicas foram acessados.

A arquitetura Merckx é um modelo pull de um fluxo de dados de instrumentação em Kafka.

A principal deficiência com Merckx foi o seu design para os dias de uma API monolítica na Uber. O Merckx esteve em falta com qualquer conceito de propagação de contexto distribuído. Ele gravava consultas SQL, chamadas Redis e até mesmo chamadas para outros serviços, mas não havia maneira de ir mais de um nível em profundidade. Outra interessante limitação do Merckx era que muitos recursos avançados como o acompanhamento de transações de banco de dados realmente só funcionavam sob uWSGI, já que os dados do Merckx eram armazenados em um armazenamento global thread-local. Depois que a Uber começou a adotar o Tornado, um framework de aplicação assíncrono para serviços Python, o armazenamento thread-local não conseguiu representar muitas solicitações simultâneas executadas na mesma thread no IOLoop do Tornado. Começamos a perceber o quão importante era ter uma história sólida para manter o estado do pedido por perto e propagá-lo corretamente, sem depender de variáveis globais ou estado global.

Em seguida, rastreamento em TChannel

No início de 2015, iniciamos o desenvolvimento do TChannel, um protocolo de multiplexação de redes e enquadramento para RPC. Um dos objetivos do design do protocolo era ter o rastreamento distribuído estilo-Dapper incorporado no protocolo como um cidadão de primeira classe. Para esse objetivo, a especificação do protocolo TChannel definiu campos de rastreamento como parte do formato binário.

spanid:8 parentid:8 traceid:8 traceflags:1

field type description
spanid int64 that identifies the current span
parentid int64 of the previous span
traceid int64 assigned by the original requestor
traceflags uint8 bit flags field

Os campos de rastreamento aparecem como parte do formato binário na especificação do protocolo TChannel.

Além da especificação do protocolo, lançamos várias bibliotecas clientes de código aberto que implementam o protocolo em diferentes linguagens. Um dos princípios do design para essas bibliotecas era ter a noção de um contexto de solicitação pelo qual o aplicativo deveria passar a partir dos pontos de extremidade do servidor até os sites de chamada de downstream. Por exemplo, em tchannel-go, a assinatura para fazer uma chamada de saída com codificação JSON exigiu o contexto como o primeiro argumento:

func (c *Client) Call(ctx Context, method string, arg, resp interface{}) error {..}

As bibliotecas TChannel incentivaram os desenvolvedores de aplicativos a escrever seu código com propagação de contexto distribuída em mente.

As bibliotecas de cliente tinham suporte embutido para rastreamento distribuído fazendo marshalling no contexto de rastreamento entre a representação ligada e o objeto de contexto de memória e criando espaços de rastreamento em torno de manipuladores de serviço e as chamadas de saída. Internamente, os spans foram representados em um formato quase idêntico ao sistema de rastreamento Zipkin, incluindo o uso de anotações específicas do Zipkin, como “cs” (Client Send) e “cr” (Client Receive). O TChannel usou uma interface de rastreamento que reporta para enviar os spans de rastreamento coletados fora do processo para o backend do sistema de rastreamento. As bibliotecas vieram com uma implementação padrão que reporta que usava o próprio TChannel e Hyperbahn, a camada de descoberta e roteamento, para enviar os spans no formato Thrift para um cluster de coletores.

As bibliotecas de cliente TChannel nos aproximaram do sistema de rastreamento de distribuição de trabalho Uber necessário, fornecendo os seguintes blocos de construção:

  • Propagação de interprocessos do contexto de rastreamento, in-band com os pedidos
  • Instrumentação de API para gravar spans de rastreamento
  • Propagação in-process do contexto de rastreamento
  • Formato e mecanismo para relatar dados de rastreamento fora do processo para o backend de rastreamento

A única peça que faltava era o próprio backend de rastreamento. Tanto o formato ligado do contexto de rastreamento quanto o formato Thrift padrão usado pelo repórter foram projetados para torná-lo muito simples para integrar TChannel com um backend Zipkin. Entretanto, naquele tempo, a única maneira de enviar spans a Zipkin era através do Scribe, e o único armazenamento de dados performático que o Zipkin suportava era Cassandra. Naquela época, não tínhamos experiência operacional direta para nenhuma dessas tecnologias, por isso construímos um protótipo de backend que combinava alguns componentes personalizados com a Zipkin UI para formar um sistema completo de rastreamento.

A arquitetura do protótipo backend para rastro gerados pelo TChannel foi um modelo push com coletores e armazenamento personalizados e a interface de usuário aberta do Zipkin.

O sucesso dos sistemas distribuídos de rastreamento em outras grandes empresas de tecnologia como o Google e o Twitter foi baseado na disponibilidade de frameworks RPC, Stubby e Finagle, respectivamente, amplamente utilizados nessas empresas.

Da mesma forma, capacidades de rastreamento out-of-the-box no TChannel foram um grande passo à frente. O protótipo de backend implantado começou a receber vestígios de várias dezenas de serviços imediatamente. Mais serviços estavam sendo construídos usando o TChannel, mas a implantação da produção em larga escala e a adoção generalizada ainda eram problemáticas. O protótipo backend e seu armazenamento baseado em Riak/Solr tiveram alguns problemas de escalonamento para o tráfego do Uber, e vários recursos de consulta estavam faltando para interoperar corretamente com a Zipkin UI. E apesar da rápida adoção do TChannel por novos serviços, a Uber ainda tinha um grande número de serviços que não utilizavam TChannel para RPC; na verdade, a maioria dos serviços responsáveis ​​pela execução das principais funções de negócios funcionava sem TChannel. Esses serviços foram implementados em quatro principais linguagens de programação (Node.js, Python, Go e Java), usando uma variedade de frameworks diferentes para comunicação entre processos. Essa heterogeneidade do cenário tecnológico tornou a implementação de rastreamento distribuído na Uber uma tarefa muito mais difícil do que em locais como o Google e o Twitter.

Construindo Jaeger em Nova York

A organização Engenharia Uber NYC começou no início de 2015, com duas equipes principais: observabilidade no lado da infraestrutura e Uber Everything no lado do produto (incluindo UberEATS e UberRUSH). Como o rastreamento distribuído é uma forma de monitoramento da produção, era um bom ajuste para a observabilidade.

Formamos a equipe Distributed Tracing com dois engenheiros e dois objetivos: transformar o protótipo existente em um sistema de produção de grande escala e tornar o rastreamento distribuído disponível e adotado por todos os microsserviços da Uber. Também precisávamos de um nome de código para o projeto. Nomear as coisas é um dos dois difíceis problemas na ciência da computação, por isso levou-nos algumas semanas de brainstorming com os temas de rastreamento, detetives e caça, até que se estabeleceu o nome Jaeger (yā-gər), que em alemão significa caçador ou atendente de caça.

A equipe de NYC já tinha a experiência operacional de executar clusters Cassandra, que era o banco de dados diretamente suportado pelo backend Zipkin. Então, decidimos abandonar o protótipo Riak/Solr. Reimplementamos os coletores em Go para aceitar o tráfego TChannel e armazená-lo em Cassandra no formato binário compatível com Zipkin. Isso nos permitiu usar Zipkin web e serviços de consulta sem quaisquer modificações e também forneceu a funcionalidade ausente de procurar rastros por tags personalizadas. Também construímos um fator de multiplicação dinamicamente configurável em cada coletor para multiplicar o tráfego de entrada n vezes com a finalidade de testar o backend com dados de produção.

A arquitetura do Jaeger continuou confiando no formato de armazenamento de Zipkin UI e de Zipkin.

A segunda ordem do negócio era tornar o rastreamento disponível para todos os serviços existentes que não estavam usando TChannel para RPC. Passamos os meses seguintes construindo bibliotecas do lado do cliente em Go, Java, Python e Node.js para suportar a instrumentação de serviços arbitrários, incluindo os baseados em HTTP. Mesmo que o backend de Zipkin fosse razoavelmente conhecido e popular, faltava uma boa história no lado da instrumentação, especialmente fora do ecossistema de Java/Scala. Consideramos várias bibliotecas open source de instrumentação, mas elas eram mantidas por diferentes pessoas sem garantia de interoperabilidade ligada, muitas vezes com APIs completamente diferentes e a maioria exigindo Scribe ou Kafka como o transporte para relatar spans. Nós finalmente decidimos escrever nossas próprias bibliotecas que seriam testadas em integração para interoperabilidade, suportariam o transporte que precisávamos e, o mais importante, forneceriam uma API de instrumentação consistente em diferentes linguagens. Todas as nossas bibliotecas de cliente foram criadas para suportar a API OpenTracing desde o início.

Outro recurso inovador que criamos nas primeiras versões das bibliotecas de cliente foi a capacidade de pesquisar o backend de rastreamento para a estratégia de amostragem. Quando um serviço recebe uma solicitação que não tem metadados de rastreamento, a instrumentação de rastreamento normalmente inicia um novo rastreamento para essa solicitação gerando um novo ID de rastreamento aleatório. No entanto, a maioria dos sistemas de rastreamento de produção, especialmente aqueles que têm que lidar com a escala da Uber, não perfila cada traço único ou o grava em armazenamento. Isso criaria um volume proibitivo de tráfego dos serviços para o backend de rastreamento, possivelmente ordens de magnitude maior do que o tráfego de negócios real tratado pelos serviços. Em vez disso, a maioria dos sistemas de rastreamento mostra apenas uma pequena percentagem de rastros e apenas perfila e registra os rastros amostrados. O algoritmo exato para fazer uma decisão de amostragem é o que chamamos uma estratégia de amostragem. Exemplos de estratégias de amostragem incluem:

  • Experimente tudo. Isso é útil para testes, mas caro na produção!
  • Uma aproximação probabilística, onde um determinado rastro é amostrado aleatoriamente com uma determinada probabilidade fixa.
  • Uma abordagem de limitação de velocidade, onde um número X de rastros são amostrados por unidade de tempo. Por exemplo, uma variante do algoritmo leaky bucket pode ser usada.

A maioria das bibliotecas de instrumentação existentes compatíveis com Zipkin suporta amostragem probabilística, mas elas esperam que a taxa de amostragem seja configurada na inicialização. Tal abordagem leva a vários problemas sérios quando usada em escala:

  • Um determinado serviço tem poucas informações sobre o impacto da taxa de amostragem no tráfego global para o backend de rastreamento. Por exemplo, mesmo se o serviço propriamente dito tiver uma taxa Query Per Second (QPS) moderada, ele poderia estar chamando outro serviço downstream que tem um fator fanout muito alto ou usando instrumentação extensa que resulta em um monte de rastros spans.
  • Na Uber, o tráfego de negócios exibe forte sazonalidade diária; mais pessoas fazem passeios durante as horas de pico. Uma probabilidade de amostragem fixa pode ser muito baixa para tráfego fora do pico, mas muito alta para o tráfego de pico.

O recurso de polling nas bibliotecas de cliente Jaeger foi projetado para solucionar esses problemas. Ao mover a decisão sobre a estratégia de amostragem apropriada para o backend de rastreamento, nós, desenvolvedores de serviços gratuitos, adivinhamos a taxa de amostragem apropriada. Isso também permite que o backend ajuste dinamicamente as taxas de amostragem à medida que os padrões de tráfego mudam. O diagrama abaixo mostra o loop de feedback dos coletores para as bibliotecas de cliente.

As primeiras versões das bibliotecas de cliente ainda usavam TChannel para enviar spans de rastreamento fora do processo, submetendo-as diretamente aos coletores, de modo que as bibliotecas dependiam do Hyperbahn para descoberta e roteamento. Essa dependência criou fricções desnecessárias para os engenheiros que adotaram o rastreamento em seus serviços, tanto no nível de infraestrutura quanto no número de bibliotecas extras que eles tinham que puxar para o serviço, criando potencialmente um inferno de dependência.

Nós endereçamos isso implementando o processo do sidecar jaeger-agent, implementado em todos os hosts como um componente da infraestrutura, assim como os agentes que coletam métricas. Todas as dependências de roteamento e descoberta foram encapsuladas no jaeger-agent, e redesenhamos as bibliotecas de cliente para reportar rastros de spans para uma porta UDP local e pesquisar o agente na interface de loopback para as estratégias de amostragem. Somente as bibliotecas básicas de rede são exigidas pelos novos clientes. Essa mudança arquitetônica foi um passo em direção à nossa visão de usar amostras pós-rastro: rastros de buffering na memória nos agentes.

A atual arquitetura Jaeger: componentes de backend implementados no Go, bibliotecas de clientes em quatro linguagens que suportam padrão OpenTracing, um frontend web baseado em React e um pipeline de dados pós-processamento e agregação baseado no Apache Spark.

Rastreamento distribuído Turnkey

A Zipkin UI foi a última peça de software de terceiros que tivemos no Jaeger. Ter que armazenar spans em Cassandra no formato Zipkin Thrift para compatibilidade com a interface do usuário limitou nosso backend e modelo de dados. Em particular, o modelo Zipkin não suportava dois recursos importantes disponíveis no padrão OpenTracing e em nossas bibliotecas de cliente: uma API de log de chave-valor e rastros representados como grafos acíclicos direcionados mais gerais em vez de apenas árvores de spans. Decidimos renovar o modelo de dados em nosso backend e escrever uma nova interface do usuário. Abaixo, o novo modelo de dados suporta nativamente tanto o registro de chave-valor quanto as referências de span. Também otimiza o volume de dados enviados fora do processo, evitando a duplicação de tag de processo em cada período:

O modelo de dados Jaeger suporta nativamente tanto o registro de chave-valor quanto as referências de span.

Atualmente, estamos concluindo a atualização do pipeline de backend para o novo modelo de dados e um novo, melhor e otimizado esquema de Cassandra. Para aproveitar o novo modelo de dados, implementamos um novo serviço de consulta Jaeger em Go e uma nova interface web criada com o React. A versão inicial da interface do usuário reproduz principalmente os recursos existentes da Zipkin UI, mas foi arquitetada para ser facilmente extensível com novos recursos e componentes, bem como embutida em outras interfaces de usuário como um próprio componente React. Por exemplo, um usuário pode selecionar um número de visualizações diferentes para visualizar resultados de rastreamento, como um histograma de durações de rastreamento ou o tempo cumulativo do serviço no rastreamento:

A UI Jaeger mostra resultados de pesquisa de rastreamento. No canto superior direito, um gráfico de dispersão de duração versus de tempo fornece uma representação visual dos resultados e capacidade de drill-down.

Como outro exemplo, um único rastreamento pode ser visualizado de acordo com casos de uso específicos. A renderização padrão é uma sequência de tempo; outras visualizações incluem um gráfico acíclico dirigido ou um diagrama de caminho crítico:

A UI Jaeger mostra detalhes de um único rastreamento. Na parte superior da tela, está um diagrama de minimap do rastreamento que suporta navegação mais fácil com grandes rastreamentos.

Ao substituir os componentes Zipkin restantes em nossa arquitetura com os próprios componentes do Jaeger, posicionamos o Jaeger como um sistema de rastreamento distribuído ponta-a-ponta turnkey.

Acreditamos que é fundamental que as bibliotecas de instrumentação sejam inerentemente parte do Jaeger para garantir tanto sua compatibilidade com o backend Jaeger quanto a interoperabilidade entre si através de testes de integração contínuos (essa garantia não estava disponível no ecossistema de Zipkin). Em particular, a interoperabilidade entre todas as linguagens suportadas (atualmente Go, Java, Python e Node.js) e todos os transportes ligados suportados (atualmente HTTP e TChannel) são testados como parte de cada pull request com a ajuda do framework crossdock, escrito pela equipe Uber Engenharia RPC. Você pode encontrar os detalhes dos testes de integração de clientes Jaeger no repositório crossdock jaeger-client-go. No momento, todas as bibliotecas de cliente Jaeger estão disponíveis como código aberto:

Estamos migrando o backend e o código UI para o GitHub e planejamos ter o código-fonte do Jaeger completo disponível em breve. Se você estiver interessado no progresso, vá ao repositório principal. Contribuições são bem-vindas, e gostaríamos de ver outros darem uma chance para o Jaeger.

***

Este artigo é do Uber Engineering Team. Ele foi escrito por Yuri Shkuro. A tradução foi feita pela Redação iMasters com autorização. Você pode conferir o original em: https://eng.uber.com/distributed-tracing/.