Blog de inginerie - Prexy: reguli de căutare și înlocuire ultra-rapide

Bine ai venit la primul nostru blog de inginerie. Poate că este puțin mai tehnic decât te-ai obișnuit din celelalte blog, dar am făcut tot posibilul să îl facem ușor de înțeles pentru toată lumea. În acest articol vom vorbi despre Prexy, un nou modul software din cadrul Clonable, care este utilizat pentru aplicarea regulilor de substituție.

Context

Ca utilizator al Clonable, probabil cunoști deja funcționalitatea „substitution rule”. Poți folosi aceste reguli de căutare și înlocuire pentru a înlocui fragmente de text sau cod cu o variantă aleasă de tine. Poți folosi această funcție, de exemplu, pentru a înlocui o cheie API sau un ID de analiză, astfel încât să poți efectua analize diferite pentru site-ul tău original și pentru clonele traduse. La nivel intern, aceste reguli sunt folosite și pentru înlocuirea numelui de domeniu original cu cel al clonei tale, precum și pentru o serie de alte operațiuni.

Încă de la prima versiune a Clonable, această funcționalitate nu a fost niciodată modificată, ceea ce nu este de mirare, având în vedere că își făcea foarte bine treaba. Cu toate acestea, existau posibilități de îmbunătățire, atât în ceea ce privește performanța, cât și ușurința în utilizare. Din când în când, alegem o parte a produsului nostru Clonable, căreia nu i s-a mai acordat atenție de mult timp, pentru a o îmbunătăți. Astfel, anul trecut am revizuit întreaga noastră infrastructură de date pentru a o face mai rapidă, mai scalabilă și mai tolerantă la erori, iar pentru aceasta am reconstruit de la zero funcționalitatea de traducere a adreselor URL. În acest trimestru a venit rândul regulilor de substituție.

Situația anterioară și limitările

Regulile de substituție sunt aplicate în Clonable abia în ultima fază, chiar înainte de a trimite răspunsul către client. Prin urmare, în prima versiune a Clonable s-a optat pentru implementarea acestora în cadrul serverului web NGINX cu ajutorul modulului replace-filter-nginx, un modul creat de OpenResty. Modulul utilizează sregex ca motor de expresii regulate de tip streaming, creat tot de OpenResty. Aspectul de streaming este important în acest caz, deoarece nu vrei să încarci fiecare răspuns în întregime în memorie. Dacă ai face acest lucru, unele răspunsuri de dimensiuni mari ar putea provoca blocarea sau prăbușirea NGINX din cauza memoriei insuficiente. O a doua caracteristică importantă a sregex este că poate procesa mai multe reguli în paralel. Fiecare clone are deja o serie de reguli standard și, în plus, uneori câteva reguli adăugate specific pentru clone-ul respectiv. Dacă acestea nu ar putea fi procesate simultan, ar trebui totuși să tamponăm întregul răspuns, deoarece, după prima regulă, trebuie să putem parcurge din nou răspunsul pentru a aplica a doua regulă.

Anul trecut, însă, am întâlnit din ce în ce mai des probleme ciudate de performanță. Aceste probleme erau adesea de scurtă durată, dar, în cazuri extreme, puteau prelungi timpul de răspuns al unei pagini cu câteva secunde. După un astfel de vârf, pagina se încărca adesea din nou normal, ceea ce făcea ca problema să fie greu de reprodus. Pentru a depana mai în detaliu această problemă, am adăugat antetul „Clonable-Timings” la toate răspunsurile, ceea ce ne-a permis să determinăm, în linii mari, care etapă a procesului de traducere consuma atât de mult timp. Am descoperit că, în aproape toate cazurile, serverul din amonte răspundea rapid și traducerea se desfășura fluid. Există însă un decalaj între finalizarea traducerii și finalizarea completă a cererii, ceea ce ne-a sugerat că ar putea fi vorba despre regulile de substituție.

Modelul de concurență al NGINX

Totuși, acest lucru nu ne-a oferit încă un răspuns la întrebarea de ce aceste întârzieri apăreau doar sporadic și de ce se întâmpla acest lucru în timp ce serverele nu erau încărcate nici măcar la jumătate din capacitatea lor. Pentru a înțelege mai bine acest fenomen, trebuie să analizăm mai în detaliu modul în care NGINX gestionează sarcinile de lucru.

NGINX are o arhitectură de tip „worker” cu un singur proces master și mai multe procese worker. Conexiunile sunt distribuite între worker-i pentru a procesa mai multe cereri simultan. Totuși, această configurație are și un dezavantaj: când un worker este ocupat cu procesarea unei cereri, celelalte cereri alocate lui trebuie să aștepte. Astfel, o singură cerere grea poate provoca întârzieri la mai multe cereri, chiar dacă ceilalți worker-i nu au nimic de făcut. Acest efect este ilustrat clar în imaginea de mai jos. În timpul realizării acestei imagini, se desfășoară un test de stres pe 10 conexiuni diferite. În imagine poți observa clar că trei procese de lucru sunt ocupate, iar un al patrulea nu face aproape nimic.

