Engineering Blog - Prexy: supersnabba sök- och ersättningsregler
Välkommen till vår första teknikblogg. Den kan vara lite mer teknisk än du är van vid från de andra bloggarna, men vi har gjort vårt bästa för att göra den begriplig för alla. I den här artikeln kommer vi att prata om Prexy, en ny mjukvara inom Clonable som används för att tillämpa substitutionsregler.
Bakgrund
Som användare av Clonable kanske du redan känner till funktionen för ersättningsregler. Du kan använda dessa sök- och ersättningsregler för att byta ut textstycken eller kod mot en självvald variant. Du kan t.ex. använda detta för att ersätta en API-nyckel eller ett analys-ID så att du kan skapa olika analyser för din ursprungliga webbplats och dina översatta kloner. Internt används samma regler också för att ersätta ditt ursprungliga domännamn med din klons domännamn och ett antal andra saker.
Sedan den allra första versionen av Clonable har denna funktionalitet aldrig uppdaterats, vilket i sig inte är förvånande, eftersom den gjorde sitt jobb alldeles utmärkt. Ändå var förbättringar möjliga, både när det gäller prestanda och användarvänlighet. Då och då tar vi Clonable en del av vår produkt som inte har använts på länge för att börja förbättra den. Förra året såg vi till exempel över hela vår datainfrastruktur för att göra den snabbare, mer skalbar och mer feltolerant, och innan dess byggde vi också om URL-översättningsfunktionen från grunden. Det här kvartalet var det ersättningsreglernas tur.
Gammal situation och begränsningar
Substitutionsregler tillämpas i Clonable först i allra sista steget, precis innan svaret skickas tillbaka till klienten. I den första versionen av Clonable valde vi därför att implementera detta i webbservern NGINX med hjälp av replace-filter-nginx-module, en modul skapad av OpenResty. Modulen använder sregex som sin strömmande regexmotor, även den skapad av OpenResty. Streamingaspekten är viktig i det här fallet, eftersom vi inte vill ladda varje svar helt i minnet. Om vi gjorde det skulle ett antal stora svar kunna orsaka att NGINX kraschar eller kraschar eftersom det inte finns tillräckligt med minne tillgängligt. En annan viktig funktion hos sregex är att den kan bearbeta flera rader parallellt. Detta beror på att varje klon redan har vissa standardregler och dessutom ibland vissa regler som lagts till specifikt för klonen. Om dessa inte kunde hanteras parallellt skulle vi fortfarande behöva buffra hela svaret, eftersom vi skulle behöva kunna matcha det igen efter den första regeln för den andra regeln.
Förra året stötte vi dock allt oftare på märkliga prestandaproblem. Dessa problem var ofta kortvariga, men kunde i extrema fall öka svarstiden för en sida med flera sekunder. Efter en sådan topp laddades sidan ofta normalt igen, vilket gjorde problemet svårt att reproducera. För att felsöka detta ytterligare lade vi till rubriken Clonable-Timings i alla svar, vilket gjorde det möjligt för oss att grovt avgöra vilket steg i översättningsprocessen som tog så mycket tid. Detta visade att i nästan alla fall svarade uppströms snabbt, och översättningen var också ganska smidig. Det fanns dock ett glapp mellan att översättningen slutfördes och att begäran slutfördes helt, och detta gav oss en antydan om att det kunde ha att göra med substitutionsreglerna.
NGINX:s samtidighetsmodell
Detta gav oss dock ännu inte svaret på varför dessa fördröjningar bara uppstod sporadiskt och varför detta hände när servrarna inte ens var belastade till hälften av sin kapacitet. För att bättre förstå detta fenomen måste vi titta lite djupare på hur NGINX hanterar arbetsbelastningar.
NGINX har en worker-arkitektur med en masterprocess och flera worker-processer. Anslutningar distribueras mellan arbetarna för att hantera flera förfrågningar samtidigt. Men det här upplägget har också en nackdel: när en arbetare är upptagen med att behandla en begäran måste de andra begärandena som också tilldelats samma arbetare vänta. Detta kan leda till att en tung förfrågan orsakar förseningar för flera förfrågningar, även om de andra arbetarna inte har något att göra. Den här effekten är bra att se i bilden nedan. Under skapandet av den här bilden körs ett stresstest över 10 olika anslutningar. Bilden visar då tydligt att tre arbetare är upptagna och en fjärde gör nästan ingenting.

