Engineering Blog - Prexy: superhurtig søgning og erstatningsregler
Velkommen til vores første tekniske blog. Den er måske lidt mere teknisk, end du er vant til fra de andre blogs, men vi har gjort vores bedste for at gøre den forståelig for alle. I denne artikel vil vi tale om Prexy, et nyt stykke software i Clonable, der bruges til at anvende substitutionsregler.
Baggrund
Som bruger af Clonable er du måske allerede bekendt med substitutionsregel-funktionaliteten. Du kan bruge disse søge- og erstatningsregler til at udskifte tekststykker eller kode med en selvvalgt variant. Du kan f.eks. bruge dette til at erstatte en API-nøgle eller et analyse-id, så du kan oprette forskellige analyser for dit oprindelige site og dine oversatte kloner. Internt bruges de samme regler også til at udskifte dit oprindelige domænenavn med din klons domænenavn og en række andre ting.
Siden den allerførste version af Clonable er denne funktionalitet aldrig blevet opdateret, hvilket i sig selv ikke er overraskende, da den gjorde sit arbejde helt fint. Alligevel var der mulighed for forbedringer, både hvad angår ydeevne og brugervenlighed. En gang imellem tager vi i Clonable en del af vores produkt, som ikke har været brugt i lang tid, og begynder at forbedre det. Sidste år reviderede vi f.eks. hele vores datainfrastruktur for at gøre den hurtigere, mere skalerbar og mere fejltolerant, og før det genopbyggede vi også URL-oversættelsesfunktionen helt fra bunden. I dette kvartal var det substitutionsreglernes tur.
Gammel situation og begrænsninger
Substitutionsregler anvendes i Clonable først i den allersidste fase, lige før svaret sendes tilbage til klienten. Den første version af Clonable valgte derfor at implementere dette i webserveren NGINX ved hjælp af replace-filter-nginx-module, et modul skabt af OpenResty. Modulet bruger sregex som sin streaming regex-motor, også skabt af OpenResty. Streaming-aspektet er vigtigt i dette tilfælde, fordi vi ikke ønsker at indlæse alle svar fuldt ud i hukommelsen. Hvis vi gjorde det, kunne en række store svar få NGINX til at gå ned, fordi der ikke er tilstrækkelig hukommelse til rådighed. En anden vigtig funktion ved sregex er, at den kan behandle flere linjer parallelt. Det skyldes, at hver klon allerede har nogle standardregler og derudover nogle gange nogle regler, der er tilføjet specifikt til klonen. Hvis disse ikke kunne håndteres parallelt, ville vi stadig være nødt til at gemme hele svaret, fordi vi skulle være i stand til at matche det igen efter den første regel for den anden regel.
Sidste år oplevede vi dog i stigende grad mærkelige problemer med ydeevnen. Disse problemer var ofte kortvarige, men kunne i ekstreme tilfælde øge svartiden på en side med flere sekunder. Efter en sådan stigning blev siden ofte indlæst normalt igen, hvilket gjorde problemet vanskeligt at reproducere. For at fejlfinde yderligere tilføjede vi headeren Clonable-Timings til alle svar, hvilket gjorde det muligt for os nogenlunde at bestemme, hvilket trin i oversættelsesprocessen der tog så lang tid. Det viste sig, at upstream i næsten alle tilfælde svarede hurtigt, og at oversættelsen også gik nogenlunde glat. Der var dog et hul mellem færdiggørelsen af oversættelsen og den fuldstændige færdiggørelse af anmodningen, og det gav os et hint om, at det kunne have noget at gøre med substitutionsreglerne.
NGINX's model for samtidighed
Men det gav os endnu ikke svar på, hvorfor disse forsinkelser kun opstod sporadisk, og hvorfor det skete, når serverne ikke engang var belastet til halvdelen af deres kapacitet. For bedre at forstå dette fænomen er vi nødt til at se lidt dybere på, hvordan NGINX håndterer arbejdsbelastninger.
NGINX har en worker-arkitektur med en masterproces og flere worker-processer. Forbindelser fordeles mellem arbejderne for at håndtere flere anmodninger samtidigt. Denne opsætning har dog også en ulempe: Når en worker har travlt med at behandle en anmodning, må de andre anmodninger, der også er tildelt den samme worker, vente. Det kan føre til, at en tung anmodning forårsager forsinkelser for flere anmodninger, selv om de andre arbejdere ikke har noget at lave. Denne effekt er god at se på billedet nedenfor. Under oprettelsen af dette billede kører en stresstest på tværs af 10 forskellige forbindelser. Billedet viser tydeligt, at tre arbejdere har travlt, og en fjerde laver næsten ingenting.