E timpul pentru Prexy

Odată identificat clar punctul de blocaj, am elaborat un plan pentru a remedia situația. Acest proiect a primit numele de Prexy, o combinație între RegEx și Proxy. Rezultatul final trebuia să îndeplinească trei cerințe principale:

  1. Trebuie să fie un înlocuitor direct pentru configurația actuală. Regulile de substituție trebuie aplicate în același mod pentru a evita apariția unor erori în configurațiile existente.

  2. Regulile de substituție trebuie aplicate în mod continuu pentru a evita ca soluția să consume prea multă memorie.

  3. Noua soluție trebuie să fie mai rapidă decât cea actuală.

În primul rând, am căutat un runtime multi-threaded puternic, capabil să proceseze cererile eficient. După ce am evaluat mai multe opțiuni, alegerea a căzut asupra runtime-ului Tokio pentru limbajul de programare Rust. Rust este cunoscut pentru faptul că permite scrierea de programe rapide, fără riscurile de siguranță asociate altor limbaje de nivel inferior, precum C++. Runtime-ul Tokio este un runtime asincron flexibil, creat pentru aplicații de rețea. Una dintre cele mai importante caracteristici pentru noi este faptul că este de tip „work-stealing”. Aceasta înseamnă că, spre deosebire de NGINX, o cerere nu este legată de un singur worker, ci un worker care nu are nimic de făcut poate „fura” sarcini de la un alt worker. În acest fel, resursele serverelor noastre pot fi utilizate mai eficient.

Primul prototip

După alegerea tehnologiilor de bază, am decis să creăm un prim prototip pentru a estima cât de mult ne-ar aduce acest proiect în termeni de viteză și dacă merita efortul. Ca primă implementare pentru motorul regex (partea care procesează și aplică efectiv regulile), am folosit biblioteca standard regex (sau „crate”, cum se numește în terminologia Rust). Acest motor, la fel ca sregex, este de tip non-backtracking, ceea ce înseamnă că prezintă un risc mai mic de a întâmpina o problemă de tip ReDoS. În cazul unui ReDoS, o expresie regulată se confruntă cu o intrare de așa natură încât timpul necesar pentru evaluarea acesteia crește exponențial. Acest lucru poate avea consecințe dezastruoase asupra performanței și a provocat o întrerupere majoră la Cloudflare. Prin urmare, pentru noi este important ca motorul pe care îl folosim să fie de tip non-backtracking.

Cu ajutorul acestui motor regex am construit un prim prototip. Acest prototip folosea reguli hard-coded, iar motorul regex stoca în buffer întregul răspuns în loc să-l transmită în flux, dar acest lucru a fost suficient pentru un prim test de performanță. 

Cu ajutorul acestui motor regex am construit un prim prototip. Acest prototip folosea reguli hard-coded, iar motorul regex stoca în buffer întregul răspuns în loc să-l transmită în flux, dar acest lucru a fost suficient pentru un prim test de performanță. Configurația noastră de testare a constat din două mașini virtuale (VM). Pe una dintre mașinile virtuale rula o configurație în care puteau funcționa atât soluția veche, cât și cea nouă. În acest fel, am putut face cu ușurință comparații între cele două metode. Pe celălalt server rula NGINX, care servea drept server de origine și furniza un fișier de test. Pe același server rula și instrumentul wrk, cu ajutorul căruia am generat încărcarea. Această configurație nu este tocmai optimă, întrucât, pentru a obține date pure, ar fi de preferat ca generatorul de încărcare să ruleze pe o mașină virtuală separată, dar această mașină virtuală dispunea de resurse suficiente pentru ca NGINX și wrk să nu se interfereze reciproc.

Primele rezultate ale testelor au fost cât se poate de clare: Prexy era de aproximativ 22 de ori mai rapid în procesarea unei singure solicitări (vezi imaginea de mai jos). Acest lucru a dat undă verde definitivă proiectului, deoarece, având în vedere o diferență atât de mare, exista suficient spațiu de manevră pentru a face față eventualelor scăderi de performanță care ar putea apărea odată cu finalizarea funcționalității.

După primul test, am efectuat o serie de optimizări evidente, cum ar fi stocarea în cache a expresiilor regulate compilate. În plus, am implementat o metodă mai eficientă de reutilizare a conexiunilor către upstream. Astfel, am obținut un câștig de viteză de aproximativ 20%. Am testat apoi ambele soluții în condiții de încărcare maximă, trimițând cât mai multe cereri către server. Și în acest caz, diferența a fost clar vizibilă, iar Prexy a atins un debit de aproximativ 25 de ori mai mare decât soluția veche.

