Blog de Engenharia - Prexy: regras de busca e substituição superrápidas
Bem-vindo ao nosso primeiro blog de engenharia. Talvez seja um pouco mais técnico do que você está acostumado nos outros blog, mas fizemos o possível para que fique compreensível para todo mundo. Neste artigo, vamos falar sobre o Prexy, um novo software dentro do Clonable que é usado para aplicar as regras de substituição.
Contexto
Als gebruiker van Clonable ken je de substitution rule functionaliteit misschien al. Deze zoek- en vervangregels kun je gebruiken om stukken tekst of code uit te wisselen met een zelfgekozen variant. Dit kun je bijvoorbeeld gebruiken om een API key te vervangen of een analytics ID zodat je verschillende analyses kunt maken voor je originele site en je vertaalde clones. Intern worden deze zelfde regels ook gebruikt voor het vervangen van je originele domeinnaam naar de domeinnaam van je clone en een aantal andere zaken.
Desde a primeira versão do Clonable, essa funcionalidade nunca foi alterada, o que, por si só, não é estranho, já que ela funcionava muito bem. Mesmo assim, havia espaço para melhorias, tanto em termos de desempenho quanto de facilidade de uso. De vez em quando, pegamos uma parte do Clonable que já fazia tempo que não recebia atenção para melhorá-la. Por exemplo, no ano passado, revisamos toda a nossa infraestrutura de dados para torná-la mais rápida, escalável e tolerante a erros, e, para isso, já reconstruímos 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, logo antes de enviar a resposta de volta para o cliente. Por isso, na primeira versão do Clonable, optamos por implementar isso no servidor web NGINX usando o módulo replace-filter-nginx, criado pela OpenResty. O módulo usa o sregex como mecanismo de expressões regulares em streaming, também criado pela OpenResty. O aspecto de streaming é importante nesse caso, porque não queremos carregar cada resposta inteiramente na memória. Se fizéssemos isso, algumas respostas grandes poderiam fazer com que o NGINX travasse ou parasse de funcionar por falta de memória disponível. Uma segunda característica importante do sregex é que ele consegue processar várias regras em paralelo. Cada clone já tem algumas regras padrão e, além disso, às vezes algumas regras adicionadas especificamente para ele. Se elas não pudessem ser processadas ao mesmo tempo, a gente ainda teria que armazenar toda a resposta no buffer, porque, depois da primeira regra, precisaríamos percorrê-la de novo para a segunda regra.
No ano passado, porém, começamos a encontrar cada vez mais problemas estranhos de desempenho. Esses problemas costumavam ser de curta duração, mas, em casos extremos, podiam aumentar o tempo de resposta de uma página em alguns segundos. Depois de um desses picos, a página geralmente voltava a carregar normalmente, o que tornava o problema difícil de reproduzir. Para investigar melhor isso, adicionamos o cabeçalho Clonable-Timings a todas as respostas, o que nos permitiu identificar, de forma geral, qual etapa do processo de tradução estava demorando tanto. Isso mostrou que, em quase todos os casos, o servidor upstream respondia rápido e que a tradução também ocorria sem problemas. No entanto, havia um intervalo entre a conclusão da tradução e a conclusão total da solicitação, o que nos deu uma pista de que isso poderia estar relacionado às regras de substituição.
O modelo de concorrência do NGINX
No entanto, isso ainda não nos deu uma resposta para a pergunta de por que esses atrasos ocorriam apenas esporadicamente e por que isso acontecia enquanto os servidores nem sequer estavam sendo utilizados em metade de sua capacidade. Para entender melhor esse fenômeno, precisamos analisar um pouco mais a fundo como o NGINX lida com as cargas de trabalho.
O NGINX tem uma arquitetura de workers com um processo mestre e vários processos workers. As conexões são distribuídas entre os workers para que seja possível processar várias solicitações ao mesmo tempo. No entanto, essa configuração também tem uma desvantagem: quando um worker está ocupado processando uma solicitação, as outras solicitações atribuídas a esse mesmo worker precisam esperar. Isso pode fazer com que uma única solicitação pesada cause atrasos em várias outras, mesmo que os outros workers estejam ociosos. Esse efeito fica bem claro na imagem abaixo. Enquanto essa imagem era criada, estava rolando um teste de carga em 10 conexões diferentes. Na imagem, dá pra ver claramente que três workers estão ocupados, enquanto um quarto está praticamente sem fazer nada.

