Naše cesta s Cache storage

21.02.2025

Říká se, že jedna ze dvou těžkých věcí při programování je invalidace cache. Což je pravda, ale o tom tento článek nebude... Chtěl bych zde popsat spíš, jak jsme se při migraci našich aplikací z on-prem serverů do AWS EKS popasovali s problematikou storage pro cache.

Konfigurace cache

Výchozí stav byl takový, že hlavní aplikace Webnode 2 SiteBuilder a starší Webnode 1 používali na vše FileSystem cache ukládanou na disk příslušného virtuálního serveru, na kterém měl uživatelův projekt (tzn. jeho webové stránky) i sdílený http server a mysql databázi. Těchto virtuálních serverů jsme měli asi 350 ve třech serverovnách a v DNS byl pro každý projekt záznam, na který server má http provoz směrovat. Implementace cache v PHP byla v nějaké legacy třídě bez možnosti jednoduše změnit driver.

Kromě toho máme portál (www.webnode.com), nějaké mikroslužby a další mini aplikace. Tyto aplikace používaly pro cache náš self-hosted Aerospike cluster běžící na 3 nodech na našich virtuálních serverech. Implementace cache zde byla lepší. Máme interní webnode/cache balíček používající cache adaptéry ze známé php knihovny cache/cache (www.phpcache.com) a kompatibilní s PSR-6/PSR-16. Ovšem pro Aerospike stejně máme napsaný vlastní adaptér, protože knihovna ho neobsahuje.

Po rozhodnutí, že všechny naše aplikace budeme provozovat v kontejnerech orchestrovaných přes Kubernetes v AWS EKS, jsme věděli, že filesystémovou cache budeme muset nahradit. Abychom to mohli (kvůli postupné migraci) provést tak, aby na starém řešení stále byla FileSystem cache a v cloudu jiná, bylo jasné, že budeme muset implementaci cache aktualizovat tak, abychom mohli mít různé adaptéry pro různá prostředí. W1 a W2 jsme tedy dopředu refaktorovali na využití našeho webnode/cache balíčku stejně jako u ostatních aplikací. To bylo první dobré rozhodnutí, které se nám později velmi vyplatilo... Nyní jsme mohli v DI kontejneru podle ENV proměnných použít různé adaptéry pro cache.


	

Aerospike

Když jsme začali dělat první POC v kontejnerech v K8S, chtěli jsme všude pro cache využít technologii kterou už známe. Tzn. Aerospike, který si budeme v k8s provozovat sami v kontejnerech. To bylo špatné rozhodnutí...

V roce 2021, ještě na on-prem, když se dělalo rozhodnutí, jestli používat Aerospike, nebo Redis, vyšlo z analýzy a benchmarku, že se bude používat Aerospike, protože je "dvojnásobně výkonější" na read/writes za sekundu, než Redis, a Redis je "in-memory only". Aerospike umí mít v paměti jen index a data na SSD disku. Což je pro nás nutné. Potřebujme mít v cache cca 500GB + 32 GB pro php session. To se jen do RAM ukládat nedá.

Aerospike jsme rozjeli v K8s jako StatefulSet ve 3 replikách a připojili GP2 volumes pro data. Při testech v testovacím i produkčním prostředí na vybraných projektech vše fungovalo skvěle. Oproti Filesytémové cache jsme taky měli konečně k dispozici i metriky a grafy pro velikost a počet klíčů, cache hit rate, stav replikace apod..

