Teknikblogg – Prexy: supersnabba sök- och ersättningsregler

Välkommen till vår första teknikblogg. Den är kanske lite mer teknisk än vad du är van vid från våra andra blogg, men vi har gjort vårt bästa för att göra den begriplig för alla. I den här artikeln ska vi prata om Prexy, en ny mjukvara inom Clonable som används för att tillämpa substitutionsreglerna.

Om oss

Som användare av Clonable känner du kanske redan till funktionen ”substitution rule”. Dessa sök- och ersättningsregler kan du använda för att byta ut text eller kod mot en variant som du själv väljer. Detta kan du till exempel använda för att ersätta en API-nyckel eller ett analys-ID så att du kan göra olika analyser för din källwebbplats och dina översatta kloner. Internt används samma regler även för att ersätta ditt ursprungliga domännamn med klonens domännamn samt för ett antal andra ändringar.

Sedan den allra första versionen av Clonable har denna funktion aldrig ändrats, och det är i sig inte konstigt, eftersom den fungerade utmärkt. Ändå fanns det utrymme för förbättringar, både när det gäller prestanda och användarvänlighet. Emellanåt tar vi fram en del av Clonable – en del av vår produkt som vi inte har ägnat tid åt på länge – för att förbättra den. Förra året reviderade vi hela datainfrastrukturen för att göra den snabbare, mer skalbar och mer feltolerant, och i samma veva byggde vi om URL-översättningsfunktionen från grunden. Nu är det substitutionsreglernas tur.

Tidigare situation och begränsningar

Substitutionsregler tillämpas i Clonable först i den allra sista fasen, precis innan svaret skickas tillbaka till klienten. I den första versionen av Clonable valde man därför att implementera detta inom webbservern NGINX med hjälp av replace-filter-nginx-modulen, en modul skapad av OpenResty. Modulen använder sregex som strömmande regexmotor, även den utvecklad av OpenResty. Den strömmande aspekten är viktig, eftersom vi inte vill ladda hela svaret i minnet. Om vi gjorde det skulle stora svar kunna få NGINX att krascha eller hänga sig på grund av otillräckligt minne. En annan viktig egenskap hos sregex är att den kan bearbeta flera regler parallellt. Varje klon har redan ett antal standardregler och ibland även regler som lagts till specifikt för klonen. Om de inte kunde behandlas samtidigt skulle vi ändå behöva buffra hela svaret, eftersom vi efter den första regeln måste kunna gå igenom det en gång till för den andra regeln.

Under det senaste året har vi stött på återkommande prestandaproblem. De var kortvariga men kunde i extrema fall förlänga svarstiden med flera sekunder. Efter en sådan topp laddades sidan ofta normalt igen, vilket gjorde det svårt att återskapa problemet. För att felsöka detta ytterligare lade vi till Clonable-Timings-rubriken i alla svar, vilket gjorde det möjligt för oss att i grova drag avgöra vilket steg i översättningsprocessen som tog så mycket tid. Vi såg att uppströmsreaktionen var snabb och att översättningen gick smidigt. Men det fanns ett glapp mellan när översättningen var klar och när begäran avslutades, vilket pekade mot substitutionsreglerna.

NGINX:s konkurrensmodell

Detta gav oss dock fortfarande inget svar på frågan varför dessa fördröjningar endast inträffade sporadiskt, och varför detta hände trots att servrarna inte ens utnyttjades 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 arbetararkitektur med en huvudprocess och flera arbetsprocesser. Anslutningarna fördelas mellan arbetarna så att flera förfrågningar kan bearbetas samtidigt. Denna konfiguration har dock en nackdel: när en arbetare är upptagen med en förfrågan måste alla andra förfrågningar som tilldelats samma arbetare vänta. Detta kan leda till att en tung förfrågan orsakar fördröjningar för flera andra, även om de andra arbetarna står stilla. Denna effekt syns tydligt i bilden nedan. Under skapandet av denna bild körs ett stresstest över 10 olika anslutningar. I bilden syns det tydligt att tre arbetare är fullt sysselsatta, medan en fjärde nästan inte gör någonting.

