Engineering Blog – Prexy: lynhurtige søge- og erstatningsregler

Velkommen til vores første tekniske blogindlæg. Det er måske lidt mere teknisk, end du er vant til fra de andre blogindlæg, men vi har gjort vores bedste for at gøre det forståeligt for alle. I denne artikel skal vi tale om Prexy, et nyt stykke software inden for Clonable , der bruges til at anvende substitutionsreglerne.

Baggrund

Som bruger af Clonable kender du måske allerede funktionen "substitution rule". Disse søge- og erstatningsregler kan du bruge til at udskifte tekststykker eller kode med en variant, du selv vælger. Dette kan du for eksempel bruge til at erstatte en API-nøgle eller et analytics-ID, så du kan foretage forskellige analyser for dit originale websted og dine oversatte kloner. Internt bruges disse samme regler også til at erstatte dit oprindelige domænenavn med klonens domænenavn samt til en række andre formål.

Siden den allerførste version af Clonable er denne funktionalitet aldrig blevet ændret, og det er i sig selv ikke mærkeligt, for den fungerede udmærket. Alligevel var der mulighed for forbedringer, både hvad angår ydeevne og brugervenlighed. En gang imellem tager vi fat på en del af vores produkt, som vi længe ikke har brugt tid på, for at forbedre den. Således gennemgik vi sidste år hele vores datainfrastruktur for at gøre den hurtigere, mere skalerbar og mere fejltolerant, og i den forbindelse genopbyggede vi også URL-oversættelsesfunktionen fra bunden. I dette kvartal var det substitutionsreglernes tur.

Tidligere situation og begrænsninger

I Clonable anvendes substitutionsregler først i den aller sidste fase, lige før svaret sendes tilbage til klienten. I den første version af Clonable valgte vi derfor at implementere dette i webserveren NGINX ved hjælp af replace-filter-nginx-modulet, et modul udviklet af OpenResty. Modulet bruger sregex som streaming-regex-motor, som også er udviklet af OpenResty. Streaming-aspektet er afgørende, da vi ikke ønsker at indlæse hvert svar fuldt ud i hukommelsen. Hvis vi gjorde det, kunne store svar få NGINX til at gå ned eller fryse, fordi der ikke er tilstrækkelig hukommelse til rådighed. Et andet centralt træk ved sregex er, at det kan behandle flere regler parallelt. Hver klon har nemlig allerede en række standardregler og derudover nogle gange en række regler, der er tilføjet specifikt til klonen. Hvis disse ikke kunne behandles samtidigt, ville vi stadig være nødt til at buffe hele responsen, fordi vi efter den første regel skal kunne gennemgå den endnu en gang for den anden regel.

I løbet af det sidste år stødte vi dog stadig oftere på mærkelige ydeevneproblemer. Disse problemer var ofte kortvarige, men kunne i ekstreme tilfælde forlænge en sides responstid med flere sekunder. Efter en sådan spidsbelastning blev siden ofte indlæst normalt igen, hvilket gjorde problemet svært at gengive. For at fejlsøge dette yderligere tilføjede vi Clonable-Timings-headeren til alle svar, hvilket gjorde det muligt for os groft at fastslå, hvilket trin i oversættelsesprocessen der tog så lang tid. Det viste sig, at upstream i næsten alle tilfælde reagerede hurtigt, og at oversættelsen også forløb ret hurtigt. Der var dog et mellemrum mellem afslutningen af oversættelsen og den fuldstændige afslutning af anmodningen, og dette gav os en antydning af, at det kunne have noget at gøre med substitutionsreglerne.

NGINX’s konkurrencemodel

Dette gav os dog stadig ikke svar på, hvorfor disse forsinkelser kun opstod sporadisk, og hvorfor det skete, selvom serverne ikke engang var belastet til halvdelen af deres kapacitet. For bedre at forstå dette fænomen må vi se lidt nærmere på, hvordan NGINX håndterer arbejdsbelastninger.

NGINX har en worker-arkitektur med én master-proces og flere worker-processer. Forbindelser fordeles mellem worker-processerne, så flere anmodninger kan behandles samtidigt. Denne opsætning har dog også en ulempe:Når en worker-proces er optaget af at behandle en anmodning, skal de andre anmodninger, der også er tildelt den samme worker-proces, vente. Dette kan medføre, at én ressourcekrævende anmodning kan forårsage forsinkelser for flere anmodninger, selvom de andre worker-processer ikke har noget at lave. Denne effekt ses tydeligt på nedenstående figur. Under udarbejdelsen af denne figur kører der en stresstest på 10 forskellige forbindelser. På figuren kan du tydeligt se, at tre worker-processer er travlt beskæftiget, mens en fjerde næsten ikke laver noget.

