Blog de Engenharia - Prexy: regras de pesquisa e substituição ultrarrápidas

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

Contexto

Como utilizador do Clonable, talvez já conheça a funcionalidade das regras de substituição. Pode utilizar estas regras de pesquisa e substituição para trocar trechos de texto ou código por uma variante à sua escolha. Pode utilizar isto, por exemplo, para substituir uma chave API ou um ID de análise, de modo a poder realizar análises diferentes para o site de origem 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, entre outras coisas.

Desde a primeira versão do Clonable que esta funcionalidade nunca foi alterada, o que, por si só, não é de estranhar, uma vez que funcionava muito bem. No entanto, eram possíveis melhorias, tanto em termos de desempenho como de facilidade de utilização. De vez em quando, pegamos no Clonable — uma parte do nosso produto à qual já há muito não dedicávamos tempo — para a melhorar. Assim, no ano passado, revimos toda a nossa infraestrutura de dados para a tornar mais rápida, mais escalável e mais tolerante a erros, e, para tal, já tínhamos reconstruído do zero a funcionalidade de tradução de URLs. Neste trimestre, foi a vez das regras de substituição.

Situação anterior e limitações

No Clonable, as regras de substituição só são aplicadas na fase final, imediatamente antes de enviar a resposta ao cliente. Por isso, na primeira versão do Clonable, optou-se por implementar isto no servidor web NGINX utilizando o módulo replace-filter-nginx, um módulo criado pela OpenResty. O módulo utiliza o sregex como motor de expressões regulares em streaming, também criado pela OpenResty. O aspeto do streaming é importante neste caso, porque não queremos carregar cada resposta na totalidade na memória. Se o fizéssemos, algumas respostas de grande dimensão poderiam fazer com que o NGINX falhasse ou ficasse bloqueado devido à falta de memória disponível. Uma segunda característica importante do sregex é a sua capacidade de processar várias regras em paralelo. Cada clone tem, de facto, várias regras padrão e, além disso, por vezes, algumas regras adicionadas especificamente para esse clone. Se estas não pudessem ser processadas em simultâneo, teríamos, mesmo assim, de armazenar toda a resposta na memória, uma vez que, após a primeira regra, teríamos de voltar a percorrê-la para a segunda regra.

No ano passado, porém, deparámo-nos cada vez com mais frequência com problemas de desempenho estranhos. Estes problemas eram frequentemente de curta duração, mas, em casos extremos, podiam aumentar o tempo de resposta de uma página em alguns segundos. Após um desses picos, a página voltava frequentemente a carregar normalmente, o que tornava o problema difícil de reproduzir. Para depurar melhor esta situação, adicionámos o cabeçalho «Clonable-Timings» a todas as respostas, o que nos permitiu determinar, em linhas gerais, qual a etapa do processo de tradução que demorava tanto tempo. Ficou claro que, em quase todos os casos, o servidor upstream respondia rapidamente e que a tradução também decorria de forma bastante ágil. No entanto, havia um intervalo entre a conclusão da tradução e a conclusão total do pedido, o que nos deu uma pista de que o problema poderia estar relacionado com as regras de substituição.

O modelo de concorrência do NGINX

No entanto, isto ainda não nos deu uma resposta à questão de por que razão estes atrasos ocorriam apenas esporadicamente e por que razão isto acontecia quando os servidores nem sequer estavam a ser utilizados a metade da sua capacidade. Para compreender melhor este fenómeno, temos de analisar mais a fundo a forma como o NGINX lida com as cargas de trabalho.

O NGINX possui uma arquitetura de «workers» com um processo «mestre» e vários processos «workers». As ligações são distribuídas pelos workers para que seja possível processar vários pedidos em simultâneo. No entanto, esta configuração também tem uma desvantagem: quando um worker está ocupado a processar um pedido, os outros pedidos que também lhe foram atribuídos têm de esperar. Isto pode fazer com que um único pedido pesado provoque atrasos em vários pedidos, mesmo que os outros workers não tenham nada para fazer. Este efeito é bem visível na imagem abaixo. Durante a criação desta imagem, estava a decorrer um teste de carga em 10 ligações diferentes. Na imagem, é claramente visível que há 3 processos de trabalho ocupados e um quarto que quase não está a fazer nada.

