Teknisk blogg – Prexy: lynraske regler for søk og erstatning

Velkommen til vår første ingeniørblogg. Den er kanskje litt mer teknisk enn du er vant til fra de andre bloggene, men vi har gjort vårt beste for å gjøre den forståelig for alle. I denne artikkelen skal vi snakke om Prexy, et nytt programvareverktøy i Clonable som brukes til å anvende substitusjonsreglene.

Bakgrunn

Som bruker av Clonable kjenner du kanskje allerede til funksjonen «substitution rule». Disse søke- og erstatningsreglene kan du bruke til å bytte ut tekst eller kode med en variant du selv velger. Dette kan du for eksempel bruke til å erstatte en API-nøkkel eller en analyse-ID, slik at du kan utføre ulike analyser for det opprinnelige nettstedet ditt og de oversatte klonene dine. Internt brukes disse samme reglene også til å erstatte det opprinnelige domenenavnet med domenenavnet til klonen din, samt til en rekke andre formål.

Siden den aller første versjonen av Clonable har denne funksjonaliteten aldri blitt endret, og det er i seg selv ikke rart, for den fungerte utmerket. Likevel var det mulig å gjøre forbedringer, både når det gjelder ytelse og brukervennlighet. Innimellom tar vi for oss en del av Clonable -produktet som vi lenge ikke har brukt tid på, for å forbedre den. I fjor gjennomgikk vi for eksempel hele datainfrastrukturen vår for å gjøre den raskere, mer skalerbar og mer feiltolerant, og i den forbindelse bygde vi også URL-oversettelsesfunksjonen helt på nytt fra grunnen av. Dette kvartalet var det substitusjonsreglenes tur.

Tidligere situasjon og begrensninger

I Clonable brukes substitusjonsregler først i den aller siste fasen, rett før svaret sendes tilbake til klienten. I den første versjonen av Clonable valgte vi derfor å implementere dette i webserveren NGINX ved hjelp av replace-filter-nginx-modulen, en modul utviklet av OpenResty. Modulen bruker sregex som streaming-regex-motor, også utviklet av OpenResty. Streaming-aspektet er viktig i dette tilfellet, fordi vi ikke ønsker å laste hvert eneste svar fullstendig inn i minnet. Hvis vi gjorde det, kunne noen store svar føre til at NGINX krasjer eller henger seg opp på grunn av utilstrekkelig minne. Et annet viktig trekk ved sregex er at den kan behandle flere regler parallelt. Hver klon har nemlig allerede en rekke standardregler, og i tillegg noen ganger en rekke regler som er lagt til spesielt for den aktuelle klonen. Hvis disse ikke kunne behandles samtidig, ville vi likevel måtte bufre hele responsen, fordi vi etter den første regelen må kunne gå gjennom den en gang til for den andre regelen.

I løpet av det siste året opplevde vi imidlertid stadig oftere merkelige ytelsesproblemer. Disse problemene var ofte kortvarige, men kunne i ekstreme tilfeller forlenge responstiden til en side med flere sekunder. Etter en slik topp lastet siden ofte inn som normalt igjen, noe som gjorde det vanskelig å gjenskape problemet. For å feilsøke dette nærmere la vi til Clonable-Timings-headeren i alle svarene, noe som gjorde det mulig for oss å grovt sett fastslå hvilket trinn i oversettelsesprosessen som tok så mye tid. Det viste seg at oppstrømsreaksjonen i nesten alle tilfeller var rask, og at oversettelsen også gikk ganske greit. Det var imidlertid et gap mellom fullføringen av oversettelsen og fullstendig fullføring av forespørselen, og dette ga oss en pekepinn på at det kunne ha noe med substitusjonsreglene å gjøre.

NGINX’ konkurransemodell

Dette ga oss imidlertid fortsatt ikke noe svar på hvorfor disse forsinkelsene bare oppstod sporadisk, og hvorfor dette skjedde mens serverne ikke engang var belastet til halvparten av kapasiteten. For å forstå dette fenomenet bedre, må vi se litt nærmere på hvordan NGINX håndterer arbeidsbelastninger.