Tid til Prexy

Da flaskehalsen var tydelig, udarbejdede vi en plan for at forbedre dette. Dette projekt fik navnet Prexy, en sammensmeltning af RegEx og Proxy. Der var tre hovedkrav til det endelige resultat:

  1. Det skal være en drop-in-erstatning for din nuværende opsætning. Anvend erstatningsreglerne på samme måde for at undgå, at ting går i stykker i eksisterende opsætninger.

  2. Anvend substitutionsreglerne løbende for at undgå, at din løsning bruger for meget hukommelse.

  3. Den nye løsning skal være hurtigere end den nuværende.

Først led vi efter et kraftfuldt multitrådet runtime, der kunne behandle anmodninger effektivt. Efter at have overvejet flere muligheder faldt valget på Tokio-runtime til programmeringssproget Rust. Rust er kendt for, at du kan skrive hurtige programmer med det uden de sikkerhedsrisici, der er forbundet med andre lavniveau-sprog som C++. Tokio-runtime er et fleksibelt, asynkront runtime udviklet til netværksapplikationer. En af de vigtigste funktioner for os er, at det er »work-stealing«. Det betyder, at en anmodning – i modsætning til NGINX – ikke er bundet til én worker, men at en worker, der ikke har noget at lave, kan »stjæle« arbejde fra en anden worker. På den måde kan vores serveres ressourcer udnyttes bedre.

Første prototype

Efter at have valgt de underliggende teknologier besluttede vi at oprette en første prototype for at vurdere, hvor stor en hastighedsgevinst dette projekt omtrent ville give os, og om det overhovedet var umagen værd. Som første implementering af regex-motoren (den del, der rent faktisk behandler og anvender reglerne) brugte vi standard-regex-biblioteket (eller "crate", som det kaldes i Rust-terminologi). Denne motor er, ligesom sregex, non-backtracking, hvilket betyder, at der er mindre risiko for et ReDoS-problem. Ved en ReDoS bliver et regulært udtryk udsat for en sådan indtastning, at den tid, det tager at evaluere indtastningen, stiger eksponentielt. Dette kan være katastrofalt for ydeevnen og har hos Cloudflare ført til et alvorligt nedbrud. For os er det derfor afgørende, at den motor, vi bruger, er non-backtracking.

Med denne regex-motor byggede vi en første prototype. Denne prototype anvendte hardkodede regler, og regex-motoren buffede hele responsen i stedet for at streame den, men det var tilstrækkeligt til en første ydelsestest. 