Po pár měsících provozu a postupného migrování dalších a dalších uživatelských projektů (webových stránek), se v logu začaly objevovat náhodné několikaminutové až hodinové nedostupnosti připojení k Aarospike clusteru. Narazili jsme na následující problémy.

  • Aerospike nodes rozjeté na spotových instancích se často vypínaly a přesouvaly na jiné k8s nody, což vynutilo synchronizaci dat, která daný Aarospike node znepřístupnila. Pokud se to stalo s více nody ve stejný čas, nastal problém. Občas se to ani samo nevzpamatovalo a data cache se musela ručně zahodit a vše restartovat. Aerospike jsme zkusili "zakotvit" jen na on-demand nodech, aby se tolik nepřesouval. Ale bohužel, ani to nebylo stoprocentní.

  • Při těchto synchronizacích, ale i při běžném provozu, byl vysoký traffik mezi Availability zónami (EC2 Others - regional bytes), který nás stál nemalé peníze.

  • Potřebovali jsme více datových namespaců, než 2, které free self-hosted Aarospike dovoluje.

  • Aerospike spotřeboval i hodně Memory resources (především pro index a také session jsme měli jen v in-memory uložišti), což vyžadovalo větší instance za více peněz...

Proto jsme se rozhodli uložiště cache nahradit. Alespoň pro "velkou" cache. Session jsme zatím nechali v Aerospiku v in-memory uložišti bez disků.

FSx for Lustre

Po problémech s připojenými disky, synchronizací a cross-zone traffikem jsme chtěli jít spíše do nějaké managed služby od AWS. Kolega přišel s nápadem využít AWS FSx for Lustre sdílený filesystém a vrátit se k "ověřené" filesystem cache. Produktová stránka AWS uváděla informaci o vysokém throughput ve stovkách GB/s a miliony IOPS. Cena za dostatečně velké úložiště byla také přívětivá, a tak jsme to zkusili.

V době, kdy jsme tento filesystém využili, stále nebyla dokončená migrace všech projektů. Ze začátku vše šlapalo skvěle. Cache fungovala, byla dostatečně rychlá a přijatelně drahá. Ovšem jak počty projektů přibývaly (řádově se bavím o milionech webů), po čase jsme začali narážet na problémy:

  • Filesytém nemá žádné TTL. Takže cache klíč, který se zapíše jako soubor na disk, tam zůstane, dokud ho někdo už jako expirovaný nepřečte a nenahradí. Pokud se tak nestane, je tam navždy. Proto jsme museli napsat vlastní garbage collector - process, který filesystém procházel a staré soubory pravidelně mazal. Což bylo extrémně pomalé a FS to značně zatěžovalo.

  • Filesystém začal mít příšerné latence v řádech vteřin. To samozřejmě, jako každý jiný delay, způsobilo frontování nedokončených HTTP requestů na Nginxu a uživatelé čekali na http odpovědi nepřijatelně dlouho.

Byla to krizová situace, a museli jsme rychle vymyslet lepší řešení.

Elasticache for Redis

AWS má službu přímo pro cache. Jmenuje se AWS Elasticache a umožnuje rozjet managed instance Redis nebo Memcache (v dnešní době už i Valkey). Redisu jsme se vždy vyhýbali hlavně kvůli tomu, že potřebujeme do cache ukládat více dat, než se může vejít jen do RAM (za rozumnou cenu). Memcache má stejný problém.

Situaci změnilo, když jsme si všimli, že AWS do svého Redisu přidalo funkci "Data tiering", která dělá přesně to, co potřebujeme! Ukládá data na SSD disk a v RAM má jen naposledy použité klíče. Vyměnit v aplikaci Cache Adapter za Redisový (který existoval v balíčcích z www.phpcache.com) byla už rutina. Vytočit instance a vyzkoušet Redis, nejprve na testingu a pak pro pár procent produkčních uživatelů, byla také otázka chvilky.

Po postupném přepnutí cache všech aplikací jsme do Elasticache for Redis přesunuli i PHP session w1, w2, portálu, oauth2 serveru a dalších aplikací, abychom už Aerospike nemuseli vůbec provozovat a časem jsme ho zrušili úplně.

S Redisem jsme na žádný zásadní problém nenarazili. Chvíli jsme ladili politiky eviction záznamů (jak se chovat při zaplnění cache).