Tid för Prexy
Med flaskhalsen tydligt synlig skapade vi en plan för att förbättra den. Detta projekt fick namnet Prexy, en sammanslagning av RegEx och Proxy. Det fanns 3 huvudkrav för slutresultatet:
Det ska vara en "drop-in"-ersättning för den nuvarande installationen. Ersättningsreglerna bör tillämpas på samma sätt för att undvika att saker går sönder i befintliga konfigurationer.
Substitutionsregler måste streamas för att förhindra att lösningen använder för mycket minne.
Den nya lösningen ska vara snabbare än den nuvarande lösningen.
Först var vi tvungna att leta efter en kraftfull flertrådig runtime som kunde behandla förfrågningar effektivt. Efter att ha övervägt flera alternativ landade valet på Tokyo runtime för programmeringsspråket Rust. Rust är känt för att göra det möjligt att skriva snabba program utan de säkerhetsrisker som finns i andra lågnivåspråk som C++. Tokyo runtime är en flexibel asynkron runtime som är gjord för nätverksbaserade applikationer. En av de viktigaste funktionerna för oss är det faktum att den är arbetsstjälande. Det innebär att till skillnad från NGINX är en begäran inte bunden till en arbetare, utan en arbetare som inte har något att göra kan "stjäla" arbete från en annan arbetare. På så sätt kan resurserna i våra servrar utnyttjas bättre.
Första prototypen
Efter att ha valt de underliggande teknikerna bestämde vi oss för att sätta upp en första prototyp för att uppskatta ungefär hur mycket hastighetsökning det här projektet skulle ge oss och om det var värt det alls. Som en första implementering för regexmotorn (den del som faktiskt bearbetar och tillämpar reglerna) använde vi standardbiblioteket för regex (eller crate som de kallas i Rust-terminologi). Denna motor, liksom sregex, är icke-backspårande, vilket innebär mindre risk för ett ReDoS-problem. I en ReDoS möter ett reguljärt uttryck en sådan indata att den tid det tar att utvärdera indata ökar exponentiellt. Detta kan vara katastrofalt för prestandan och ledde till att Cloudflare drabbades av ett stort avbrott. Så för oss är det viktigt att den motor vi använder är icke-backtracking.
Med hjälp av denna regex-motor byggde vi en första prototyp. Denna prototyp använde hårdkodade regler och regexmotorn buffrade hela svaret istället för att strömma det, men det räckte för ett första prestandatest.
Med hjälp av denna regex-motor byggde vi en första prototyp. Denna prototyp använde hårdkodade regler och regexmotorn buffrade hela svaret istället för att strömma det, men det räckte för ett första prestandatest. Vår testuppsättning bestod av två virtuella datorer, där den ena körde en uppsättning som både den gamla och den nya lösningen kunde köras på. På så sätt kunde vi enkelt göra jämförelser mellan de två metoderna. Den andra servern körde NGINX, som fungerade som ursprungsserver och serverade en testfil. På samma server kördes också wrk-verktyget, som vi använde för att generera belastningen. Detta är inte helt optimalt, eftersom man för ren data hellre kör lastgeneratorn på en separat VM, men denna VM hade tillräckligt med resurser för att NGINX och wrk inte skulle störa varandra.
De första testresultaten var mycket tydliga: Prexy var cirka 22 gånger snabbare när det gällde att hantera en enda begäran (se bilden nedan). Detta gav projektet det slutgiltiga klartecknet, eftersom det med en så stor skillnad fanns tillräckligt med utrymme för att absorbera vissa prestandaförluster som kunde uppstå när funktionaliteten gjordes komplett.

Efter inledande tester implementerade vi några uppenbara optimeringar, som att cachelagra kompilerade reguljära uttryck. Vi implementerade också ett mer effektivt sätt att återanvända anslutningar till uppströms. Med detta uppnådde vi en hastighetsökning på cirka 20%. Vi testade sedan också båda lösningarna under toppbelastning genom att skicka så många förfrågningar till servern som möjligt. Även här syntes skillnaden tydligt och Prexy uppnådde en genomströmning som var cirka 25 gånger högre än den gamla lösningen.

