Inženirski blog – Prexy: izjemno hitra pravila za iskanje in zamenjavo
Dobrodošli na našem prvem inženirskem blogu. Morda je vsebina nekoliko bolj tehnična, kot ste navajeni iz drugih blogov, vendar smo se potrudili, da bi bila razumljiva za vse. V tem članku bomo govorili o Prexyju, novi programski opremi znotraj Clonable, ki se uporablja za uporabo pravil zamenjave.
Ozadje
Kot uporabnik Clonable morda že poznate funkcionalnost pravil za zamenjavo. Ta pravila za iskanje in zamenjavo lahko uporabite za zamenjavo delov besedila ali kode s samodejno izbrano različico. To lahko na primer uporabite za zamenjavo API-ključa ali identifikacijske številke za analitiko, da lahko izvajate različne analize za svojo izvorno spletno stran in prevedene klone. Notranje se ta pravila uporabljajo tudi za zamenjavo vašega izvirnega domenskega imena z domenskim imenom vašega klona ter za nekaj drugih zadev.
Od prve različice Clonable ta funkcionalnost ni bila nikoli spremenjena, kar samo po sebi ni nič čudnega, saj je odlično opravljala svojo nalogo. Vendar pa so bile možne izboljšave, tako na področju zmogljivosti kot tudi na področju uporabniške prijaznosti. Včasih vzamemo Clonable, del našega izdelka, ki mu že dolgo nismo posvetili časa, da ga izboljšamo. Tako smo lani temeljito prenovili celotno našo podatkovno infrastrukturo, da bi jo naredili hitrejšo, bolj prilagodljivo in odpornejšo na napake, za kar smo tudi že enkrat od začetka na novo zgradili funkcionalnost prevajanja URL-jev. V tem četrtletju je bila na vrsti funkcionalnost pravil za zamenjavo.
Prejšnje stanje in omejitve
Pravila zamenjave se v Clonable uporabijo šele v zadnji fazi, tik pred pošiljanjem odgovora nazaj stranki. V prvi različici Clonable smo se zato odločili, da to implementiramo znotraj spletnega strežnika NGINX s pomočjo modula replace-filter-nginx, ki ga je razvil OpenResty. Modul uporablja sregex kot strežniški regex-motor, ki ga je prav tako razvil OpenResty. V tem primeru je pomemben prav ta strežniški vidik, saj ne želimo vsakega odziva v celoti naložiti v pomnilnik. Če bi to storili, bi lahko nekateri večji odzivi povzročili sesutje ali zamrznitev strežnika NGINX zaradi nezadostnega pomnilnika. Druga pomembna lastnost sregexa je, da lahko vzporedno obdeluje več pravil. Vsak klon namreč že vsebuje nekaj standardnih pravil, poleg tega pa včasih še nekaj pravil, ki so bila dodana posebej za ta klon. Če jih ne bi bilo mogoče obdelati hkrati, bi morali še vedno shraniti celoten odgovor v pomnilnik, saj bi morali po prvem pravilu še enkrat pregledati odgovor za drugo pravilo.
V zadnjem letu pa smo vse pogosteje naleteli na nenavadne težave z zmogljivostjo. Te težave so bile pogosto kratkotrajne, vendar so v skrajnih primerih lahko podaljšale odzivni čas strani za nekaj sekund. Po enem takšnem vrhu se je stran pogosto spet nalagala normalno, zaradi česar je bilo težko ponoviti težavo. Da bi to podrobneje odpravili, smo vsem odgovorom dodali glavo »Clonable-Timings«, kar nam je omogočilo, da smo v grobem ugotovili, kateri korak prevajalskega procesa je vzel toliko časa. Iz tega je bilo razvidno, da je v skoraj vseh primerih upstream odgovoril hitro in da je tudi prevajanje potekalo dokaj gladko. Vendar pa je med zaključkom prevajanja in popolnim zaključkom zahtevka obstajala vrzel, kar nam je dalo namig, da bi to lahko bilo povezano s pravili zamenjave.
Model sočasnosti NGINX
Vendar pa nam to še ni dalo odgovora na vprašanje, zakaj so se te zamude pojavljale le občasno in zakaj se je to dogajalo, čeprav strežniki niso bili obremenjeni niti za polovico svoje zmogljivosti. Da bi bolje razumeli ta pojav, moramo nekoliko podrobneje preučiti, kako NGINX obdeluje delovne obremenitve.
NGINX ima arhitekturo delavcev z enim glavnim procesom in več delavskimi procesi. Povezave se razporedijo med delavce, da se lahko hkrati obdeluje več zahtevkov. Ta konfiguracija pa ima tudi slabost: ko je delavec zaseden z obdelavo zahtevka, morajo ostali zahtevki, ki so prav tako dodeljeni istemu delavcu, počakati. To lahko povzroči, da en zahteven zahtevek povzroči zamudo pri več zahtevkih, tudi če drugi delavci nimajo dela. Ta učinek je dobro viden na spodnji sliki. Med izdelavo te slike poteka stresni test prek 10 različnih povezav. Na sliki je jasno vidno, da so trije delovni procesi zelo zasedeni, četrti pa skoraj nič ne počne.