NGINX har en worker-arkitektur med én masterprosess og flere worker-prosesser. Tilkoblingene fordeles på arbeiderne for å kunne behandle flere forespørsler samtidig. Denne oppsettet har imidlertid også en ulempe: når en arbeider er opptatt med å behandle en forespørsel, må de andre forespørslene som også er tildelt den samme arbeideren, vente. Dette kan føre til at én tung forespørsel kan forårsake forsinkelser for flere forespørsler, selv om de andre arbeiderne ikke har noe å gjøre. Denne effekten fremgår tydelig av figuren nedenfor. Mens denne figuren ble laget, pågikk det en stresstest på 10 forskjellige tilkoblinger. I figuren ser man tydelig at tre arbeiderprosesser er travle, mens en fjerde nesten ikke gjør noe.

Tid for Prexy

Med flaskehalsen tydelig synlig, laget vi en plan for å forbedre dette. Dette prosjektet fikk navnet Prexy, en sammensetning av RegEx og Proxy. Det var tre hovedkrav til sluttresultatet:

  1. Det må være en direkte erstatning for den nåværende konfigurasjonen. Erstatningsreglene må brukes på samme måte for å unngå at ting går i stykker i eksisterende konfigurasjoner.

  2. Erstatningsreglene må brukes i sanntid for å unngå at løsningen bruker for mye minne.

  3. Den nye løsningen må være raskere enn den nåværende løsningen.

Først måtte vi finne et kraftig multitrådet runtime som kan behandle forespørsler på en effektiv måte. Etter å ha vurdert flere alternativer falt valget på Tokio-runtime for programmeringsspråket Rust. Rust er kjent for at man kan skrive raske programmer med det, uten sikkerhetsrisikoene som finnes i andre lavnivåspråk som C++. Tokio-runtime er et fleksibelt, asynkront runtime laget for nettverksapplikasjoner. En av de viktigste funksjonene for oss er at det er «work-stealing». Dette innebærer at, i motsetning til NGINX, er en forespørsel ikke bundet til én worker, men at en worker som ikke har noe å gjøre, kan «stjele» arbeid fra en annen worker. På den måten kan ressursene på serverne våre utnyttes bedre.

Første prototype

Etter å ha valgt de underliggende teknologiene, bestemte vi oss for å sette opp en første prototype for å anslå hvor mye hastighetsgevinst dette prosjektet omtrent ville gi oss, og om det i det hele tatt var verdt innsatsen. Som første implementering for regex-motoren (den delen som faktisk behandler og anvender reglene), brukte vi standard regex-biblioteket (eller «crate», som det kalles i Rust-terminologi). Denne motoren er, akkurat som sregex, «non-backtracking», noe som betyr at risikoen for et ReDoS-problem er mindre. Ved en ReDoS blir et regulært uttrykk utsatt for en slik inndata at tiden det tar å evaluere inndataen øker eksponentielt. Dette kan være katastrofalt for ytelsen, og har ført til at Cloudflare opplevde en alvorlig driftsforstyrrelse. For oss er det derfor viktig at motoren vi bruker er non-backtracking.

Med denne regex-motoren bygde vi en første prototype. Denne prototypen brukte hardkodede regler, og regex-motoren bufret hele responsen i stedet for å streame den, men dette var nok for en første ytelsestest. 

Med denne regex-motoren bygde vi en første prototype. Denne prototypen brukte hardkodede regler, og regex-motoren bufret hele responsen i stedet for å streame den, men dette var nok for en første ytelsestest. Testoppsettet vårt besto av to virtuelle maskiner (VM-er). På den ene VM-en kjørte et oppsett der både den gamle og den nye løsningen kunne kjøre. På den måten kunne vi enkelt sammenligne de to metodene. På den andre serveren kjørte NGINX, som fungerte som origin-server og serverte en testfil. På den samme serveren kjørte også verktøyet wrk, som vi brukte til å generere belastningen. Dette er ikke helt optimalt, siden man for å få rene data helst bør kjøre belastningsgeneratoren på en egen VM, men denne VM-en hadde nok ressurser til at NGINX og wrk ikke var i veien for hverandre.

De første testresultatene var krystallklare: Prexy var omtrent 22 ganger raskere til å behandle én forespørsel (se bildet nedenfor). Dette ga prosjektet endelig grønt lys, fordi en så stor forskjell ga nok rom til å tåle eventuelle ytelsestap som kunne oppstå når funksjonaliteten ble fullført.

Etter den første testen gjennomførte vi en rekke åpenbare optimaliseringer, som for eksempel caching av kompilerte regulære uttrykk. I tillegg implementerte vi en mer effektiv måte å gjenbruke tilkoblinger til oppstrøms på. Dermed oppnådde vi en hastighetsgevinst på rundt 20 %. Vi testet deretter begge løsningene under toppbelastning, hvor vi sendte så mange forespørsler som mulig til serveren. Også her var forskjellen tydelig, og Prexy oppnådde en gjennomstrømning som var omtrent 25 ganger høyere enn den gamle løsningen.

