Blogue de Engenharia - Prexy: regras de pesquisa e substituição super rápidas

Partilhar

Bem-vindo ao nosso primeiro blogue de engenharia. Pode ser um pouco mais técnico do que aquele a que está habituado nos outros blogues, mas fizemos o nosso melhor para o tornar compreensível para todos. Neste artigo, vamos falar sobre o Prexy, uma nova peça de software do Clonable utilizada para aplicar regras de substituição.

Antecedentes

Como utilizador do Clonable, já deve estar familiarizado com a funcionalidade das regras de substituição. Pode utilizar estas regras de pesquisa e substituição para trocar partes de texto ou código por uma variante selecionada pelo utilizador. Pode utilizá-las, por exemplo, para substituir uma chave de API ou um ID de análise para poder criar análises diferentes para o seu site original e para os seus clones traduzidos. Internamente, estas mesmas regras também são utilizadas para substituir o seu nome de domínio original pelo nome de domínio do seu clone e uma série de outras coisas.

Desde a primeira versão do Clonable, esta funcionalidade nunca foi actualizada, o que, em si, não é surpreendente, uma vez que cumpria muito bem a sua função. No entanto, eram possíveis melhorias, tanto em termos de desempenho como de facilidade de utilização. De vez em quando, nós Clonable, pegamos numa parte do nosso produto que já não é utilizada há muito tempo para começar a melhorá-la. No ano passado, por exemplo, revimos toda a nossa infraestrutura de dados para a tornar mais rápida, mais escalável e mais tolerante a falhas e, antes disso, também reconstruímos de raiz a funcionalidade de tradução de URL. Neste trimestre, foi a vez das regras de substituição.

Situação antiga e limitações

As regras de substituição são aplicadas no Clonable apenas na última fase, imediatamente antes de enviar a resposta de volta ao cliente. A primeira versão do Clonable optou, portanto, por implementar isso no servidor web NGINX usando o replace-filter-nginx-module, um módulo criado pela OpenResty. O módulo usa sregex como seu mecanismo de streaming regex, também criado pela OpenResty. O aspeto de streaming é importante neste caso, porque nós não queremos carregar cada resposta completamente na memória. Se o fizéssemos, um número de respostas grandes poderia fazer com que o NGINX travasse ou falhasse por não haver memória suficiente disponível. Uma segunda caraterística importante do sregex é que ele pode processar várias linhas em paralelo. Isso ocorre porque cada clone já tem algumas regras padrão e, além disso, às vezes algumas regras adicionadas especificamente para o clone. Se estas não pudessem ser tratadas em paralelo, ainda teríamos de guardar toda a resposta, porque teríamos de ser capazes de a fazer corresponder novamente após a primeira regra para a segunda regra.

No entanto, no ano passado, deparámo-nos cada vez mais com problemas de desempenho estranhos. Estes problemas eram muitas vezes de curta duração, mas podiam aumentar o tempo de resposta de uma página em segundos em casos extremos. Após um desses picos, a página voltava a carregar normalmente, tornando o problema difícil de reproduzir. Para depurar ainda mais o problema, adicionámos o cabeçalho Clonable-Timings a todas as respostas, o que nos permitiu determinar aproximadamente qual o passo do processo de tradução que estava a demorar tanto tempo. Isso revelou que, em quase todos os casos, o upstream respondeu rapidamente, e a tradução também foi bastante tranquila. No entanto, houve um intervalo entre a conclusão da tradução e a conclusão total do pedido, o que nos deu uma dica de que poderia ter a ver com as regras de substituição.

Modelo de concorrência do NGINX

No entanto, isso ainda não nos deu uma resposta sobre por que esses atrasos ocorriam apenas esporadicamente e por que isso acontecia quando os servidores não estavam carregados nem mesmo com metade de sua capacidade. Para compreender melhor este fenómeno, temos de analisar um pouco melhor a forma como o NGINX lida com as cargas de trabalho.