Čas za Prexy
Ko je postalo jasno, kje je ozko grlo, smo pripravili načrt za izboljšanje. Projekt smo poimenovali Prexy, kar je združitev besed RegEx in Proxy. Za končni rezultat so veljale 3 glavne zahteve:
Rešitev mora biti neposredna zamenjava za trenutno konfiguracijo. Pravila zamenjave je treba uporabiti na enak način, da se prepreči motnje v obstoječih konfiguracijah.
Pravila zamenjave je treba uporabljati v realnem času, da se prepreči prekomerna poraba pomnilnika.
Nova rešitev mora biti hitrejša od sedanje rešitve.
Najprej smo morali poiskati zmogljivo večnitno izvedbeno okolje, ki lahko učinkovito obdeluje zahteve. Po preučitvi več možnosti smo se odločili za izvedbeno okolje Tokio za programski jezik Rust. Rust je znan po tem, da omogoča pisanje hitrih programov brez varnostnih tveganj, značilnih za druge nizkonivojske jezike, kot je C++. Tokio runtime je prilagodljivo asinhrono izvedbeno okolje, ustvarjeno za omrežne aplikacije. Ena od najpomembnejših lastnosti zanje je dejstvo, da podpira »work-stealing«. To pomeni, da v nasprotju z NGINX-om zahteva ni vezana na enega samega delavca, ampak da delavec, ki nima dela, lahko »ukrade« delo drugemu delavcu. Na ta način se lahko viri njihovih strežnikov bolje izkoristijo.
Prvi prototip
Po izbiri osnovnih tehnologij smo se odločili, da bomo pripravili prvi prototip, da bi ocenili, koliko približnega povečanja hitrosti bi nam ta projekt prinesel in ali se sploh splača. Kot prvo implementacijo za regex motor (del, ki dejansko obdeluje in uporablja pravila) smo uporabili standardno regex knjižnico (ali »crate«, kot se imenuje v terminologiji Rust). Ta motor je, tako kot sregex, brez povratnega sledenja (non-backtracking), kar pomeni, da je tveganje za pojav ReDoS manjše. Pri ReDoS-u se redni izraz sooči z vnosom, pri katerem se čas, potreben za njegovo ovrednotenje, eksponentno poveča. To lahko katastrofalno vpliva na zmogljivost in je pri Cloudflareju povzročilo veliko motnjo. Zato je za nas pomembno, da je motor, ki ga uporabljamo, brez povratnega sledenja.
S tem regex-motorjem smo zgradili prvi prototip. Ta prototip je uporabljal trdno zakodirana pravila, regex-motor pa je celoten odgovor shranil v pomnilnik namesto da bi ga pretakal, vendar je to zadostovalo za prvi test zmogljivosti.
S tem regex-motorjem smo zgradili prvi prototip. Ta prototip je uporabljal trdno zakodirana pravila, regex-motor pa je celoten odgovor shranjeval v pomnilnik namesto da bi ga pretakal, vendar je to zadostovalo za prvi test zmogljivosti. Naša testna postavitev je obsegala dva virtualna računalnika (VM). Na enem VM je tekla postavitev, v kateri sta lahko delovali tako stara kot nova rešitev. Na ta način smo lahko enostavno primerjali obe metodi. Na drugem strežniku je tekel NGINX, ki je deloval kot izvorni strežnik in streževal testno datoteko. Na istem strežniku je teklo tudi orodje wrk, s katerim smo ustvarjali obremenitev. To ni povsem optimalno, saj je za čiste podatke najbolje, da generator obremenitve teče na ločenem virtualnem računalniku, vendar je imel ta virtualni računalnik dovolj virov, da si NGINX in wrk nista motila.
Prvi rezultati testiranja so bili povsem jasni: Prexy je bil pri obdelavi ene zahteve približno 22-krat hitrejši (glej sliko spodaj). To je projektu dokončno dalo zeleno luč, saj je bila ob tako veliki razliki na voljo dovolj rezerve, da bi lahko prenesli morebitne upade zmogljivosti, ki bi se lahko pojavili pri dokončanju funkcionalnosti.

Po prvem testu smo izvedli nekaj očitnih optimizacij, kot je shranjevanje v predpomnilnik kompiliranih rednih izrazov. Poleg tega smo uvedli učinkovitejši način ponovne uporabe povezav do upstream strežnika. S tem smo dosegli približno 20-odstotno povečanje hitrosti. Nato smo obe rešitvi preizkusili tudi pod največjo obremenitvijo, pri čemer smo na strežnik poslali čim več zahtevkov. Tudi tu je bila razlika jasno vidna, saj je Prexy dosegel prepustnost, ki je bila približno 25-krat višja od stare rešitve.