Está na hora do Prexy

Com o gargalo claramente visível, elaborámos um plano para melhorar esta situação. Este projeto recebeu o nome de Prexy, uma combinação de RegEx e Proxy. Havia três requisitos principais para o resultado final:

  1. Deve ser um substituto direto da configuração atual. As regras de substituição devem ser aplicadas da mesma forma para evitar que algo deixe de funcionar nas configurações existentes.

  2. As regras de substituição devem ser aplicadas de forma contínua para evitar que a solução consuma demasiada memória.

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

Em primeiro lugar, tivemos de procurar um runtime multithreaded potente, capaz de processar pedidos de forma eficiente. Depois de ponderarmos várias opções, a escolha recaiu sobre o runtime Tokio para a linguagem de programação Rust. O Rust é conhecido por permitir escrever programas rápidos, sem os riscos de segurança de outras linguagens de baixo nível, como o C++. O runtime Tokio é um runtime assíncrono flexível, concebido para aplicações de rede. Uma das funcionalidades mais importantes para nós é o facto de ser «work-stealing». Isto significa que, ao contrário do NGINX, uma solicitação não está vinculada a um único trabalhador, mas sim que um trabalhador que não tenha nada para fazer pode «roubar» trabalho a outro trabalhador. Desta forma, os recursos dos nossos servidores podem ser melhor aproveitados.

Primeiro protótipo

Depois de escolher as tecnologias subjacentes, decidimos criar um primeiro protótipo para avaliar aproximadamente quanto ganho de velocidade este projeto nos traria e se valeria a pena. Como primeira implementação para o motor de expressões regulares (a parte que efetivamente processa e aplica as regras), utilizámos a biblioteca padrão de expressões regulares (ou «crate», como são chamadas na terminologia do Rust). Este motor, tal como o sregex, é «non-backtracking», o que significa que apresenta um risco menor de ocorrer um problema de ReDoS. Num ReDoS, uma expressão regular é confrontada com uma entrada tal que o tempo necessário para avaliar a entrada aumenta exponencialmente. Isto pode ser desastroso para o desempenho e, no caso da Cloudflare, causou uma falha significativa. Por isso, para nós, é importante que o motor que utilizamos seja «non-backtracking».

Com este motor de expressões regulares, construímos um primeiro protótipo. Este protótipo utilizava regras codificadas de forma rígida e o motor de expressões regulares armazenava toda a resposta em buffer em vez de a transmitir em tempo real, mas isto foi suficiente para um primeiro teste de desempenho. 

Com este motor de expressões regulares, construímos um primeiro protótipo. Este protótipo utilizava regras codificadas de forma rígida e o motor de expressões regulares armazenava em buffer toda a resposta em vez de a transmitir em fluxo contínuo, mas isto foi suficiente para um primeiro teste de desempenho. A nossa configuração de teste consistia em duas máquinas virtuais (VM). Numa das VM, estava a funcionar uma configuração na qual tanto a solução antiga como a nova podiam ser executadas. Desta forma, pudemos facilmente fazer comparações entre ambos os métodos. No outro servidor, rodava o NGINX, que funcionava como servidor de origem e servia um ficheiro de teste. Nesse mesmo servidor também rodava a ferramenta wrk, com a qual gerámos a carga. Esta configuração não é totalmente ideal, uma vez que, para obter dados puros, o ideal seria rodar o gerador de carga numa VM separada, mas esta VM tinha recursos suficientes para que o NGINX e o wrk não se interferissem mutuamente.

Os primeiros resultados dos testes foram inequívocos: o Prexy era cerca de 22 vezes mais rápido no processamento de um único pedido (ver a imagem abaixo). Isto deu definitivamente luz verde ao projeto, uma vez que, com uma diferença tão grande, havia margem suficiente para absorver eventuais degradações de desempenho que pudessem ocorrer ao completar a funcionalidade.