Motor de exprimare regulată (regex) pentru streaming

Una dintre cerințele acestui proiect era ca motorul regex utilizat să fie de tip streaming. Motorul regex din prototip nu era de acest tip, așa că a fost nevoie de muncă suplimentară în acest sens. Cu toate acestea, motorul regex s-a dovedit a fi foarte bine optimizat, așa că am decis să folosim această implementare ca bază pentru motorul nostru de streaming. Când se transmit date printr-un motor regex, există câteva aspecte importante de care trebuie să ții cont. În primul rând, nu știi ce date vor urma. Prin urmare, trebuie să lucrezi cu potriviri parțiale: potriviri care nu sunt încă complete, dar care au potrivit deja unul sau mai multe caractere. La găsirea unei potriviri complete, trebuie verificat apoi dacă nu există potriviri parțiale care se suprapun, deoarece acestea pot fi, în cele din urmă, considerate potriviri (iar o potrivire mai lungă are prioritate față de una mai scurtă în cazul suprapunerii). Prin urmare, poți începe să procesezi o potrivire abia după ce toate potrivirile parțiale existente în acel moment se dovedesc a nu fi potriviri complete.

Optimizări suplimentare

Conversia motorului regex a avut, așa cum era de așteptat, un impact negativ asupra performanței Prexy. Cu toate acestea, exista încă suficientă marjă în comparație cu soluția veche și, prin adăugarea unor optimizări suplimentare, am ajuns în cele din urmă chiar la un nivel mai ridicat decât înainte de conversia motorului regex. Una dintre optimizările pe care le-am aplicat a fost memorarea faptului dacă o regulă trebuie aplicată unui anumit fișier. Resursele statice, precum fișierele CSS și JS, nu se modifică aproape niciodată, iar în acest caz este inutil să executăm de fiecare dată o regulă, având în vedere că aceasta nu se potrivește niciodată cu fișierul respectiv. O alternativă ar fi fost stocarea în cache a rezultatului înlocuirilor, dar dezavantajul acestei strategii este că necesită multă memorie pentru a stoca toate fișierele. Prin memorarea regulilor care trebuie aplicate, avem nevoie doar de câțiva octeți pe fișier, stocând informația sub formă de bitmap.

În plus, exploatăm la maximum optimizările motorului regex, de exemplu renunțând la reținerea grupurilor de captură când acestea nu sunt utilizate în înlocuire. Astfel, motorul regex trebuie să rețină doar începutul și sfârșitul întregii potriviri, ceea ce reduce efortul. De asemenea, am dezactivat grupurile de captură denumite, deoarece acestea erau destul de complexe în varianta de streaming și, întrucât soluția veche nu le suporta, nu erau neapărat necesare. Toate aceste optimizări au dus, în final, la următoarele performanțe:

Rezultatul final

După testarea amănunțită a Prexy pentru a ne asigura că comportamentul era într-adevăr identic cu cel al vechii soluții, am început implementarea treptată. Pe parcursul unei perioade de aproximativ o săptămână, am activat Prexy pentru fiecare clonă. În sistemul nostru de monitorizare, momentul activării era adesea ușor de observat. Nu era deloc neobișnuit să observăm o reducere de aproximativ 50% a timpilor de răspuns. De asemenea, s-au observat diferențe mai mici, de aproximativ 10%, la alți clienți care aveau puține reguli de substituție. Propriul nostru site web a devenit cu aproximativ 30% mai rapid (~50 ms).

În ceea ce privește infrastructura noastră generală, observăm următoarele diferențe după implementarea Prexy.

  • Consum de RAM cu 33% mai mic

  • Consum de CPU cu 20% mai mic

  • Răspunsuri cu 10-50 % mai rapide

În concluzie, putem afirma că Prexy a fost un proiect de succes. Prin înlocuirea unui modul NGINX învechit, putem folosi acum tehnici mai noi și mai eficiente, care fac o diferență clar măsurabilă pentru toți clienții noștri. Și în viitor vom continua să îmbunătățim Clonable, atât în ceea ce privește funcțiile noi, cât și îmbunătățirea funcționalităților existente.


Mulțumim că ai citit acest blog de inginerie. Spune-ne dacă ți-ar plăcea să primești mai des astfel de informații tehnice despre produsul nostru. Te-a entuziasmat acest proiect? Atunci aruncă o privire și la pagina noastră de oferte de locuri de muncă :)

Începe astăzi gratuit cu Clonable

Descoperă cum poți gestiona centralizat site-urile web internaționale de pe o singură platformă.

Utilizăm cookie-uri pentru a-ți îmbunătăți experiența, a analiza traficul pe site și a ne perfecționa serviciile.

Citește politica noastră privind cookie-urile