Hora do Prexy
Com o gargalo bem visível, fizemos um plano para melhorar isso. Esse projeto ganhou o nome de Prexy, uma combinação de RegEx e Proxy. Havia três requisitos principais para o resultado final:
Ele deve ser um substituto direto para a configuração atual. As regras de substituição precisam ser aplicadas da mesma forma para evitar que algo dê errado nas configurações existentes.
As regras de substituição devem ser aplicadas de forma contínua para evitar que a solução use muita memória.
A nova solução precisa ser mais rápida do que a atual.
Primeiro, precisávamos encontrar um runtime multithreaded potente, capaz de processar solicitações de forma eficiente. Depois de avaliar 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 Tokio Runtime é um runtime assíncrono flexível, feito para aplicativos de rede. Uma das principais características para a gente é o fato de ele ser “work-stealing”. Isso significa que, ao contrário do NGINX, uma solicitação não fica presa a um único worker, mas um worker que não tem nada para fazer pode “roubar” trabalho de outro worker. Assim, os recursos dos nossos servidores são melhor aproveitados.
Primeiro protótipo
Depois de escolher as tecnologias subjacentes, decidimos montar um primeiro protótipo para estimar quanto ganho de velocidade esse projeto nos traria, mais ou menos, e se valeria a pena. Como primeira implementação para o mecanismo de regex (a parte que realmente processa e aplica as regras), usamos a biblioteca padrão de regex (ou “crate”, como são chamadas na terminologia do Rust). Esse mecanismo, assim como o sregex, é do tipo “non-backtracking”, o que significa que há menos risco de ocorrer um problema de ReDoS. Em um ReDoS, uma expressão regular recebe uma entrada tal que o tempo necessário para avaliá-la aumenta exponencialmente. Isso pode ser desastroso para o desempenho e já causou uma grande falha na Cloudflare. Por isso, pra gente, é importante que o mecanismo que usamos seja “non-backtracking”.
Com esse mecanismo de expressões regulares, criamos um primeiro protótipo. Esse protótipo usava regras codificadas e o mecanismo de expressões regulares armazenava toda a resposta em buffer em vez de transmiti-la em tempo real, mas isso foi o suficiente para um primeiro teste de desempenho.
Com esse mecanismo de expressões regulares, criamos um primeiro protótipo. Esse protótipo usava regras codificadas e o mecanismo de expressões regulares armazenava toda a resposta em buffer em vez de transmiti-la em tempo real, mas isso foi o suficiente para um primeiro teste de desempenho. Nossa configuração de teste consistia em duas VMs. Em uma delas, rodava uma configuração na qual tanto a solução antiga quanto a nova podiam funcionar. Assim, pudemos fazer comparações fáceis entre os dois métodos. No outro servidor, rodava o NGINX, que funcionava como servidor de origem e servia um arquivo de teste. Nesse mesmo servidor também rodava a ferramenta wrk, com a qual geramos a carga. Isso não é totalmente ideal, já que, para obter dados puros, o ideal é rodar o gerador de carga em uma VM separada, mas essa VM tinha recursos suficientes para que o NGINX e o wrk não atrapalhassem um ao outro.
Os primeiros resultados dos testes foram bem claros: o Prexy era cerca de 22 vezes mais rápido no processamento de uma única solicitação (veja a imagem abaixo). Isso deu o sinal verde definitivo para o projeto, já que, com uma diferença tão grande, havia margem suficiente para absorver eventuais quedas de desempenho que pudessem ocorrer ao aperfeiçoar a funcionalidade.

Depois do primeiro teste, fizemos algumas otimizações óbvias, como armazenar em cache expressões regulares compiladas. Além disso, implementamos uma maneira mais eficiente de reutilizar conexões com o servidor upstream. Com isso, conseguimos um ganho de velocidade de cerca de 20%. Depois, testamos as duas soluções sob carga máxima, enviando o máximo possível de solicitações para o servidor. Aí também a diferença ficou bem visível, e o Prexy atingiu uma taxa de transferência cerca de 25 vezes maior do que a solução antiga.