Streaming regex-motor
Ett av kraven i detta projekt var att den regexmotor som användes skulle vara strömmande. Prototypens regex create är inte det, så det krävdes ytterligare arbete inom detta område. Regex crate visade sig dock vara mycket optimerad, så vi bestämde oss för att ta denna implementering som grund för vår streamingmotor. När man strömmar data genom en regex-motor finns det några saker som är viktiga att tänka på. För det första vet du inte vilka data som fortfarande kommer att komma. Du bör därför börja arbeta med partiella matchningar: matchningar som ännu inte är fullständiga, men som redan har matchat 1 eller flera tecken. När du hittar en fullständig matchning måste du sedan kontrollera att det inte finns några överlappande partiella matchningar, eftersom dessa också kan sluta som matchningar (och en längre matchning vinner över en kortare matchning vid överlappning). Du kan alltså bara börja bearbeta en matchning om alla aktuella partiella matchningar visar sig inte vara en fullständig matchning.
Ytterligare optimeringar
Som väntat hade ombyggnaden av regexmotorn en negativ inverkan på Prexys prestanda. Det fanns dock fortfarande gott om marginal jämfört med den gamla lösningen, och genom att lägga till ytterligare optimeringar hamnade vi till slut ännu högre igen än innan vi byggde om regexmotorn. En av de optimeringar vi använde var att komma ihåg om en regel skulle tillämpas på en viss fil. Statiska tillgångar som CSS- och JS-filer ändras nästan aldrig, och därför är det meningslöst att köra en regel varje gång när den aldrig matchar den specifika filen. Ett alternativ till detta var att cacha resultatet av ersättningarna, men nackdelen med denna strategi är att det tar mycket minne att lagra alla filer i. Genom att komma ihåg vilka regler som ska tillämpas behöver vi bara några få byte per fil genom att lagra informationen som en bitmapp.
Dessutom drar vi full nytta av optimeringar inom regexmotorn genom att till exempel inte behålla fångstgrupper när de inte används i ersättningen. På så sätt behöver regexmotorn bara komma ihåg början och slutet av hela matchningen, vilket sparar arbete. Vi inaktiverade också namngivna fångstgrupper, eftersom detta var ganska komplext i streamingvarianten, och eftersom den gamla lösningen inte heller stödde detta var det inte nödvändigtvis nödvändigt. Alla dessa optimeringar gav i slutändan följande prestanda:

Slutresultatet
Efter att ha testat Prexy i stor utsträckning för att säkerställa att dess beteende verkligen var detsamma som den gamla lösningen började vi rulla ut den stegvis. Under en period på ungefär en vecka aktiverade vi Prexy för varje klon. I vår övervakning var tidpunkten för aktiveringen ofta lätt att se. En minskning av svarstiderna med cirka 50 % var inte ovanlig att se. Mindre skillnader på cirka 10% sågs också hos andra kunder som hade mycket få substitutionsregler. Vår egen webbplats blev cirka 30% snabbare (~50 ms).
Över hela vår infrastruktur ser vi följande skillnader efter att ha rullat ut Prexy.
33% mindre RAM-förbrukning
20% mindre CPU-förbrukning
10-50 % snabbare svar
Sammantaget kan vi alltså säga att Prexy var ett lyckat projekt. Genom att byta ut en föråldrad NGINX-modul kan vi nu använda nyare och effektivare tekniker som gör en tydlig mätbar skillnad för alla våra kunder. I framtiden kommer vi att fortsätta att förbättra Clonable, både vad gäller nya funktioner och förbättring av befintlig funktionalitet.
Tack för att du läste den här teknikbloggen. Låt oss veta om du vill få den här typen av tekniska insikter i vår produkt oftare. Blev du upphetsad av det här projektet? Ta då en titt på vår sida med lediga tjänster :)
Kom igång gratis med Clonable redan idag
Upptäck hur du centralt hanterar internationella webbplatser från en enda plattform.