O NGINX tem uma arquitetura de trabalhador com um processo mestre e vários processos de trabalhador. As conexões são distribuídas entre os workers para lidar com várias solicitações simultaneamente. No entanto, esta configuração também tem uma desvantagem: quando um trabalhador está ocupado a processar um pedido, os outros pedidos também atribuídos a esse mesmo trabalhador têm de esperar. Isso pode fazer com que um pedido pesado cause atrasos em vários pedidos, mesmo que os outros trabalhadores não tenham nada para fazer. Esse efeito é bem parecido com o da imagem abaixo. Durante a criação desta imagem, um teste de estresse está sendo executado em 10 conexões diferentes. A imagem mostra claramente que três workers estão ocupados e um quarto não está fazendo quase nada.

Tempo para o Prexy

Com o gargalo claramente visível, criámos um plano para o melhorar. Este projeto foi denominado Prexy, uma amálgama de RegEx e Proxy. Havia 3 requisitos principais para o resultado final:

  1. Deve ser um substituto imediato para a configuração atual. As regras de substituição devem ser aplicadas da mesma forma para evitar quebras nas configurações existentes.

  2. As regras de substituição devem ser transmitidas em fluxo contínuo para evitar que a solução utilize demasiada memória.

  3. A nova solução deve ser mais rápida do que a solução atual.

Primeiro, tivemos de procurar um tempo de execução multithread potente que pudesse processar pedidos de forma eficiente. Depois de considerar várias opções, a escolha recaiu sobre o tempo de execução Tokyo para a linguagem de programação Rust. A Rust é conhecida por permitir escrever programas rápidos sem os riscos de segurança de outras linguagens de baixo nível, como o C++. O tempo de execução Tokyo é um tempo de execução assíncrono flexível, concebido para aplicações em rede. Uma das caraterísticas mais importantes para nós é o facto de ser um roubo de trabalho. Isso significa que, ao contrário do NGINX, um pedido não está vinculado a um trabalhador, mas um trabalhador que não tem nada para fazer pode "roubar" trabalho de outro trabalhador. Desta forma, os recursos dos nossos servidores podem ser melhor utilizados.

Primeiro protótipo

Depois de escolher as tecnologias subjacentes, decidimos criar um protótipo inicial para estimar aproximadamente o ganho de velocidade que este projeto nos daria e se valeria a pena. Como implementação inicial para o motor regex (a parte que realmente processa e aplica as regras), usámos a biblioteca regex padrão (ou crate, como é chamada na terminologia Rust). Esse mecanismo, como o sregex, não é backtracking, o que significa menos risco de um problema de ReDoS. Em um ReDoS, uma expressão regular enfrenta uma entrada tal que o tempo que leva para avaliar a entrada aumenta exponencialmente. Isso pode ser desastroso para o desempenho e fez com que o cloudflare tivesse uma grande interrupção. Portanto, para nós, é importante que o mecanismo que usamos não tenha backtracking.

Utilizando este motor regex, construímos um protótipo inicial. Esse protótipo usava regras codificadas e o mecanismo de regex armazenava toda a resposta em buffer em vez de transmiti-la, mas isso era suficiente para um teste de desempenho inicial.

Utilizando este motor regex, construímos um protótipo inicial. Esse protótipo usava regras codificadas e o mecanismo de regex armazenava em buffer toda a resposta em vez de transmiti-la, mas isso era suficiente para um teste de desempenho inicial. Nossa configuração de teste consistia em duas VMs, com uma VM executando uma configuração na qual tanto a solução antiga quanto a nova solução podiam ser executadas. Dessa forma, poderíamos facilmente fazer comparações entre os dois métodos. O outro servidor executava o NGINX, que servia como servidor de origem e servia um ficheiro de teste. O mesmo servidor também executou a ferramenta wrk, que usámos para gerar a carga. Isso não é totalmente ideal, pois para dados puros é preferível executar o gerador de carga em uma VM separada, mas essa VM tinha recursos suficientes para que o NGINX e o wrk não interferissem um no outro.

Os primeiros resultados dos testes foram muito claros: o Prexy foi cerca de 22 vezes mais rápido no tratamento de um único pedido (ver a imagem abaixo). Isto deu luz verde final ao projeto, porque com uma diferença tão grande havia espaço suficiente para absorver algumas degradações de desempenho que poderiam ocorrer ao completar a funcionalidade.

Após os testes iniciais, implementámos algumas optimizações óbvias, como o armazenamento em cache de expressões regulares compiladas. Também implementámos uma forma mais eficiente de reutilizar ligações ao upstream. Com isso, conseguimos um ganho de velocidade de cerca de 20%. De seguida, testámos também ambas as soluções em picos de carga, enviando o maior número possível de pedidos ao servidor. Também aqui, a diferença foi claramente visível e o Prexy alcançou uma taxa de transferência cerca de 25x superior à solução antiga.