Dags för Prexy

När vi identifierat flaskhalsen utarbetade vi en plan för att förbättra situationen. Projektet fick namnet Prexy, en sammanslagning av RegEx och Proxy. Det fanns tre huvudkrav på slutresultatet:

  1. Det ska fungera som en direkt ersättning för din nuvarande konfiguration, och ersättningsreglerna måste tillämpas på samma sätt för att undvika att något går sönder i befintliga konfigurationer.

  2. Substitutionsreglerna måste tillämpas löpande för att förhindra att lösningen använder för mycket minne.

  3. Den nya lösningen måste vara snabbare än den nuvarande lösningen.

Först och främst behövde vi hitta en kraftfull multitrådad runtime som kan hantera förfrågningar på ett effektivt sätt. Efter att ha övervägt flera alternativ föll valet på Tokio-runtime för programmeringsspråket Rust. Rust är känt för att man kan skriva snabba program med det, utan de säkerhetsrisker som finns i andra lågnivåspråk som C++. Tokio-runtimen är en flexibel asynkron runtime byggd för nätverksapplikationer. För oss är dess work-stealing-funktion avgörande. Till skillnad från NGINX är en begäran inte låst till en enda arbetare, utan en ledig arbetare kan ta över uppgifter från en annan. På så sätt utnyttjar vi våra servrars resurser på bästa sätt.

Första prototypen

Efter att ha valt de underliggande teknologierna beslutade vi oss för att sätta upp en första prototyp för att uppskatta hur stor hastighetsvinst detta projekt ungefär skulle ge oss och om det överhuvudtaget var värt besväret. Som 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 det kallas i Rust-terminologi). Denna motor är, precis som sregex, icke-backtracking, vilket innebär att risken för ett ReDoS-problem är mindre. Vid en ReDoS-attack utsätts ett reguljärt uttryck för en sådan indata att den tid det tar att utvärdera indatan ökar exponentiellt. Detta kan vara katastrofalt för prestandan och har hos Cloudflare lett till ett omfattande driftavbrott. För oss är det därför viktigt att den motor vi använder är icke-backtracking.

Med hjälp av en regexmotor byggde vi en första prototyp. Prototypen använde hårdkodade regler och regexmotorn buffrade hela svaret i stället för att strömma det, men det räckte för ett första prestandatest. 

Med regexmotorn byggde vi en första prototyp. Denna prototyp använde hårdkodade regler och regexmotorn buffrade hela svaret i stället för att strömma det, men detta räckte för ett första prestandatest. Vår testuppsättning bestod av två virtuella maskiner (VM). På den ena VM:en kördes en miljö där både den gamla och den nya lösningen kunde köras. På så sätt kunde vi enkelt göra jämförelser mellan de båda metoderna. På den andra kördes NGINX som ursprungsserver och serverade en testfil. På samma server kördes även verktyget wrk för att generera belastningen. Detta är inte helt optimalt, eftersom du för rena data helst vill köra belastningsgeneratorn på en separat virtuell maskin, men den virtuella maskinen hade tillräckligt med resurser för att NGINX och wrk inte skulle störa varandra.

De första testresultaten var tydliga: Prexy var ungefär 22 gånger snabbare när det gällde att hantera en enda förfrågan (se bilden nedan). Det gav projektet grönt ljus, eftersom en så stor skillnad gav utrymme att hantera eventuella prestandaförsämringar som kan uppstå när funktionaliteten färdigställs.

Efter det första testet genomförde vi ett antal uppenbara optimeringar, såsom cachelagring av kompilerade reguljära uttryck. Dessutom implementerade vi ett effektivare sätt att återanvända anslutningar till uppströms. På så sätt uppnådde vi en hastighetsvinst på cirka 20 %. Vi testade sedan båda lösningarna under toppbelastning, där vi skickade så många förfrågningar som möjligt till servern. Även här var skillnaden tydlig och Prexy uppnådde en genomströmning som var ungefär 25 gånger högre än den gamla lösningen.