Taky jsme udělali optimalizaci cross availability zone trafficu. Chtěli jsme docílit, aby containery používaly pro čtení Redis node ve stejné availability zoně. Stačilo na to vytvořit vcelku geniálně jednoduchý cache adapter, který "set" a "del" posílá na Write endpoint, ale "get" čtení na endpoint redis nodu ve stejné AZ, jako beží k8s node s tímto kontejnerem. Využívá to toho, že AWS má write a readonly cluster endpointy, které na urovni DNS správně řeší failover při přehození primary (master) na jiný node, ale taky mají endpointy pro jednotlivé nody clusteru, nezávisle na jejich roli v clusteru.

Tyto endpointy jsou přes ENV předány do konfigurace Adaptéru. Adaptér si sám zjistí, ve které availability zóně container aplikace běží přes EC2 instance metadata API placement/availability-zone a informaci si uloží do temp souboru, který vydrží jen po dobu životnosti k8s podu, aby každý nový pod toto AWS EC2 instance metadata api volal pouze jednou.


	

Tento cache storage už nám zůstal až do teď.

Aktuální rozdělení cache

Zatím finálním řešením se tedy stal Redis managovaný AWS na instancích s tzv. data tieringem. Aktuální setup vypadá takto:

  • 2x instance cache.r6gd.2xlarge (8 CPU, 52GB RAM, 199GB SSD) pro cache projektů W2 a W1 (každá samostatně)

  • 2x instance cache.m7g.xlarge (4 CPU, 12GB RAM) pro cache všech ostatních aplikací (v clusteru, každý node v jedné AZ)

  • 2x instance cache.r6gd.xlarge (4 CPU, 26GB RAM, 99GB SSD) pro session projektů, portálu a dalších php aplikací (v clusteru, každý node v jedné AZ)

Samotnou cache v aplikaci máme rozdělenou podle účelů. Např. W2 SiteBuilder má několik vrstev cache:

  • Primární cache domény (doména -> id projektu)

  • Cache stránek (url -> rendered HTML)

  • Cache dat z DB (query -> data)

  • Cache dotazů na mikroslužby (request -> response)

V Redisu jsou pak uloženy ve zvláštních databázích (dá se využít až 16 databazí indexovaných čísly).

Tento setup je kompromis mezi cenou instancí, high availability a cenou za cross availability zone traffic. Zatím jsme s tím takto spokojeni a vše funguje perfektně.

Lessons learned

A co jsme si ze všech těchto cvičení odnesli?

  1. Adaptéry jsou nutná věc. A nejen pro cache. Infrastruktura se může během let (v našem případě i měsíců a týdnů) měnit a nemělo by to vyžadovat větší změny v aplikaci než změnu konfigurace Dependecy Injection Containeru. S možnými změnami infrastruktury výborně pracuje i celý koncept Hexagonální architektury, kterou ve Webnode využíváme a postupně chceme zavádět i do starších aplikací...

  2. Nepodcenit Perfomance/Load testy či benchmarky. Z tohoto našeho case-study je vidět, že se nám nejednou stalo, že na testingu, stagingu i v produkci při části cílového workloadu vše může fungovat bez problémů, ale až při nasazení na 100% workload můžou nastat různorodé síťové a performance problémy. V našem případě se bohužel produkční zátěž simuluje opravdu špatně a stále pro to nemáme žádné dobré ustálené řešení. Máme rezervy a je zde co vylepšovat :)

  3. Používat managed řešení. Hostovat vlastní Aerospike či Redis sice lze, ale vyžaduje to hodně úsilí a znalostí. Když už chcete nějaké storage řešení prvozovat v K8s, je vždy lepší použít nějaký operátor. Vůbec nejlepší je ale použít cache řešení, které vám váš Cluster provider nabízí. Pročíst si pořádně dokumentaci a pricing pro AWS ElastiCache měl být první krok...

Nedávno v AWS ElastiCache přibyla možnost Valkey cache instancí, které v AWS také podporují data tiering a jsou o něco málo levnější než Redis. Možná ji taky někdy vyzkoušíme.

A ano. S invalidací cache bojujeme stále stejně 😁