Motor regex de fluxo contínuo

Um dos requisitos deste projeto era que o motor regex utilizado fosse de streaming. A criação de regex do protótipo não é, portanto, foi necessário trabalho adicional nessa área. No entanto, o regex crate acabou por ser altamente optimizado, pelo que decidimos tomar esta implementação como base para o nosso motor de streaming. Ao fazer streaming de dados através de um motor regex, há algumas coisas importantes a ter em mente. Primeiro, não se sabe que dados ainda estão por vir. Por isso, deve começar a trabalhar com correspondências parciais: correspondências que ainda não estão completas, mas que já corresponderam a 1 ou mais caracteres. Ao encontrar uma correspondência completa, tem de verificar se não existem correspondências parciais sobrepostas, porque estas também podem acabar por ser correspondências (e uma correspondência mais longa ganha a uma correspondência mais curta em caso de sobreposição). Assim, só é possível começar a processar uma correspondência se todas as correspondências parciais actuais não forem uma correspondência completa.

Outras optimizações

Como esperado, a reconstrução do motor regex teve um impacto negativo no desempenho do Prexy. No entanto, ainda havia uma grande margem em comparação com a solução antiga e, ao adicionar mais optimizações, acabámos por ficar ainda mais elevados do que antes de reconstruir o motor regex. Uma das optimizações que aplicámos foi a de nos lembrarmos se devíamos aplicar uma regra a um determinado ficheiro. Os activos estáticos, como os ficheiros CSS e JS, quase nunca mudam e, por isso, é inútil executar sempre uma regra quando esta nunca corresponde a esse ficheiro específico. Uma alternativa a isso era armazenar em cache o resultado das substituições, mas a desvantagem dessa estratégia é que é preciso muita memória para armazenar todos os arquivos. Ao lembrar quais as regras a aplicar, precisamos apenas de alguns bytes por ficheiro, armazenando a informação como um mapa de bits.

Além disso, tiramos o máximo partido das optimizações do motor regex, por exemplo, não mantendo os grupos de captura quando não são utilizados na substituição. Como resultado, o motor regex só precisa de se lembrar do início e do fim de toda a correspondência, o que poupa trabalho. Também desactivámos os grupos de captura nomeados, uma vez que era bastante complexo na variante de fluxo contínuo e, como a solução antiga também não o suportava, não era necessariamente necessário. Todas estas optimizações acabaram por proporcionar o seguinte desempenho:

O resultado final

Depois de testar extensivamente o Prexy para garantir que o seu comportamento era de facto o mesmo que o da solução antiga, começámos a implementá-lo de forma incremental. Durante um período de cerca de uma semana, activámos o Prexy para cada clone. Na nossa monitorização, o momento da ativação era muitas vezes fácil de ver. Uma diminuição de cerca de 50% nos tempos de resposta não era rara de se ver. Também foram observadas pequenas diferenças de cerca de 10% noutros clientes que tinham muito poucas regras de substituição. O nosso próprio sítio Web tornou-se cerca de 30% mais rápido (~50ms).

Em toda a nossa infraestrutura geral, observamos as seguintes diferenças após a implementação do Prexy.

  • 33% menos consumo de RAM

  • 20% menos consumo de CPU

  • Respostas 10-50 % mais rápidas

Em suma, podemos dizer que o Prexy foi um projeto bem sucedido. Ao substituir um módulo NGINX desatualizado, podemos agora utilizar técnicas mais recentes e mais eficientes que fazem uma diferença clara e mensurável para todos os nossos clientes. No futuro, continuaremos a melhorar o Clonable, tanto em termos de novas caraterísticas como de melhoria da funcionalidade existente.


Obrigado por ler este blogue de engenharia. Diga-nos se gostaria de receber este tipo de informações técnicas sobre o nosso produto com mais frequência. Ficou entusiasmado com este projeto? Então dá uma vista de olhos na nossa página de vagas :)

Utilizamos cookies para melhorar a sua experiência, analisar o tráfego do site e aperfeiçoar os nossos serviços.

Leia a nossa política de cookies