Tocmai ai instalat în producţie o nouă aplicaţie, şampaniile pocnesc şi toată lumea este fericită. A doua zi colegii devops-i vin la tine. Situaţia nu pare prea roz. Aplicaţia s-a oprit de mai multe ori pe parcursul evenimentelor încărcate de seara trecută. Ca lucrurile să fie şi mai complicate, motivul pentru care aplicaţia a cedat nu este cunoscut. Singurul indiciu pe care îl ai este încărcarea excesivă a resurselor sistemului de operare.
Înainte de a pleca acasă ieri, totul părea să funcţioneze corect, conform aşteptărilor. Pentru că nu ai prea mult timp la dispoziţie până la următorul val de evenimente recomanzi adăugarea unor noi instanţe. Asta nu îi prea complicat, creezi câteva noi maşini virtuale, instalezi aplicaţia, le adaugi la grupul de instanţe existente şi începi să accepţi trafic cu noile maşini virtuale. Totul arată în regulă. A doua zi, lucrurile arată bine. Aplicaţia a făcut faţă evenimentelor de seara trecută. Focul a fost stins, dar jarul mai fumegă. Ştii că în curând se pregătesc evenimente care o să aducă şi mai mult trafic. Cel mai probabil capacitatea curentă o să fie insuficientă iar aplicaţia o să cedeze. Pe lângă asta, nu eşti singurul care a băgat de seamă. Proprietarii au fost deja informaţi şi îţi cer să iei măsuri.
În regulă, devine clar că trebuie să defineşti mai clar capacitatea în raport cu aplicaţiile care fac cereri aplicaţiei tale. Discuţi cu echipele care menţin aceste aplicaţii şi într-un final cădeți de acord asupra capacităţilor previzionate pentru cererile făcute de către fiecare aplicaţie client. Puse cap la cap, toate capacităţile nu depăşesc capacitatea grupului iniţial dar cu toate acestea aplicaţia s-a oprit din prima seară. Ceva nu este în regulă! În momentul de faţă nu ai nici un habar despre felul în care arată traficul servit de aplicaţia ta în producţie şi mai ştii că în viitorul apropiat proprietarii o să îţi ceară să adaugi funcţionalitate nouă. Singură soluţie pe care o ai în momentul de faţă este să adaugi noi maşini virtuale, dar totuşi aceasta nu reprezintă o rezolvare pe termen lung. Ai nevoie să înţelegi ce anume determină depăşirea capacităţilor. Trebuie să implementezi un mecanism care să îţi permită să măsori la nivel de operaţie numărul de cereri pe secundă efectuate de fiecare client în parte, pentru că în astfel de termeni se rezumă contractul care îl ai cu clienţii aplicaţiei tale. Acest mecanism trebuie să fie extensibil, flexibil şi performant.
Să analizezi fişierele de log-uri, dar aceasta nu reprezintă o soluţie prea performantă pentru că se bazează pe accesul la disc care este cunoscut a fi lent şi în plus nu prea îţi permite să iei decizii automate în cadrul aplicaţiei. Baza de date nu reprezintă o soluţie prea bună, pentru că asta ar urma să crească încărcarea pe setul de conexiuni pe care le foloseşte aplicaţia pentru a deservi cererile clienţilor. Ai putea să porneşti un serviciu HTTP care să publice valori despre capacitatea aplicaţiei, dar aceasta pare prea mult pentru că un astfel de serviciu în sine consumă resurse.
JMX pare un candidat bun. Ai nevoie de o instanţă singleton care să abstractizeze un simplu counter pe care să îl incrementezi constant la fiecare cerere pe care mai apoi să îl publici în serviciul de MBean-uri. În regulă, dar aceasta nu va reda forma traficului per operaţie ci mai degrabă traficul total la nivel de aplicaţie. De aceea, trebuie să instanţiezi abstracţia pentru fiecare operaţie chemată de clienţi. Lucrurile se complică pentru că o soluţie bazată pe counter-i individuali invocaţi peste tot prin cod nu o să se potrivească pentru prea multe operaţii noi pe care le vei adăuga în viitor. Trebuie să definesti o abstracţie care să encapsuleze toţi counter-ii şi care să ofere un API simplu de folosit, bazat pe identificatori. O astfel de abstracţie îţi permite să izolezi aplicaţia de viitoare noi funcţionalităţi. Sună bine! Implementezi, faci câteva cereri de test după care instalezi aplicaţia în producţie şi începi să urmărești valorile raportate. Arată bine.
Acum poţi să îţi dai seama câte cereri sunt servite de aplicaţia ta, însă scopul era să înţelegi care este numărul de cereri efectuate de către fiecare client aşa că următorul pas este să adaugi în abstracţia ta identitatea clienţilor care efectuează cererile. Pentru că anterior ai ales să abstractizezi counter-ii acum tot ce trebuie să faci, îi să adaugi un nou parametru care să identifice clientul pentru care se execută cererea. În interiorul abstractizării, trebuie să identifici counter-ul asociat clientului şi să îl incrementezi de fiecare dată când serveşti o cerere. Desigur nu ai vrea să implementezi cod care să cunoască apriori toţi clienţii. O astfel de soluţie este fragilă şi nu are un viitor prea îndepărtat.
Abstractizarea trebuie să fie suficient de generică astfel încât să poată să acomodeze orice invocare de operaţie din parte oricărui client fără a cunoaște în prealabil setul de clienţi și operaţiile invocate de către aceştia. Pare complicat, în special pentru că o astfel de abstractizare trebuie să fie împărţită între toate firele de execuţie ale aplicaţiei şi aceasta înseamnă stare mutabilă şi partajată. Ca structură de bază alegi un index ale cărui chei reprezintă concatenarea dintre identitatea clientului şi operaţia pe care o invocă. Valoarea indexului este counter-ul. Ca să rezolvi problema de partajare între firele de execuţie, de fiecare dată când ai nevoie să adaugi noi înregistrări în index, în loc să muți indexul existent, îl recreezi cu totul ca fiind o copie a celui existent la care adaugi noua cheie.
Da, o astfel de abordare are unele dezavantaje: este posibil ca până când toate cheile indexului sunt descoperite, unele counter-e să fie create de mai multe ori. Un alt dezavantaj este acela că până când se stabilizează indexul este posibil să pierzi unele cereri din cadrul numărătorii. Pe cealaltă parte, abordarea aceasta are beneficii în ceea ce priveşte performanţa şi anume structura indexului se poate baza pe o colecţie simplă HashMap care îţi permite să ai un acces la date rapid fără restricţii de concurenţă.
Acum că ai o idee despre cum să rezolvi problema extensibilităţii, ai mai vrea ca indexul de counter-i să devină un index de tranzacţii per secundă (TPS) pentru că în definitiv aceasta vrei să măsori. Acel counter pe care îl ai în momentul de faţă trebuie să fie resetat după fiecare secundă, ceea ce ar aduce o nouă problemă. Ca să resetezi counter-ul după fiecare secundă, ai nevoie să asociezi counter-ului momentul ultimei resetări, ceea ce înseamnă şi mai multă stare partajată. Pe lângă aceasta, mai ai nevoie ca de fiecare dată când numeri o cerere să verifici dacă este nevoie să resetezi counter-ul înainte de a-l incrementa. Nu sună prea bine! În loc să implementezi o astfel de abordare, ai putea să permiţi firului de execuţie care procesează cererea să incrementeze un counter-ul, iar pe un alt fir de execuţie să te asiguri că firele de execuţie care procesează cererile clienților accesează counter-ul resetat după fiecare secundă. Aceasta practic înseamnă să schimbi indexul de counter-i, cel pe care te-ai străduit să îl păstrezi semi-static pentru a-l păstra într-o colecție simplă de tip HashMap. Devine clar că indexul nu mai poate să conţină ca valoare un simplu counter. Valoarea indexului trebuie să fie un obiect complex care să reprezinte o listă circulară de counter-i, cu un numar fix de elemente ceea ce îţi permite să renunţi la elementele de control al partajării de stare. Capul listei va reprezenta counter-ul care se incrementează în secunda curentă, iar restul de elemente din listă va reprezenta counter-i reutilizabili în care s-au numărat cereri din secundele anterioare. Firul de execuţie care menţine counter-ii trebuie doar să modifice capul listei, iar pentru că vrei să refoloseşti counter-ii lista poate să fie imutabilă. Pe baza unei astfel de abordări, dacă expui în JMX capul listei, vei putea să monitorizezi numărul de cereri pe secundă deservite fiecărui client.
După ce implementezi și instalezi aplicația în producţie începi să monitorizezi valorile. După puţin timp totul se clarifică. Unul dintre clienţii aplicaţiei execută un număr de cereri cu mult peste contractul stabilit. Imediat ce observi aceasta reclami situaţia echipei de dezvoltare. După mai multe investigaţii se pare că aplicaţia client care cauza acest comportament are un defect ce cauzează depăşirea contractului.
Imediat după ce defectul este reparat se observă clar cum aplicaţia ta revine în parametri contractelor pe care le ai cu clienții tăi. Perfect, s-a rezolvat problema!
Oare chiar s-a rezolvat? Ce se întâmplă în momentul în care un astfel de defect reapare? Nu îţi poţi permite ca aplicaţia să se prăbuşească din nou în producţie. Ai nevoie ca atunci când un client depăşeşte limitele contractuale să refuzi procesarea. Pe baza implementării curente după ce incrementezi un counter , poţi să verifici dacă counter-ul a trecut de o anumită limita şi când aceasta se întâmplă să dai înapoi o eroare care să anunţe depăşirea limitei contractuale. Sună dur. Cu toate că nu vrei să ai clienţi care depăşesc limitele stabilite poate eşti în situaţia în care clientul tău depăşeşte limita, dar aplicaţia încă mai are capacitatea de a procesa cererea fără să afecteze performanţa oferită celorlalţi clienţi. Ai vrea ca atunci când decizi să refuzi procesarea unei cereri să o faci doar dacă ştii că procesarea ar avea efecte nedorite. Poţi să adaugi în decizia de a refuza o cerere si o condiţie simplă care verifică capacitatea disponibilă sau ai putea să mai verifici câte alte cereri curente aşteaptă să fie procesate. Ambele implică calcule şi partajare de stare aşa că dacă decizi să foloseşti o astfel de abordare, trebuie să validezi dacă avantajele sunt mai mari decât costurile.
Pe lângă această posibilitate de a scurt-circuita refuzarea cererilor, ai mai putea să îmbunătăţeşti implementarea actuală cu capacitatea de a nu refuza cereri când clienţii tăi ocazional trec de limita convenită cu un anumit mic procent. Aceasta înseamnă că atunci când iei decizia de refuzare de cereri trebuie să ai în vedere valoarea counter-ului curent şi de asemenea media pe ultimele câteva secunde. Desigur, aceasta implică mai multe calcule dar pentru eficiență calculele pot fi făcute o singură dată per secundă, iar locul ideal pentru aşa ceva este firul de execuție care menţine counter-ii curenţi. Acest fir de execuţie pe lângă responsabilitatea de a reseta un counter vechi ar putea să calculeze media cererilor pe secundă.
Voila! După ce ai implementat şi aceste ultime aspecte, ai ajuns să ai o implementare solidă de QoS - Quality of Service.
de Vlad Ciurca
de Ioana Rusu
de Ioana Varga