Tradução do artigo:
Este artigo me pareceu bastante interessante, e como o Envoy é frequentemente utilizado como parte do 'istio' ou simplesmente como 'controlador de ingresso' do Kubernetes, a maioria das pessoas não tem uma interação tão direta com ele quanto, por exemplo, com instalações típicas do Nginx ou Haproxy. No entanto, se algo falhar, seria bom entender como tudo isso funciona internamente. Eu tentei traduzir o máximo de texto possível para o russo, incluindo palavras específicas, e para aqueles que acham difícil olhar para isso, deixei os originais entre parênteses. Bem-vindo à continuação.
A documentação técnica de baixo nível sobre a base de código do Envoy é atualmente bastante escassa. Para corrigir isso, planejo fazer uma série de artigos no blog sobre vários subsistemas do Envoy. Como este é o primeiro artigo, por favor, me avise o que você acha e o que poderia ser interessante nos próximos artigos.
Uma das perguntas técnicas mais comuns que recebo sobre o Envoy é o pedido por uma descrição de baixo nível do modelo de threading utilizado. Neste post, vou descrever como o Envoy associa conexões a threads, bem como a descrição do sistema de armazenamento local de threads (Thread Local Storage), que é utilizado internamente para tornar o código mais paralelo e de alto desempenho.
Visão geral do threading
![[Перевод] Envoy modelo de threads (Envoy threading model)](/wp-content/uploads/2019/04/c5028463db1eb5b07ccb1a3af1f64a03.jpeg)
O Envoy utiliza três tipos diferentes de threads:
- Principal (Main): Esta thread gerencia o início e a finalização do processo, todo o processamento da API do XDS (xDiscovery Service), incluindo DNS, verificação de integridade (health checking), gerenciamento geral do cluster e do processo de execução do serviço (runtime), redefinição de estatísticas, administração e gerenciamento geral dos processos — sinais do Linux, reinicialização a quente (hot restart) etc. Tudo que acontece nesta thread é assíncrono e 'não bloqueante'. Em geral, a thread principal coordena todos os processos críticos de funcionalidade, para os quais não é necessário um grande uso de CPU. Isso permite que a maior parte do código de gerenciamento seja escrita como se fosse de um único thread.
- Trabalhador (Worker): Por padrão, o Envoy cria uma thread de trabalhador (worker thread) para cada thread de hardware no sistema, o que pode ser controlado pela opção
--concurrencyCada thread de trabalho inicia um ciclo de eventos 'não bloqueante', que é responsável por escutar cada listener. No momento em que este artigo foi escrito (29 de julho de 2017), não há segmentação (sharding) de listeners, aceitação de novas conexões, criação de uma instância de pilha de filtros para conectar e processar todas as operações de entrada e saída (IO) durante a existência da conexão. Mais uma vez, isso permite que grande parte do código de processamento de conexões seja escrito como se fosse de thread única. - Flusher de Arquivo: Cada arquivo que o Envoy escreve, principalmente logs de acesso, atualmente tem um thread bloqueante independente. Isso ocorre porque a gravação em arquivos em cache pelo sistema de arquivos, mesmo ao usar
O_NONBLOCKpode, às vezes, bloquear (suspiro). Quando os threads de trabalho precisam gravar em um arquivo, os dados são efetivamente movidos para um buffer na memória, onde eventualmente são descarregados através do thread file flush. Esta é uma das áreas do código onde, tecnicamente, todos os threads de trabalho podem bloquear a mesma trava, tentando preencher o buffer de memória.
Manipulação de Conexões
Como discutido brevemente acima, todos os threads de trabalho escutam todos os listeners sem qualquer segmentação. Assim, o núcleo é utilizado para encaminhar corretamente os sockets aceitos para os threads de trabalho. Núcleos modernos são geralmente muito bons nisso, eles utilizam recursos como priorização de IO para tentar preencher um thread de trabalho com tarefas antes de começar a usar outros threads que também escutam o mesmo socket, além de não utilizar lock giratório para processar cada solicitação.
Uma vez que a conexão é aceita em um thread de trabalho, ela nunca deixa esse thread. Todo o processamento subsequente da conexão é totalmente tratado no thread de trabalho, incluindo qualquer comportamento de encaminhamento.
Isso tem várias consequências importantes:
- Todos os pools de conexões no Envoy pertencem a um fluxo de trabalho. Assim, embora os pools de conexões HTTP/2 realizem apenas uma conexão com cada host remoto de cada vez, se houver quatro fluxos de trabalho, haverá quatro conexões HTTP/2 para o host remoto em estado estável.
- A razão pela qual o Envoy funciona dessa maneira é que, mantendo tudo em um único fluxo de trabalho, quase todo o código pode ser escrito sem bloqueios e como se fosse de thread única. Esse design simplifica a escrita de uma grande quantidade de código e escala incrivelmente bem para um número quase ilimitado de fluxos de trabalho.
- No entanto, uma das principais conclusões é que, do ponto de vista da eficiência do pool de memória e conexões, é realmente importante ajustar o parâmetro
--concurrency. Ter mais fluxos de trabalho do que o necessário resultará em perda de memória, em um maior número de conexões ociosas e na diminuição da velocidade de acesso ao pool de conexões. Na Lyft, nossos contêineres envoy sidecar operam com um nível de paralelismo muito baixo, de modo que o desempenho corresponde aproximadamente aos serviços com os quais estão próximos. Executamos o Envoy como um proxy de borda apenas em máxima concorrência.
O que significa modo não bloqueante
O termo "não bloqueante" foi usado várias vezes na discussão de como funcionam o fluxo principal e os fluxos de trabalho. Todo o código foi escrito com a premissa de que nada nunca é bloqueado. No entanto, isso não está totalmente correto.
Envoy usa várias bloqueios de longo prazo no processo:
- Como mencionado, ao registrar logs de acesso, todos os fluxos de trabalho recebem um bloqueio igual antes de preencher o buffer de log na memória. O tempo de retenção do bloqueio deve ser muito baixo, mas é possível que esse bloqueio seja contestado em alta paralelismo e alta capacidade de transmissão.
- O Envoy utiliza um sistema muito complexo para processar estatísticas, que é local para o fluxo. Este será o tema de uma postagem separada. No entanto, mencionarei brevemente que, como parte do processamento local das estatísticas do fluxo, às vezes é necessário obter um bloqueio para o "armazenamento central de estatísticas". Esse bloqueio nunca deve ser necessário.
- O fluxo principal precisa periodicamente coordenar-se com todos os fluxos de trabalho. Isso é feito por meio da "publicação" do fluxo principal para os fluxos de trabalho e, às vezes, dos fluxos de trabalho de volta para o fluxo principal. Para enviar, um bloqueio é necessário para que a mensagem publicada possa ser colocada na fila para entrega posterior. Esses bloqueios nunca devem sofrer competição significativa, mas ainda assim podem ser tecnicamente bloqueados.
- Quando o Envoy escreve no log do fluxo de erros do sistema (standard error), ele obtém um bloqueio de todo o processo. Em geral, a gravação local de logs do Envoy é considerada péssima em termos de desempenho, por isso não recebe muita atenção para melhoria.
- Existem alguns outros bloqueios ocasionais, mas nenhum deles é crítico para o desempenho e nunca deve ser contestado.
Armazenamento local do fluxo (Thread local storage)
Devido à maneira como o Envoy separa as responsabilidades do fluxo principal das responsabilidades do fluxo de trabalho, há uma demanda de que o processamento complexo possa ser realizado no fluxo principal e, em seguida, fornecido a cada fluxo de trabalho com um alto grau de paralelismo. Esta seção descreve o sistema de Armazenamento Local de Threads (TLS) do Envoy em um nível elevado. Na próxima seção, descreverei como ele é utilizado para gerenciar o cluster.
![[Перевод] Envoy modelo de threads (Envoy threading model)](/wp-content/uploads/2019/04/a47af1e8eb1f55a4d7609b3ff3ee9ce1.jpeg)
Como já foi descrito, o fluxo principal processa praticamente todas as funções de gerenciamento e funcionalidade do plano de controle no processo do Envoy. O plano de controle aqui está um pouco sobrecarregado, mas se considerarmos isso dentro do próprio processo do Envoy e compararmos com o encaminhamento realizado pelos fluxos de trabalho, isso parece razoável. Como regra geral, o processo do fluxo principal realiza algum trabalho e, em seguida, precisa atualizar cada fluxo de trabalho de acordo com o resultado desse trabalho. Nesse caso, a thread de trabalho não precisa aplicar bloqueio a cada acesso..
O sistema TLS (Thread Local Storage) do Envoy funciona da seguinte forma:
- O código que é executado na thread principal pode alocar um slot TLS para todo o processo. Embora isso seja abstrato, na prática, é um índice em um vetor que proporciona acesso O(1).
- A thread principal pode definir dados arbitrários em seu slot. Quando isso é feito, os dados são publicados em cada thread de trabalho como um evento normal do ciclo de eventos.
- As threads de trabalho podem ler de seu slot TLS e extrair quaisquer dados locais de thread disponíveis lá.
Embora seja uma paradigma muito simples e incrivelmente poderoso que se assemelha ao conceito de bloqueio RCU (Read-Copy-Update). Essencialmente, as threads de trabalho nunca veem qualquer alteração de dados nos slots TLS durante a execução do trabalho. A modificação ocorre apenas durante os períodos de inatividade entre os eventos de trabalho.
O Envoy utiliza isso de duas maneiras diferentes:
- Armazenando dados variados em cada thread de trabalho, o acesso a esses dados é feito sem qualquer bloqueio.
- Armazenando um ponteiro comum para dados globais em modo ‘apenas leitura’ em cada thread de trabalho. Assim, cada thread de trabalho tem um contador de referências para os dados, que não pode ser reduzido durante a execução do trabalho. Somente quando todos os trabalhadores se acalmarem e carregarem novos dados globais, os dados antigos serão destruídos. Isso é idêntico ao RCU.
Thread de atualização de cluster (Cluster Update Threading)
Nesta seção, descreverei como o TLS (Thread Local Storage) é usado para gerenciar o cluster. A gestão do cluster envolve a manipulação da API xDS e/ou DNS, bem como a verificação de integridade (health checking).
![[Перевод] Envoy modelo de threads (Envoy threading model)](/wp-content/uploads/2019/04/238f5718729e1f12f8f0d6507f16abe8.jpeg)
A gestão de threads do cluster inclui os seguintes componentes e etapas:
- O gerenciador de cluster é um componente dentro do Envoy que gerencia todos os upstreams conhecidos do cluster, a API CDS (Cluster Discovery Service), as APIs SDS (Secret Discovery Service) e EDS (Endpoint Discovery Service), DNS e verificações de integridade (health checking) ativas. Ele é responsável por criar uma representação ‘eventualmente consistente’ de cada upstream do cluster, que inclui hosts descobertos, bem como o estado de saúde (health status).
- O verificador de saúde realiza verificações ativas de disponibilidade e informa ao gerenciador de cluster sobre mudanças no estado de disponibilidade.
- CDS (Serviço de Descoberta de Cluster) / SDS (Serviço de Descoberta Secreta) / EDS (Serviço de Descoberta de Endpoint) / DNS são executados para determinar a pertença ao cluster. A mudança de estado é retornada ao gerente do cluster.
- Cada thread de trabalho executa continuamente um ciclo de processamento de eventos.
- Quando o gerente de cluster determina que o estado do cluster mudou, ele cria uma nova captura de estado do cluster, disponível apenas para leitura, e a envia para cada thread de trabalho.
- Durante o próximo período de inatividade, a thread de trabalho atualizará a captura no slot TLS reservado.
- Durante um evento de entrada/saída que deve determinar o host para balanceamento de carga, o balanceador de carga solicitará o slot TLS (armazenamento local de thread) para obter informações sobre o host. Para isso, não são necessárias bloqueios. Observe também que o TLS pode gerar eventos durante a atualização, permitindo que os subsistemas de balanceamento de carga e outros componentes recalcularem caches, estruturas de dados, etc. Isso está além do escopo deste post, mas é utilizado em vários lugares no código.
Usando o procedimento descrito acima, o Envoy pode processar cada solicitação sem quaisquer bloqueios (exceto os mencionados anteriormente). Além da complexidade do próprio código TLS, a maior parte do código não precisa entender como a multithreading funciona e pode ser escrita em modo de thread única. Isso facilita a escrita da maior parte do código, além de oferecer um desempenho excelente.
Outros subsistemas que utilizam TLS
TLS (armazenamento local de thread) e RCU (Atualização de Leitura e Copia) são amplamente utilizados no Envoy.
Exemplos de uso:
- Mecanismo de alteração de funcionalidade em tempo de execução: A lista atual de funcionalidades ativadas é calculada na thread principal. Então, cada thread de trabalho recebe uma captura somente leitura utilizando a semântica RCU.
- Substituição de tabelas de rotas: para tabelas de rotas fornecidas pelo RDS (Route Discovery Service), as tabelas de rotas são criadas no fluxo principal. Uma cópia somente leitura será fornecida a cada thread de trabalho posteriormente, usando a semântica RCU (Read Copy Update). Isso torna a modificação das tabelas de rotas atomicamente eficiente.
- Cache de cabeçalhos HTTP: Como se verifica, calcular o cabeçalho HTTP para cada solicitação (com cerca de ~25K+ RPS por núcleo) é bastante custoso. O Envoy calcula centralizadamente o cabeçalho aproximadamente a cada meio segundo e o fornece a cada trabalhador através do TLS e RCU.
Há outros casos, mas os exemplos anteriores devem fornecer uma boa compreensão do uso do TLS.
Armadilhas de desempenho conhecidas (Known performance pitfalls)
Embora o Envoy funcione bem em geral, existem algumas áreas conhecidas que requerem atenção quando usado com muito alto paralelismo e largura de banda:
- Como já descrito neste artigo, atualmente todas as threads de trabalho ficam bloqueadas ao escrever no buffer de memória do log de acesso. Com alto paralelismo e alta largura de banda, será necessário agrupar logs de acesso para cada thread de trabalho às custas de entregas desordenadas ao gravar no arquivo final. Como alternativa, pode-se criar um log de acesso separado para cada thread de trabalho.
- Embora as estatísticas sejam muito otimizações, em situações de muito alto paralelismo e largura de banda, provavelmente haverá competição atômica nas estatísticas individuais. A solução para esse problema é ter contadores para uma thread de trabalho com redefinição periódica dos contadores centrais. Isso será discutido em um post subsequente.
- A arquitetura existente não funcionará bem se o Envoy for implementado em um cenário com muito poucas conexões que exigem recursos significativos para processamento. Não há garantia de que as conexões serão distribuídas uniformemente entre as threads de trabalho. Isso pode ser resolvido implementando o balanceamento de conexões de trabalho, onde a possibilidade de troca de conexões entre as threads de trabalho será realizada.
Conclusão (Conclusion)
O modelo de threads do Envoy foi desenvolvido para garantir facilidade de programação e paralelismo em grande escala, mas pode resultar em uso excessivo de memória e conexões, caso não seja configurado adequadamente. Esse modelo permite que ele funcione muito bem com um número extremamente elevado de threads e largura de banda.
Como mencionei brevemente no Twitter, o design também pode operar sobre uma pilha de rede totalmente funcional no modo usuário, como o DPDK (Data Plane Development Kit), o que pode levar servidores comuns a processar milhões de solicitações por segundo com um processamento completo no L7. Será muito interessante ver o que será desenvolvido nos próximos anos.
Um último comentário rápido: já me perguntaram várias vezes por que escolhemos C++ para o Envoy. A razão continua sendo que ainda é a única linguagem de nível industrial amplamente adotada que pode construir a arquitetura descrita neste post. C++ definitivamente não é a melhor escolha para todos ou mesmo para muitos projetos, mas para casos de uso específicos, ainda é a única ferramenta capaz de fazer o trabalho.
Links para código
Links para arquivos de interfaces e implementações de cabeçalho discutidos neste post:
Fonte: habr.com