Streaming av regex-motor

Et av kravene til dette prosjektet var at regex-motoren som brukes, skal være av streaming-typen. Regex-motoren i prototypen er ikke det, så på dette området var det behov for ekstra arbeid. Regex-motoren viste seg imidlertid å være svært optimalisert, så vi bestemte oss for å bruke denne implementeringen som grunnlag for vår streaming-motor. Når du streamer data gjennom en regex-motor, er det en rekke ting det er viktig å holde øye med. For det første vet du ikke hvilke data som kommer senere. Du må derfor jobbe med delvise treff: treff som ennå ikke er fullstendige, men som allerede har matchet ett eller flere tegn. Når du finner et fullstendig treff, må du deretter kontrollere at det ikke finnes overlappende delvise treff, fordi disse til slutt også kan utgjøre treff (og et lengre treff vinner over et kortere treff i tilfelle overlapping). Du kan altså først begynne å behandle et treff når det viser seg at ingen av de delvise treffene på det tidspunktet er et fullstendig treff.

Ytterligere optimaliseringer

Ombyggingen av regex-motoren hadde, som forventet, en negativ innvirkning på ytelsen til Prexy. Det var imidlertid fortsatt god margin sammenlignet med den gamle løsningen, og ved å legge til ytterligere optimaliseringer endte vi til slutt opp med enda bedre resultater enn før ombyggingen av regex-motoren. En av optimaliseringene vi gjennomførte, var å huske om en regel skal brukes på en bestemt fil. Statiske ressurser som CSS- og JS-filer endres nesten aldri, og derfor er det meningsløst å kjøre en regel hver gang når den aldri treffer den spesifikke filen. Et alternativ til dette var å cache resultatet av erstatningene, men ulempen med denne strategien er at det krever mye minne å lagre alle filene. Ved å huske hvilke regler som skal brukes, trenger vi bare noen få byte per fil ved å lagre informasjonen som et bitmap.

I tillegg utnytter vi optimaliseringene i regex-motoren fullt ut, for eksempel ved å ikke holde rede på fangstgrupper når disse ikke brukes i erstatningen. Dermed trenger regex-motoren bare å huske begynnelsen og slutten av hele treffet, noe som sparer arbeid. Vi har også deaktivert navngitte fangstgrupper, fordi dette var ganske komplisert i den strømmende varianten, og siden den gamle løsningen heller ikke støttet dette, var det ikke nødvendig. Alle disse optimaliseringene resulterte til slutt i følgende ytelse:

Sluttresultatet

Etter å ha testet Prexy grundig for å sikre at oppførselen faktisk var den samme som den gamle løsningen, begynte vi med en trinnvis utrulling. Over en periode på omtrent en uke aktiverte vi Prexy for hver enkelt klon. I overvåkingssystemet vårt var aktiveringstidspunktet ofte tydelig å se. Det var ikke uvanlig å se en reduksjon på rundt 50 % i responstidene. Det var også mindre forskjeller på rundt 10 % hos andre kunder som hadde få substitusjonsregler. Vår egen nettside ble rundt 30 % raskere (~50 ms).

Når det gjelder vår samlede infrastruktur, ser vi følgende forskjeller etter utrullingen av Prexy.

  • 33 % lavere RAM-forbruk

  • 20 % lavere CPU-forbruk

  • 10–50 % raskere svar

Alt i alt kan vi altså si at Prexy var et vellykket prosjekt. Ved å erstatte en utdatert NGINX-modul kan vi nå benytte oss av nyere og mer effektive teknikker som utgjør en klart målbar forskjell for alle våre kunder. Også i fremtiden vil vi fortsette å forbedre Clonable, både når det gjelder nye funksjoner og forbedring av eksisterende funksjonalitet.


Takk for at du leste denne ingeniørbloggen. Gi oss beskjed hvis du har lyst til å få slike tekniske innblikk i produktet vårt oftere. Har dette prosjektet vekket entusiasmen din? Ta da en titt på vår stillingsside :)

Kom i gang gratis med Clonable i dag

Oppdag hvordan du kan administrere internasjonale nettsteder sentralt fra én plattform.

Vi bruker informasjonskapsler for å forbedre brukeropplevelsen din, analysere trafikken på nettstedet og forbedre tjenestene våre.

Les vår policy for informasjonskapsler