Med denne regex-motor byggede vi en første prototype. Denne prototype anvendte hardkodede regler, og regex-motoren buffede hele responsen i stedet for at streame den, men det var tilstrækkeligt til en første ydelsestest. Vores testopsætning bestod af to virtuelle maskiner (VM'er). På den ene VM kørte et setup, hvor både den gamle og den nye løsning kunne køre. På den måde kunne vi nemt foretage sammenligninger mellem de to metoder. På den anden server kørte NGINX, der fungerede som origin-server og serverede en testfil. På samme server kørte også værktøjet wrk, som vi brugte til at generere belastningen. Dette er ikke helt optimalt, da man for at få rene data helst bør køre belastningsgeneratoren på en separat VM, men denne VM havde tilstrækkelige ressourcer til, at NGINX og wrk ikke var i vejen for hinanden.

De første testresultater var helt tydelige: Prexy var cirka 22 gange hurtigere til at behandle en enkelt anmodning (se billedet nedenfor). Dette gav projektet endeligt grønt lys, da der med en så stor forskel var rigelig plads til at absorbere eventuelle ydelsesforringelser, der måtte opstå i forbindelse med færdigudviklingen af funktionaliteten.

Efter den første test gennemførte vi en række oplagte optimeringer, såsom cachelagring af kompilerede regulære udtryk. Derudover implementerede vi en mere effektiv måde at genbruge forbindelser til upstream på. Dermed opnåede vi en hastighedsforbedring på omkring 20 %. Derefter testede vi også begge løsninger under spidsbelastning, hvor vi sendte så mange anmodninger som muligt til serveren. Også her var forskellen tydelig, og Prexy opnåede en gennemstrømning, der var cirka 25 gange så høj som den gamle løsning.

Streaming regex-motor

Et af kravene til dette projekt var, at den anvendte regex-motor skulle være streamingbaseret. Regex-koden fra prototypen var ikke det, så på dette område var der behov for ekstra arbejde. Regex-koden viste sig dog at være meget optimeret, så vi besluttede at bruge denne implementering som grundlag for vores streaming-motor.Når du streamer data gennem en regex-motor, er der en række ting, det er vigtigt at holde øje med. For det første ved du ikke, hvilke data der endnu skal komme. Du er derfor nødt til at arbejde med delvise matches: matches, der endnu ikke er komplette, men som allerede har matchet 1 eller flere tegn.Når du finder en komplet match, skal du derefter kontrollere, at der ikke er overlappende delvise matches, da disse i sidste ende også kan matche (og en længere match vinder over en kortere match i tilfælde af overlap). Du kan altså først begynde at behandle et match, når det viser sig, at alle de på det tidspunkt eksisterende delvise matches ikke udgør et fuldstændigt match.

Yderligere optimeringer

Omskrivningen af regex-motoren havde, som forventet, en negativ indvirkning på Prexys ydeevne. Der var dog stadig tilstrækkelig margin i forhold til den gamle løsning, og ved at tilføje yderligere optimeringer endte vi til sidst endda med at præstere bedre end før omskrivningen af regex-motoren. En af de optimeringer, vi anvendte, var at huske, om en regel skal anvendes på en bestemt fil. Statiske ressourcer som CSS- og JS-filer ændres næsten aldrig, så det er unødvendigt at udføre reglerne hver gang, når de alligevel ikke matcher filen. Et alternativ var at cache resultatet af udskiftningerne, men ulempen ved denne strategi er, at det kræver meget hukommelse at gemme alle filerne. Ved at huske, hvilke regler der skal anvendes, behøver vi kun et par bytes pr. fil, da informationen gemmes som et bitmap.

Derudover udnytter vi optimeringerne i regex-motoren optimalt ved f.eks. ikke at gemme capturing groups, når disse ikke bruges i erstatningen. Dermed behøver regex-motoren kun at huske begyndelsen og slutningen af hele matchet, hvilket sparer arbejde. Vi har også deaktiveret named capturing groups, da dette var ret komplekst i den streamende variant, og da den gamle løsning heller ikke understøttede det, var det heller ikke nødvendigvis påkrævet. Alle disse optimeringer resulterede i sidste ende i følgende ydeevne:

Det endelige resultat

Efter at have testet Prexy grundigt for at sikre, at funktionaliteten faktisk var den samme som den gamle løsning, begyndte vi den trinvise udrulning. Over en periode på cirka en uge aktiverede vi Prexy for hver enkelt klon. I vores overvågning kunne vi ofte tydeligt se tidspunktet for aktiveringen. Det var ikke ualmindeligt at se en reduktion på omkring 50 % i responstiderne. Der var også mindre forskelle på omkring 10 % hos andre kunder, der kun havde få substitutionsregler. Vores egen hjemmeside blev omkring 30 % hurtigere (~50 ms).

Hvad angår vores overordnede infrastruktur, ser vi følgende forskelle efter implementeringen af Prexy.

  • 33 % mindre RAM-forbrug

  • 20 % mindre CPU-forbrug

  • 10-50 % hurtigere svar

Alt i alt kan vi altså konkludere, at Prexy var et vellykket projekt. Ved at udskifte et forældet NGINX-modul kan vi nu benytte nyere og mere effektive teknikker, der gør en tydelig og målbar forskel for alle vores kunder. Også i fremtiden vil vi fortsætte med at forbedre Clonable, både hvad angår nye funktioner og forbedring af eksisterende funktionalitet.


Tak for at du læste denne tekniske blog. Lad os vide, hvis du gerne vil have flere af denne slags tekniske indblik i vores produkt. Er du blevet helt begejstret for dette projekt? Så kig også forbi vores jobside :)

Kom i gang gratis i dag med Clonable

Find ud af, hvordan du centralt administrerer internationale hjemmesider fra én platform.

Vi bruger cookies til at forbedre din oplevelse, analysere trafikken på hjemmesiden og forbedre vores tjenester.

Læs vores cookiepolitik