Mecanismo de expressões regulares em streaming
Um dos requisitos desse projeto era que o mecanismo de regex usado fosse de streaming. O regex do protótipo não era assim, então, nesse ponto, ainda foi preciso um trabalho extra. No entanto, o regex acabou se mostrando bem otimizado, então decidimos usar essa implementação como base para o nosso mecanismo de streaming. Quando você processa dados por um mecanismo de regex, há alguns pontos importantes para ficar de olho. Primeiro, você não sabe quais dados ainda vão chegar. Por isso, é preciso trabalhar com correspondências parciais: correspondências que ainda não estão completas, mas já têm um ou mais caracteres correspondentes. Ao encontrar uma correspondência completa, é preciso verificar se não há correspondências parciais sobrepostas, porque elas também podem ser válidas (e uma correspondência mais longa prevalece sobre uma mais curta em caso de sobreposição). Portanto, só é possível começar a processar uma correspondência quando todas as correspondências parciais do momento não forem, de fato, correspondências completas.
Outras otimizações
A reformulação do mecanismo de expressões regulares (regex) teve, como esperado, um impacto negativo no desempenho do Prexy. No entanto, ainda havia margem suficiente em comparação com a solução antiga e, ao adicionarmos mais otimizações, acabamos até mesmo superando o desempenho que tínhamos antes da reformulação do mecanismo de regex. Uma das otimizações que aplicamos foi lembrar se uma regra deve ser aplicada a um determinado arquivo. Recursos estáticos, como arquivos CSS e JS, quase nunca mudam, e, por isso, não faz sentido executar uma regra toda vez, já que ela nunca vai corresponder a esse arquivo específico. Uma alternativa seria armazenar em cache o resultado das substituições, mas a desvantagem dessa estratégia é que ocupa muita memória guardar todos os arquivos. Ao lembrar quais regras devem ser aplicadas, precisamos de apenas alguns bytes por arquivo, já que armazenamos a informação como um bitmap.
Além disso, aproveitamos ao máximo as otimizações do mecanismo de regex, por exemplo, não mantendo grupos de captura quando eles não são usados na substituição. Assim, o mecanismo de regex só precisa lembrar o início e o fim da correspondência inteira, o que economiza trabalho. Também desativamos os grupos de captura nomeados, porque isso era bem complexo na versão em streaming e, como a solução antiga também não suportava isso, não era necessariamente necessário. Todas essas otimizações acabaram resultando nos seguintes resultados de desempenho:

O resultado final
Depois de testarmos exaustivamente o Prexy para garantir que o comportamento fosse realmente o mesmo da solução antiga, começamos a implementá-lo gradualmente. Ao longo de cerca de uma semana, ativamos o Prexy para cada clone . No nosso monitoramento, o momento da ativação costumava ficar bem visível. Não era raro ver uma redução de cerca de 50% nos tempos de resposta. Também observamos diferenças menores, de cerca de 10%, em outros clientes que tinham poucas regras de substituição. Nosso próprio site ficou cerca de 30% mais rápido (~50 ms).
Em relação à nossa infraestrutura geral, observamos as seguintes diferenças após a implantação do Prexy.
33% menos consumo de RAM
20% a menos de consumo de CPU
Respostas 10 a 50 % mais rápidas
Resumindo, podemos dizer que o Prexy foi um projeto de sucesso. Ao substituir um módulo NGINX desatualizado, agora podemos usar técnicas mais recentes e eficientes que fazem uma diferença claramente mensurável para todos os nossos clientes. No futuro, continuaremos aprimorando o Clonable, tanto com novos recursos quanto melhorando as funcionalidades já existentes.
Obrigado por ler este blog de engenharia. Mande um recado se você curtir receber mais vezes esse tipo de visão técnica sobre o nosso produto. Ficou animado com esse projeto? Então dê uma olhada na nossa página de vagas :)
Comece hoje mesmo de graça com o Clonable
Descubra como gerenciar sites internacionais de forma centralizada a partir de uma única plataforma.