Streaming av regex-motor

Ett av kraven i detta projekt var att den regexmotor som används ska vara strömmande. Regexkoden i prototypen är inte det, så på detta område krävdes ytterligare arbete. Regexkoden visade sig dock vara mycket optimerad, så vi beslutade att använda just denna implementering som grund för vår strömmande motor. När du strömmar data genom en regexmotor finns det ett antal saker som är viktiga att hålla koll på. För det första vet du inte vilken data som kommer härnäst. Du måste därför jobba med partiella matchningar – delmatcher som ännu inte är fullständiga men som redan har matchat ett eller flera tecken. När en fullständig matchning hittas måste du kontrollera att det inte finns överlappande partiella matchningar, eftersom även de kan bli matchningar (och en längre matchning prioriteras framför en kortare vid överlappning). Du kan alltså först börja bearbeta en matchning när alla delmatcher som finns vid det tillfället visar sig inte vara fullständiga matchningar.

Ytterligare optimeringar

Ombyggnaden av regexmotorn hade, som förväntat, en negativ inverkan på Prexys prestanda. Det fanns dock fortfarande tillräckligt med marginal jämfört med den gamla lösningen, och genom att lägga till ytterligare optimeringar hamnade vi till slut till och med högre än före ombyggnaden av regexmotorn. En av de optimeringar vi tillämpade var att komma ihåg om en regel ska tillämpas på en viss fil. Statiska tillgångar som CSS- och JS-filer ändras nästan aldrig, så det är meningslöst att köra en regel som aldrig matchar just den filen. Ett alternativ var att cacha resultatet av ersättningarna, men nackdelen med den strategin är att den kräver mycket minne för att lagra alla filer. 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.

Vi utnyttjar dessutom regexmotorns optimeringar fullt ut genom att till exempel inte spara capturing groups när de inte används i ersättningen. Då behöver regexmotorn bara komma ihåg början och slutet av hela matchningen, vilket sparar arbete. Vi har även inaktiverat named capturing groups eftersom det var ganska komplicerat i den strömmande varianten och eftersom den gamla lösningen inte heller stödde detta var det inte nödvändigt. Alla dessa optimeringar resulterade slutligen i följande prestanda:

Slutresultatet

Efter att ha testat Prexy ingående för att säkerställa att funktionen verkligen var densamma som den gamla lösningen började vi med en stegvis utrullning. Under en period på ungefär en vecka aktiverade vi Prexy för varje klon. I vår övervakning såg vi tydligt när aktiveringen skedde. Det var inte ovanligt att svarstiderna föll med cirka 50 %. Hos andra kunder med få substitutionsregler såg vi mindre skillnader, runt 10 %. Vår egen webbplats blev cirka 30 % snabbare (~50 ms).

När det gäller vår övergripande infrastruktur ser vi följande skillnader efter införandet av Prexy.

  • 33 % lägre RAM-förbrukning

  • 20 % lägre CPU-förbrukning

  • 10–50 % snabbare svarstider

Sammantaget kan vi konstatera att Prexy var ett framgångsrikt projekt. Genom att ersätta en föråldrad NGINX-modul kan vi nu använda nyare och effektivare tekniker som gör en tydligt mätbar skillnad för alla våra kunder. Även i framtiden kommer vi att fortsätta förbättra Clonable, både när det gäller nya funktioner och förbättring av befintlig funktionalitet.


Tack för att du läste den här teknikbloggen. Hör av dig om du vill få fler sådana här tekniska inblickar i vår produkt. Har du blivit helt entusiastisk över det här projektet? Ta då en titt på vår rekryteringssida :)

Kom igång gratis med Clonable redan idag

Upptäck hur du hanterar internationella webbplatser centralt från en enda plattform.

Vi använder cookies för att förbättra din upplevelse, analysera webbplatsens trafik och förbättra våra tjänster.

Läs vår cookiepolicy