Rust a webfejlesztésben (is): hatékony API réteg Rust alapokon
A Rust programozási nyelvre leggyakrabban a C/C++ leváltójaként tekintenek, ami előbb-utóbb átveszi majd az uralmat a biztonságkritikus kernelek és alacsony szintű alkalmazások fejlesztésében. Hatékonysága és alkalmazásának sokrétű lehetőségei azonban jóval túlmutatnak a biztonságon, sokkal több területen predesztinálják sikerre. A webalkalmazások, azon belül az API rétegek fejlesztése esetén is jelentős potenciál rejlik a Rustban, kiváltképp ott, ahol a gyorsaság is nagy jelentőséggel bír.
Web- és mobilalkalmazások fejlesztése során csak a munka felét jelenti a felhasználó böngészőjében vagy mobiltelefonján futó frontend alkalmazások elkészítése. Ezek a felületek működésük során egy vagy több üzleti rendszerrel is kommunikálnak, így a kommunikáció sokszínűsége életre hívja a különböző middleware vagy backend for frontend (BFF) rétegeket. Több gyakori probléma merül fel, amikor egy frontend alkalmazást, valamint egy elsősorban üzleti folyamatok és logikák köré szervezett rendszert együttműködésre kell bírni.
Az egyik alapvető probléma, hogy a frontend fejlesztők egy egységes, pl. REST vagy GraphQL jellegű API-val szeretnének dolgozni, ami éppen csak annyi adatot, úgy és olyan formában ad vissza, amire és ahogy a frontend alkalmazás adott funkciójának szüksége van. Ezzel szemben az üzleti rendszerek változatos API-kat biztosítanak, túlságosan „bőbeszédűek”, és ha több rendszert kell integrálni, a protokollok is eltérhetnek. Gyakran teljesítményproblémák is felmerülnek, mert az üzleti rendszerek sok esetben nincsenek felkészülve rá, hogy több tízezer kérést válaszoljanak meg másodpercenként, következésképp az adatokat valahol cache-elni kell.
További problémaforrás lehet, ha különböző rendszerek adatait és folyamatait kell kombinálni és standardizálni. Például különböző rendszerek adják a felhasználó azonosítást, a járatinformációkat, a foglalásokat, miközben egy negyedikből a CRM rendszer ajánlatai jönnek, majd ezek egy CMS rendszerből hivatkoznak tartalmakat. Ezt a komplexitást frontenden kezelni nem tanácsos, már csak biztonsági szempontból is fontos, hogy az üzleti logika rejtve maradjon. Itt kap szerepet a BFF réteg (vagy más néven middleware), ami megvalósítja a közvetítő szerepet a frontend és az üzleti rendszerek között.
Milyen a jó BFF?
Ügyfélélmény szempontjából a legfontosabb elvárás lehet egy BFF réteggel kapcsolatban, hogy gyors legyen. Ez technikai szempontból azt is jelenti, hogy a lehető legkisebb válaszidővel dolgozzon, illetve a lehető leghatékonyabban kihasználja a rendelkezésre álló hardver erőforrásait. Fontos, hogy a közbeiktatott BFF réteg minimális mértékben növelje az ügyfél irányából az üzleti rendszerek felé érkező kérések kiszolgálásának késleltetését (latency).
Üzleti szempontból pedig fontos a biztonság: egyrészt maga a BFF réteg legyen a lehető legkevésbé sebezhető, másrészt az üzleti rendszereket is védje mind a túlterhelési kísérletektől (például hatékony cache-eléssel, az egyidejű kérések számának limitálásával), mind a rosszindulatú tevékenységektől (például a bejövő adatok ellenőrzésével, előszűrésével).
CI/CD-vel folytatódik az AWS hazai online meetup-sorozata! A sorozat december 12-i, ötödik állomásán bemutatjuk az AWS CodeCatalyst platformot, és a nyílt forráskódú Daggert is.
Egy BFF réteg fejlesztésekor az első fontos döntés a programozási nyelv kiválasztása. Szóba jöhet a PHP, Python, Java, C#, Go, JavaScript / TypeScript nyelvek bármelyike, mindnek megvannak a maga előnyei és hátrányai. Ehhez a sorhoz csatlakozott mostanra a Rust is, ami meredek választásnak tűnhet: egy meglehetősen új, alacsony szintű nyelv, beletanulni sem kifejezetten egyszerű.
Ugyanakkor a népszerűsége gyorsan nő azokon a területeken ahol fontos a teljesítmény és a biztonság: cloud szolgáltatók alapvető rendszereiket alapozzák rá (lásd Amazon Firecracker), a blockchain rendszerek fejlesztésénél kifejezetten gyakori választás és az operációs rendszerek fejlesztésébe is kezd beszivárogni. Az elmúlt néhány évben nagyon sokan kezdték el webes fejlesztésekre is használni, és kifejezetten komoly ökoszisztémája alakult ki ezen a területen.
Egy sportfogadási rendszer kihívásai
Nálunk a Mito Digitalnál a Rust használatához szükséges végső lökést egy sportfogadási rendszer megvalósítása adta meg. Ebben a rendszerben a fogadási események tízezreinek gyorsan változó adatait kellett kezelni. A felhasználóktól másodpercenként több ezer kérés érkezik, amit az üzleti rendszer nem lenne képes kezelni, hiszen nem erre tervezték. Ekkor születetett meg az igény egy egyedi webalkalmazásra, ami memóriában tárolja a sportesemények adatait, és közvetlenül onnan szolgálja ki a felhasználók kéréseit.
Az üzleti rendszertől csak naponta egyszer kérjük le a teljes esemény adatbázist, majd pár másodpercenként a változásokat. Az alkalmazásunk XML formátumban kapja meg az adatokat, ezeket feldolgozzuk, in-memory adatstruktúrákban tároljuk, majd a hatékony keresés érdekében több szempont szerint indexeljük: néhol egyszerű B-Tree indexekkel, néhol egy full-text search engine használatával (tantivy).
Történetünk érdekessége, hogy ennek a rendszernek az első verziója Go programozási nyelven készült el. Alapvetően két problémánk volt vele: az XML feldolgozása lassú volt, és az alkalmazás nagyon sok memóriát használt, feszegetve a rendelkezésünkre álló hardver határait.
Az új Rust alapú implementáció mindkét problémát megoldotta: mind az XML-feldolgozás ideje, mind a memóriahasználat töredékére csökkent.
A Rusttal való ismerkedés elég gyorsan ment, mivel alapvető struktúráiban hasonló a korábban megismert nyelvekhez (Go, C#, PHP, JavaScript). Igazi újdonságot a borrow checker jelentett. Ez a fordítási időben futó ellenőrzés biztosítja a Rust két egyedülálló tulajdonságát: egyrészt garbage collector nélkül képes biztonságos memóriamenedzsmentet végezni, másrészt lényegében kockázatmentessé teszi a többszálú programozást (fearless concurrency). A borrow checker megismerése, megszokása eltartott egy ideig, de miután ezen túljutottunk, komolyabb problémák nélkül tudtunk a fejlesztéssel haladni.
A Rust mind szinkron, mind aszinkron programozást lehetővé tesz. Az aszinkron programozás async/await kulcsszavai ismerősek lehetnek a C#-ból és a JavaScriptből, működésük is hasonló. A Rusthoz több különböző async runtime implementáció is létezik, mi a tokio-rs megoldása mellett döntöttünk, mivel erre épülnek az általunk preferált Warp és Axum webes keretrendszerek is. Az aszinkron programozásnak köszönhetően az alkalmazásnak csak néhány szálat (thread) kell párhuzamosan futtatnia: egy-egy szál felelős bizonyos háttérfeladatok elvégzéséért (mint például a sportfogadásos esetben az adatváltozások rendszeres letöltése), és nagyjából CPU magonként 1-1 szálat indít el a tokio runtime a befutó webes kérések aszinkron kiszolgálására.
Így jött képbe az ArcSwap
Egy többszálú környezetben komoly probléma lehet a közös adatokhoz való hozzáférés szinkronizálása. Ha véletlenül több szál próbálna egy időben módosítani ugyanazon a memóriaterületen, az nagy valószínűséggel rendszerösszeomláshoz vagy méretes biztonsági réshez vezetne. A legtöbb esetben ezt a problémát lock és mutex használatával kerülik el, így biztosítva hogy egy időben csak egy szál dolgozzon az adott adatszerkezettel.
Esetünkben az ArcSwap konstrukció segítségével a legtöbb esetben lockless módon tudtuk megoldani az ilyen versenyhelyzetek (race condition) kezelését, így a kliensek kéréseit kiszolgáló szálak és a háttérfeladatok egymás blokkolása nélkül tudnak dolgozni, teljesítményüket csak a rendelkezésre álló CPU teljesítmény limitálja.
Az ArcSwap egy olyan adatszerkezet, ami egy atomi műveletekkel lecserélhető pointeren keresztül hivatkozik egy másik adatstruktúrára. Maga a hivatkozott adatstruktúra attól a ponttól kezdve, hogy az ArcSwap szerkezetbe csomagoltuk, már csak olvasható állapotba kerül, így több szál is biztonságosan olvashatja párhuzamosan, zárolások nélkül. Ha az adatot módosítani kell, akkor először egy olcsó copy-on-write másolat készül róla, ott elvégezzük a szükséges módosításokat, majd egyetlen atomi CPU művelettel kicseréljük az ArcSwap-ban tárolt adatot az új verzióra, a régit pedig eldobjuk. Ilyenkor az eldobott adat által lefoglalt memória azonnal felszabadul, nem kell egy garbage collector futásra várni. Jelentős részben ennek köszönhetjük a memóriafelhasználás drasztikus csökkenését.
Az ArcSwapen túl nagyon sok előre implementált konkurrens hozzáférést biztosító adatszerkezetet lehet találni a crates.io-n. Ez a Rust csomagkezelőjének, a Cargo-nak a központi repository-ja, hasonló, mint az npm és az https://npmjs.org a JavaScript-nek, vagy a composer és a https://packagist.org a PHP-nak. Néhány példa: a crossbeam például a Go csatornáihoz hasonló kommunikációs csatornákat, a dashmap egy konkurrens hozzáférésű HashMap adatszerkezetet, az evmap egy lock-free eventually consistent HashMap megvalósítást biztosít.
Statikusan típusos programozási nyelvek esetén komoly probléma tud lenni, hogy az implementáció során sok boilerplate kódot kell előállítani. Ilyen lehet például amikor egy adatstruktúrát JSON-be kell konvertálni vagy onnan beolvasni, de ilyen az is, amikor egy REST API végpontot összekapcsolunk az URL routing réteggel. A legtöbb nyelv ennek kiküszöbölésére valamilyen annotációs megoldást használ, amiből akár runtime, reflexió (reflection) segítségével generálható a boilerplate kód.
A Rust erre a problémára deklaratív makrókat biztosít. A makrók a C nyelv makróihoz hasonlóan fordítási időben, a tényleges fordítási menet előtt Rust kódra fordulnak. Ez lényegesen egyszerűbb és kényelmesebb, mint például a Go nyelv go generate megoldása, és nincs runtime overheadje, mint a reflexiónak. A makrók segítségével például egy Rust adatstruktúra JSON oda-vissza konverziója egy-két egyszerű deklaratív makró megadásával megvalósítható (ld. serde_json). A makrók nagyon sok rutinműveletet leegyszerűsítenek a Rustban, ennek köszönhetően a fejlesztő a megoldandó feladatra koncentrálhat.
Egy mikroszolgáltatásokra épülő rendszerben nagyon fontos a megfigyelhetőség (observability). A tokio tracing library segítségével mind a loggolás, mind a nyomkövetés (tracing) könnyen megvalósítható. Mivel a nyelv statikus, itt nincs dinamikus runtime instrumentation, mint mondjuk a Java-ban vagy a C#-ban, de a makrók segítségével minimális munkával elhelyezhetők a loggoláshoz, méréshez szükséges kódrészletek. A begyűjtött adatok különféle adaptereken keresztül továbbíthatóak szinte bármilyen Application Performance Monitoring (APM) eszközbe. Például a log adatok ElasticSearch-be vagy Grafana Lokiba, a tracing spanek adatai bármilyen OpenTelemetry kompatibilis adatgyűjtőbe, a metrikák Prometheusba.
A Rust nagy előnye Kubernetes vagy serverless környezetben, hogy a belőle készülő futtatható állománynak nagyon kevés runtime függősége van.
Ha statikusan linkelhető musl libc környezetre fordítunk, akkor a containernek lényegében csak a futtatandó állományt, és néhány kiegészítő konfigurációt kell tartalmaznia (például timezone, locale adatok), így az egész container összesen néhány MB méretű lesz. A nyelv runtime overheadje nagyon kicsi, egy .NET vagy Java alkalmazáshoz képest egy Rustban írt alkalmazás pillanatok alatt elindul (ez például AWS Lambda cold start esetén is hasznos).
Mennyire gyors?
Ha egy in-memory adatstruktúrából kiszolgálható a válasz, és nem kell nagyméretű JSON-t generálni, akkor nagyjából egy webszerver statikus állomány kiszolgálás sebességével összevethető: több tízezer kérést tud kiszolgálni másodpercenként, néhány milliszekundum válaszidővel. Ha bonyolultabb a válasz előállítása, vagy nagyobb JSON választ kell kigenerálni, akkor ez eshet néhány ezer kérés/másodperc tempóra egy 4 vCPU méretű gépen, és a válaszidő is inkább lehet a 10-100ms nagyságrend környékén. Ha nem lehet memóriából kiszolgálni a kérést, akkor pedig már nem a Rust alapú alkalmazás, hanem a meghívott backend szolgáltatás válaszideje lesz a döntő tényező.
A konkurens kérések száma az aszinkron működés miatt nem okoz problémát: egy-egy bejövő kapcsolat csak minimális mennyiségű memóriát fogyaszt, előbb lesz probléma például a socketek által elfoglalható file descriptorok elfogyása, mint a memóriahasználat.
Összességében nekünk nagyon pozitív élmény a Rust, közel sem olyan nehéz használni, mint amennyire elsőre tartottunk tőle.
Hiányosságok persze még vannak: a nyelv fiatal, ezért jóval kevesebb kiforrott eszköz áll hozzá rendelkezésre, mint mondjuk a C# vagy Java esetében. Rust fejlesztőt találni sem egyszerű még, mi is inkább házon belül képzünk át más nyelvekről backend fejlesztőket. Szerencsére a nyelv népszerűsége a TIOBE index és GitHub adatai szerint is gyorsan nő, így ezek a problémák idővel remélhetőleg megoldódnak.