Streaming regex engine
Ena od zahtev tega projekta je bila, da mora biti uporabljeni regex-motor streaming. Regex-motor iz prototipa tega ni, zato je bilo na tem področju potrebno dodatno delo. Vendar se je izkazalo, da je regex-motor zelo optimiziran, zato smo se odločili, da bomo to implementacijo vendarle vzeli kot osnovo za naš streaming-motor. Ko podatke pretakate skozi regex-motor, je treba upoštevati nekaj pomembnih stvari. Prvič, ne veste, kateri podatki bodo še prišli. Zato morate delati z delnimi ujemanji: ujemanji, ki še niso popolna, vendar so že ujemali enega ali več znakov. Ob najdenju popolnega ujemanja je nato treba preveriti, da ni prekrivajočih se delnih ujemanj, saj se lahko ta na koncu prav tako ujemajo (v primeru prekrivanja pa daljše ujemanje prevlada nad krajšim). Zato lahko obdelate podatke šele, ko se izkaže, da nobeno od takratnih delnih ujemanj ni popolno ujemanje.
Dodatne optimizacije
Preoblikovanje regex motorja je, kot je bilo pričakovano, negativno vplivalo na zmogljivost Prexyja. Vendar je v primerjavi s staro rešitvijo še vedno ostalo dovolj rezerve, z dodatnimi optimizacijami pa smo na koncu dosegli celo boljše rezultate kot pred preoblikovanjem regex motorja. Ena od optimizacij, ki smo jo uporabili, je bilo shranjevanje podatka, ali je treba določeno pravilo uporabiti za določeno datoteko. Statični elementi, kot so datoteke CSS in JS, se skoraj nikoli ne spremenijo, zato je nesmiselno vsakič izvajati pravilo, če se to nikoli ne ujema s to konkretno datoteko. Alternativa temu je bilo shranjevanje rezultatov zamenjav v predpomnilnik, vendar je pomanjkljivost te strategije v tem, da shranjevanje vseh datotek zahteva veliko pomnilnika. Z zapomnitvijo, katere pravila je treba uporabiti, potrebujemo le nekaj bajtov na datoteko, saj informacije shranjujemo kot bitno sliko.
Poleg tega optimalno izkoriščamo optimizacije znotraj regex-motorja, na primer tako, da ne beležimo zajemnih skupin, kadar se te ne uporabljajo pri zamenjavi. Zaradi tega si mora regex-motor zapomniti le začetek in konec celotnega ujemanja, kar prihrani delo. Prav tako smo onemogočili imenovane zajemne skupine, saj je bilo to v različici s pretakanjem precej zapleteno, poleg tega pa stara rešitev tega ni podpirala, zato to tudi ni bilo nujno potrebno. Vse te optimizacije so na koncu zagotovile naslednjo zmogljivost:

Končni rezultat
Po temeljitem testiranju Prexyja, da bi se prepričali, da se res obnaša enako kot stara rešitev, smo začeli s postopnim uvajanjem. V obdobju približno enega tedna smo za vsak klon aktivirali Prexy. V našem sistemu za spremljanje je bil trenutek aktivacije pogosto dobro viden. Zmanjšanje odzivnih časov za približno 50 % ni bilo redko. Manjše razlike, okoli 10 %, so bile opazne tudi pri drugih strankah, ki so imele le malo pravil za zamenjavo. Naša lastna spletna stran je postala približno 30 % hitrejša (~50 ms).
Glede na našo splošno infrastrukturo opažamo naslednje razlike po uvedbi Prexyja.
33 % manjša poraba pomnilnika RAM
20 % manjša poraba procesorske moči
10–50 % hitrejši odzivi
Skratka, lahko rečemo, da je bil Prexy uspešen projekt. Z zamenjavo zastarelega modula NGINX lahko zdaj uporabljamo novejše in učinkovitejše tehnike, ki za vse naše stranke pomenijo jasno merljivo razliko. Tudi v prihodnosti bomo nadaljevali z izboljševanjem Clonable, tako na področju novih funkcij kot tudi izboljševanja obstoječih funkcionalnosti.
Hvala, ker ste prebrali ta inženirski blog. Povejte nam, če bi radi pogosteje dobivali takšne tehnične vpoglede v naš izdelek. Vas je ta projekt popolnoma navdušil? Potem si oglejte tudi našo stran z razpisanimi delovnimi mesti :)
Začnite danes brezplačno z Clonable.
Odkrijte, kako lahko mednarodne spletne strani centralno upravljate z ene platforme.