Após o primeiro teste, implementámos algumas otimizações óbvias, como o armazenamento em cache de expressões regulares compiladas. Além disso, implementámos uma forma mais eficiente de reutilizar ligações ao servidor upstream. Com isso, obtivemos um ganho de velocidade de cerca de 20%. Testámos então ambas as soluções sob carga máxima, enviando o maior número possível de pedidos para o servidor. Também aqui a diferença foi bem visível e o Prexy atingiu um débito cerca de 25 vezes superior ao da solução antiga.

Motor de expressões regulares em streaming

Um dos requisitos deste projeto era que o motor de expressões regulares utilizado fosse de streaming. O motor de expressões regulares do protótipo não o era, pelo que foi necessário algum trabalho adicional nesta área. No entanto, o motor de expressões regulares revelou-se bastante otimizado, pelo que decidimos utilizar esta implementação como base para o nosso motor de streaming. Ao transmitir dados através de um motor de expressões regulares, há alguns aspetos importantes a ter em conta. Em primeiro lugar, não se sabe quais os dados que ainda estão por vir. Por isso, é necessário trabalhar com correspondências parciais: correspondências que ainda não estão completas, mas que já corresponderam a um ou mais caracteres. Ao encontrar uma correspondência completa, é necessário verificar se não existem correspondências parciais sobrepostas, uma vez que estas também podem acabar por corresponder (e, em caso de sobreposição, uma correspondência mais longa prevalece sobre uma mais curta). Assim, só é possível processar uma correspondência quando todas as correspondências parciais existentes nesse momento se revelarem não ser correspondências completas.

Outras otimizações

A reformulação do motor de expressões regulares teve, como esperado, um impacto negativo no desempenho do Prexy. No entanto, ainda havia margem suficiente em comparação com a solução anterior e, ao adicionar mais otimizações, acabámos por atingir um desempenho ainda superior ao que tínhamos antes da reformulação do motor de expressões regulares. Uma das otimizações que aplicámos foi memorizar se uma regra deve ser aplicada a um determinado ficheiro. Os recursos estáticos, como 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 seria armazenar em cache o resultado das substituições, mas a desvantagem desta estratégia é que ocupa muita memória para guardar todos os ficheiros. Ao memorizar quais as regras que devem ser aplicadas, precisamos apenas de alguns bytes por ficheiro, uma vez que a informação é armazenada como um bitmap.

Além disso, tiramos o máximo partido das otimizações no motor de expressões regulares, por exemplo, não mantendo grupos de captura quando estes não são utilizados na substituição. Desta forma, o motor de expressões regulares só precisa de memorizar o início e o fim de toda a correspondência, o que poupa trabalho. Também desativámos os grupos de captura nomeados, uma vez que isto era bastante complexo na variante de streaming e, como a solução antiga também não suportava essa funcionalidade, não era necessariamente necessário. Todas estas otimizações resultaram, em última análise, nos seguintes resultados de desempenho:

O resultado final

Após testarmos exaustivamente o Prexy para garantir que o comportamento era, de facto, idêntico ao da solução anterior, iniciámos a implementação gradual. Ao longo de cerca de uma semana, ativámos o Prexy para cada clone. No nosso sistema de monitorização, o momento da ativação era frequentemente bem visível. Não era raro observar uma redução de cerca de 50% nos tempos de resposta. Também se verificaram diferenças menos acentuadas, de cerca de 10%, noutros clientes que tinham poucas regras de substituição. O nosso próprio site ficou cerca de 30% mais rápido (~50 ms).

No que diz respeito à nossa infraestrutura global, 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 afirmar que o Prexy foi um projeto bem-sucedido. Ao substituir um módulo NGINX desatualizado, podemos agora utilizar técnicas mais recentes e eficientes que fazem uma diferença claramente mensurável para todos os nossos clientes. Também no futuro continuaremos a trabalhar no aperfeiçoamento do Clonable, tanto no que diz respeito a novas funcionalidades como ao aperfeiçoamento das funcionalidades existentes.


Obrigado por ler este blogue de engenharia. Diga-nos se gostaria de receber com mais frequência este tipo de vislumbres técnicos sobre o nosso produto. Ficou totalmente entusiasmado com este projeto? Então, dê uma vista de olhos na nossa página de ofertas de emprego :)

Comece hoje mesmo gratuitamente com o Clonable

Descubra como gerir sites internacionais de forma centralizada a partir de uma única plataforma.

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