Tid til Prexy
Da flaskehalsen var klart synlig, lavede vi en plan for at forbedre den. Projektet fik navnet Prexy, en sammensmeltning af RegEx og Proxy. Der var 3 hovedkrav til slutresultatet:
Det skal være en drop-in-erstatning for den nuværende opsætning. Erstatningsreglerne skal anvendes på samme måde for at undgå at ødelægge ting i eksisterende opsætninger.
Substitutionsregler skal streames for at forhindre, at løsningen bruger for meget hukommelse.
Den nye løsning skal være hurtigere end den nuværende.
Først skulle vi finde en stærk multi-threaded runtime, der kunne behandle forespørgsler effektivt. Efter at have overvejet flere muligheder faldt valget på Tokyo runtime til programmeringssproget Rust. Rust er kendt for at gøre det muligt at skrive hurtige programmer uden de sikkerhedsrisici, der er forbundet med andre lavniveausprog som C++. Tokyo runtime er en fleksibel asynkron runtime, der er lavet til netværksbaserede applikationer. En af de vigtigste funktioner for os er, at den stjæler arbejde. Det betyder, at i modsætning til NGINX er en anmodning ikke bundet til en arbejder, men en arbejder, der ikke har noget at lave, kan "stjæle" arbejde fra en anden arbejder. På den måde kan ressourcerne på vores servere udnyttes bedre.
Første prototype
Efter at have valgt de underliggende teknologier besluttede vi at lave en første prototype for at vurdere, hvor stor en hastighedsforøgelse projektet ville give os, og om det overhovedet var det værd. Som en første implementering af regex-motoren (den del, der rent faktisk behandler og anvender reglerne) brugte vi standard regex-biblioteket (eller crate, som de kaldes i Rust-terminologi). Denne motor er ligesom sregex ikke-backtracking, hvilket betyder mindre risiko for et ReDoS-problem. I et ReDoS står et regulært udtryk over for et sådant input, at den tid, det tager at evaluere inputtet, stiger eksponentielt. Det kan være katastrofalt for ydeevnen og var skyld i, at cloudflare blev ramt af et stort nedbrud. Så for os er det vigtigt, at den motor, vi bruger, er non-backtracking.
Ved hjælp af denne regex-motor byggede vi en første prototype. Denne prototype brugte hårdt kodede regler, og regex-motoren lagde hele svaret i buffer i stedet for at streame det, men det var nok til en indledende performancetest.
Ved hjælp af denne regex-motor byggede vi en første prototype. Denne prototype brugte hårdt kodede regler, og regex-motoren bufferede hele svaret i stedet for at streame det, men det var nok til en indledende performancetest. Vores testopsætning bestod af to VM'er, hvor den ene VM kørte en opsætning, som både den gamle og den nye løsning kunne køre på. På den måde kunne vi nemt lave sammenligninger mellem de to metoder. Den anden server kørte NGINX, som fungerede som origin-server og serverede en testfil. Den samme server kørte også wrk-værktøjet, som vi brugte til at generere belastningen. Det er ikke helt optimalt, da man til rene data foretrækker at køre belastningsgeneratoren på en separat VM, men denne VM havde ressourcer nok til, at NGINX og wrk ikke forstyrrede hinanden.
De første testresultater var helt klare: Prexy var ca. 22 gange hurtigere til at håndtere en enkelt anmodning (se billedet nedenfor). Det gav projektet det endelige grønne lys, for med så stor en forskel var der plads nok til at absorbere nogle ydelsesforringelser, der kunne opstå, når funktionaliteten blev gjort komplet.

Efter de første tests implementerede vi nogle åbenlyse optimeringer, såsom caching af kompilerede regulære udtryk. Vi implementerede også en mere effektiv måde at genbruge forbindelser til upstream. Med dette opnåede vi en hastighedsforøgelse på omkring 20 %. Vi testede også begge løsninger under spidsbelastning ved at sende så mange anmodninger til serveren som muligt. Også her var forskellen tydelig, og Prexy opnåede en gennemstrømning, der var ca. 25 gange højere end den gamle løsning.

Streaming regex-motor
Et af kravene i dette projekt var, at den anvendte regex-motor skulle være streaming. Det er prototypens regex create ikke, så der var brug for yderligere arbejde på dette område. Men regex crate viste sig at være meget optimeret, så vi besluttede at tage denne implementering som grundlag for vores streaming-motor. Når man streamer data gennem en regex-motor, er der et par ting, der er vigtige at huske på. For det første ved du ikke, hvilke data der stadig kommer. Du skal derfor begynde at arbejde med delvise matches: matches, der endnu ikke er komplette, men som allerede har matchet 1 eller flere tegn. Når du finder et komplet match, skal du kontrollere, at der ikke er nogen overlappende delvise matches, fordi disse også kan ende med at være matches (og et længere match vinder over et kortere match i tilfælde af overlapning). Så du kan kun begynde at behandle et match, hvis alle nuværende delvise matches viser sig ikke at være et komplet match.
Yderligere optimeringer
Som forventet havde ombygningen af regex-motoren en negativ indvirkning på Prexys ydeevne. Der var dog stadig masser af margin i forhold til den gamle løsning, og ved at tilføje yderligere optimeringer endte vi endnu højere igen end før genopbygningen af regex-motoren. En af de optimeringer, vi anvendte, var at huske, om en regel skulle anvendes på en bestemt fil. Statiske aktiver som CSS- og JS-filer ændrer sig næsten aldrig, og derfor er det nytteløst at udføre en regel hver gang, når den aldrig matcher på den specifikke fil. Et alternativ til dette var at cache resultatet af udskiftningerne, men ulempen ved denne strategi er, at det kræver en masse hukommelse at gemme alle filerne. Ved at huske, hvilke regler der skal anvendes, har vi kun brug for nogle få bytes pr. fil ved at gemme oplysningerne som en bitmap.
Derudover udnytter vi optimeringer i regex-motoren fuldt ud ved f.eks. ikke at beholde indfangningsgrupper, når de ikke bruges i erstatningen. Det betyder, at regex-motoren kun behøver at huske starten og slutningen af hele matchet, hvilket sparer arbejde. Vi deaktiverede også navngivne indfangningsgrupper, da dette var ret komplekst i streaming-varianten, og da den gamle løsning heller ikke understøttede dette, var det ikke nødvendigvis nødvendigt. Alle disse optimeringer gav i sidste ende følgende ydelse:

Det endelige resultat
Efter at have testet Prexy grundigt for at sikre, at den faktisk opførte sig på samme måde som den gamle løsning, begyndte vi at rulle den ud trinvist. Over en periode på omkring en uge aktiverede vi Prexy for hver klon. I vores overvågning var timingen af aktiveringen ofte let at se. Et fald på ca. 50 % i svartider var ikke sjældent at se. Mindre forskelle på omkring 10 % blev også set hos andre kunder, som havde meget få substitutionsregler. Vores egen hjemmeside blev ca. 30 % hurtigere (~50 ms).
På tværs af vores samlede infrastruktur ser vi følgende forskelle efter udrulningen af Prexy.
33% mindre RAM-forbrug
20% mindre CPU-forbrug
10-50 % hurtigere svar
Alt i alt kan vi altså sige, at Prexy var et vellykket projekt. Ved at udskifte et forældet NGINX-modul kan vi nu bruge nyere og mere effektive teknikker, som gør en klar målbar forskel for alle vores kunder. I fremtiden vil vi fortsætte med at forbedre Clonable, både med hensyn til nye funktioner og forbedring af eksisterende funktionalitet.
Tak, fordi du læste denne tekniske blog. Lad os vide, hvis du gerne vil have den slags tekniske indblik i vores produkt oftere. Blev du begejstret for dette projekt? Så tag et kig på vores side med ledige stillinger :)