Zero Downtime Releases

Lukáš Vyhlídka
zonky-developers
Published in
10 min readApr 27, 2018

Když jsem v Zonky před rokem a půl začínal, nic jako bezvýpadkové nasazování nás netrápilo. Potřeby z hlediska vývoje byly zcela jinde a nové verze platformy se vydávaly jednou za 14 dnů s hodinovým odstavením systému. Release jednou za čas — přestože je to týden či dva — znamená, že se vždy dostává na světlo světa spousta nových věcí. Všechno se musí k určitému datu stabilizovat a otestovat. Často se stávalo, že QA inženýři byli v den releasu zavaleni kupou testování a celý release v ohrožení v případě nálezu závažné regrese. Idea releasu kdykoliv si člověk zamane přináší tu výhodu, že je možné nasadit ne v době, kdy se to dlouho dopředu naplánovalo, ale kdy je daná věc připravená a řádně otestovaná bez ohledu na stav dalších rozpracovaných věcí.

Když jsme se tedy rozhodli, že bezvýpadkové releasy jsou na pořadu dne, museli jsme zvolit cestu, kterou se vydáme. U nás se hodně probíralo řešení v podobě Kubernetes (K8s), které by nám pomohlo nejen s bezvýpadkovým nasazováním, ale i dalšími věcmi jako lepší utilizace hardware, škálování / failover, atd. Volba je to rozumná, nicméně jsme se rozhodli, že se touto cestou zatím nevydáme.

Naše aplikace totiž nebyla zcela připravená na běh ve více instancích, což je pro bezvýpadkové nasazování v podstatě nutnost. Jelikož jsem již jednou zažil projekt, kde se aplikace předělávala na cluster variantu a rovnou se nasazovala do K8s, neměl jsem z toho dobrý pocit. Asi tušíte, že to v rámci mé zkušenosti nedopadlo dobře — často něco nefungovalo a bylo složité řešit, zda je to problém aplikace jako takové, problém v našem používání K8s, či co…

Nakonec jsme tedy zvolili variantu, že uděláme bezvýpadkové nasazování na naší stávající infrastruktuře, ideálně se současnými nástroji. Tam si aplikaci hezky vyladíme a následný přechod na K8s již bude “jednoduchý”.

Naše infrastruktura

Infrastruktura byla popsaná v 500% zrychlení build procesu, ale pro účely tohoto článku to probereme ještě jednou.

Aplikace se nahrubo skládá z jednoho monolitu (cca před rokem to byla naše jediná komponenta a jméno již zůstalo), který je obklopen několika mikroslužbami. Mikroslužby jsou založené na Spring Boot, monolit je potom samostatné WARko nasazované na Tomcat s tím, že všechny tyto části jsou součástí jednoho Git repozitáře a jako buildovací nástroj je použit Maven. Pro komunikaci mezi našimi komponentami používáme REST. Z mikroslužeb je jedna dejme tomu speciální, jmenuje se API-Gateway a chodí přes ní všechny volání naší aplikace zvenku. Vidět by to být mělo na následujícím obrázku.

Příklad infrastruktury

Náš integrační nástroj je Jenkins s tím, že se snažíme (alespoň u těch hlavních částí to tak je) používat joby v podobě Jenkins Pipelines, uložených spolu s aplikací v našem Git repozitáři. Pro automatizaci nasazování používáme Ansible.

V rámci vývoje používáme GitFlow. Vývojáři při práci vytvářejí feature větvě, které jsou potom pomocí pull requestů (PR) mergovány do vývojové větve s názvem next. Když je čas na release, udělá se z nextu releasovací branch, která se po stabilizaci a otestování mergne do větve master. Master větev tedy představuje stav, který je na našich produkčních serverech. Pro správu našich ticketů používáme Jiru.

Technická příprava

K tomu, abychom byli schopni nasadit novou verzi aplikace bez jejího výpadku bylo nutné mít od každé služby více než jednu instanci s tím, že se budou nasazovat postupně. Nasadí se nejprve jedna a když je úspěšně dokonáno, jde se na druhou instanci atd. HTTP požadavky na danou službu musí být chytře směřovány (load balancing) mimo ty instance, které se právě aktualizují.

Jelikož máme dva typy služeb z pohledu infrastruktury middleware — mikroslužby jsou postavené nad Spring Boot zatímco monolit není (samotný přepis bude tématem jiného článku)— použili jsme i dva typy load balancingu.

Abychom byli schopni instance služeb před jejich nasazením odstranit z load balanceru, tedy odklonit provoz, využili jsme funkce Health Checků. Ty jsou ve Spring Bootu již přítomny (v monolitu je máme implementovány ručně). Před plánovaným vypnutím instance se označí jeden health checků jako “špatný”. Pak už stačí počkat nějakou chvíli (load balancery například jednou za 10s ověří, že je vše v pořádku) a může se jít na věc.

S load-balancováním jsme měli spojenu ještě jednu věc. Instance rozeseté po více serverech znamená, že spolu komunikují přes síť a tudíž začne docházet k chybám. Řešili jsme co se stane, když jedna ze služeb v celém řetězu zpracování HTTP požadavku neodpoví? V takové chvíli si nemůžeme být zcela jistí, zda byl požadavek zpracován a pouze nám kvůli chybě na síti nedošla odpověď, či zda požadavek nebyl vůbec zpracován.

Pokud bychom se k tomu nijak nepostavili, vznikaly by nám zcela jistě nekonzistence. Řešit se to dá různě (např. vícefázový commit či kompenzační transakce). Asi nejlepší řešení je nemít mezi jednotlivými službami žádné transakční chování. Bohužel to nebyl náš případ. My jsme po prozkoumání našich endpointů zjistili, že se můžeme vesměs opřít o tzv. idempotenci — pokud zavoláme endpoint se stejnými vstupními daty vícekrát, efektivně se změna v systému aplikuje pouze jednou. Díky tomu jsme mohli na našich load balancerech nastavit automatické opakování akce (retry) v případě, kdy nějaké volání nedopadne dobře.

Aby to nebylo zase až tak ideální (jistě jste si všimli slova “vesměs”), ne všechny naše endpointy tuto idempotenci splňovaly — typicky POST operace takové nejsou. Takovéto endpointy jsme tedy z onoho automatického opakování vyňali s tím, že pokud nastane chyba právě v tomto kroku, oznámíme takovou situaci uživateli, kterého necháme celou akci jednoduše opakovat. Toto jsme si mohli dovolit proto, že v našem případě neidempotentní operace sice může vytvořit nějakou datovou entitu, kterou dále již nikdo nepoužívá (sirotek / orphan), ale nevznikne nám žádná vážná datová nekonzistence.

Pokud by nám při takové operaci vznikla situace, že by se např. zdvojily peníze na účtech našich uživatelů, určitě bychom si to dovolit nemohli. Zde bych rád apeloval na to, abyste nikdy nepodceňovali diskuse ohledně návrhu (RESTového) API a nebrali připomínky kolegů (s tím, že tato operace není RESTful, atd.) na lehkou váhu…

Když jsme měli load balanované služby ve více instancích hotové, zbývalo nám z pohledu infrastruktury vyřešit nasazování jedné instance dané služby po druhé. Použili jsme pro to Ansible, který se nám již nějakou dobu osvědčoval pro automatizaci deploymentu infrastruktury. Pro bezvýpadkové nasazení každé naší služby jsme udělali jeden Ansible playbook (skript zjednodušeně řečeno), který dělá zhruba následující:

  • Ověří, že jsou všechny instance dané služby zdravé
  • Přes HTTP management rozhraní healthchecku nastaví jednu instanci služby jako “down”
  • Počká, než všechny load balancery danou instanci odstraní
  • Shodí instanci
  • Stáhne novou verzi služby z Jenkinse a nasadí ji
  • Spustí instanci
  • Počká, než instance naběhne a prozradí o sobě přes healthcheck, že je zdravá
  • Přejde na další instanci

Zpětná kompatibilita

Při bezvýpadkovém releasu nastává okamžik, kdy jedna instance běží již v nové verzi, zatímco druhá běží ve verzi staré. Jejich okolí s oběma instancemi komunikují transparentně bez ohledu na verzi. Pokud by například nová verze přejmenovala jeden ze svých endpointů (například /loan/{id} na /loans/{id}) nastal by problém neboť okolí, které je zvyklé komunikovat pomocí starého endpointu, by najednou komunikovat nemohlo. Tento příklad je nekompatibilní změna. Někdy je ovšem nutné nekompatibilní změnu udělat. V takovém případě se na to musí jít postupně v několika krocích, kdy každý krok znamená samostatný release.

V prvním kroku je potřeba vytvořit nový endpoint s tím, že starý bude fungovat tak, jako doposud — nová instance bude podporovat obě varianty endpointů. Když je celé bezvýpadkové nasazení hotovo (všechny instance podporují obě varianty), je nutné pozměnit okolí naší mikroservicy tak, aby začalo využívat výhradně nový endpoint. Tato změna lze provést dalším bezvýpadkovým releasem, tentokrát však ostatních služeb, které volají službu naší. Nakonec, když je i tato fáze hotova, můžeme udělat další bezvýpadkové nasazení naší servisy, které odstraní onen původní, již nepoužívaný, endpoint.

Příklad změny endpointu pomocí bezvýpadkového nasazování

V našem příkladě je vidět, že vývoj pro bezvýpadkové nasazování je občas zdlouhavější, než to s výpadkem. Je to daň za to, že máme systém celou dobu připraven odpovídat našim zákazníkům.

Výše zmíněný příklad byl příkladem, kdy je potřeba dělat zpětně nekompatibilní změnu v URL našich endpointů. Dalším takovým příkladem je přejmenování nebo odebrání některého z datových polí přímo v payloadu (např. prvek JSON objektu). Řešit se to dá opět stejným způsobem, jako v našem příkladě. Přidání dalších dat (nový field v nějakém existujícím JSON objektu) je obecně bráno jako zpětně kompatibilní změna. Je však důležité, aby nová instance služby počítala s tím, že její okolí nebude s tímto novým parametrem pracovat (dá se to brát jako null hodnota). Pokud tedy služba přidá do objektu, který se používá jako request při volání endpointu nový parametr, musí počítat s tím, že tento parametr nemusí být přítomen.

Další podstatnou věcí, kdy můžeme narazit na zpětně nekompatibilní změny je databáze. Naše služby používají pro jednotlivé instance (od každé servicy máme 2 instance) společnou databázi a opět — pokud by nová instance například smazala nějakou z tabulek, nastalo by zcela jistě fiasko (ta stará by ji ráda používala). Řešení těchto změn je však opět de facto stejné. Je to tedy jednodušší jen v tom, že opravdu stačí danou službu přenasadit 2x za sebou — nemusí se řešit okolí, protože databáze samotná je od okolí dané mikroservisy odstíněna.

Upravené GitFlow

Technickou stránku bezvýpadkového nasazování máme hotovou. Byli jsme schopni pustit Ansible, který vezme JAR z Jenkinse a bez výpadku to všechno nasadí. Bohužel jsme ještě neměli vyřešeno:

  • jak poznat co se nasadilo
  • kdy se to nasadilo
  • kdo danou změnu udělal a co všechno obsahovala
  • kdo to celé otestoval (je třeba Bejkovce)

Nebudu se zde věnovat tomu, jak jsme se pomalu dostávali k momentálnímu stavu, raději se na to vrhněme.

GitFlow jsme pouze vylepšili pro naši potřebu, takže základ, jak jsem ho popisoval v úvodu, v podstatě zůstal — onen starý způsob vývoje (tedy vlastní větev vytvořená nad větví next, která se merguje přes PR opět do nextu) jsme nazvali Slow-Track a nový, na který se vrhneme nyní, jsme nazvali Fast-Track. Fast-Track se liší v tom, že se větev pro vývoj nevytváří z větve next, ale přímo z master větve s tím, že PR se poté merguje opět přímo do master větve. Jelikož master větev představuje stav naší produkce, pak je jasné, že změna se rovnou nasazuje.

Kdybychom v tomto stavu skončili, nebylo by to zcela bezpečné, protože merge přímo do masteru bez řádného otestování smrdí po problémech. Z tohoto důvodu jsme v rámci naší Jenkins pipeline, která spouští testy v rámci daného PR, vytvořili jistý blok kódu obalený IFem. Tento IF jednoduše řečeno zjišťuje, jestli se nejedná o Fast-Track PR (na základě cílové a zdrojové větve) a pokud ano, vytvoří po úspěšném vykonání testů Dockerizované prostředí naší aplikace na nové AWS EC2 instanci.

Taková instance je potom použita pro testování, že všechno správně funguje — s tímto zpravidla pomáhá QA inženýr týmu, který nasazuje. Merge PR do master větve potom proběhne až po té, co je jednak potvrzena správnost kódu některým z PR reviewerů a jednak je potvrzena funkčnost na onom testovacím prostředí.

Git branch model

Po té, co se provede merge do masteru už vlastně přichází ona technická stránka věci (viz Technická Příprava). V současnosti pustíme Jenkins Job, který vezme poslední commit z masteru, provede kompletní build a následně spustí Ansible Playbook pro jednotlivé služby, které je potřeba přenasadit. Pokud se ptáte, jak Jenkins pozná, které služby je třeba nasadit, za chvilku to zmíním…

Stage prostředí

Při bezvýpadkovém nasazení často hrozí, že něco nedopadne dobře. Někdo pozmění něco v Ansiblu a ejhle. Někdo v samotné změně napíše špatně migraci databáze a ejhle. Někdo špatně nakonfiguruje Spring kontext a jejda. Stát se může spousta věcí. Abychom si tohle všechno neladili přímo na produkci, vytvořili jsme si prostředí, pracovně nazvané jako Stage.

Stage prostředí by mělo před samotným bezvýpadkovým releasem co nejvíce odpovídat produkčnímu stavu s tím, že se samotný akt nasazení zkusí právě tam. Když všechno dopadne dobře, můžeme si být o trošičku jistější, že by to mohlo dopadnout dobře i na produkci. Jistota to bohužel není stoprocentní, ale pořád je to lepší, než drátem do oka. Když k tomu přidáte pravidelné modlení se a přinášení obětí všem IT bohům (nějaká ta starší Raspberry Pi se snad najde), mohlo by to fungovat.

Jira

V Jiře jsme udělali pár změn, aby nám naše tickety reflektovaly, zda jsou řešeny Slow či Fast Trackem a aby v obou případech bylo jasné v jakém stavu se nachází. Největší změna se dotkla stavů, kterými jednotlivé tickety prochází. Nejjednodušší asi bude podívat se na následující graf přechodů.

Graf přechodu stavů ticketů v Jiře

Původní stavy New, Open, In Progress, Ready for QA, QA in Progress a Closed (nebudu je moc rozebírat, je to taková klasika) jsme obohatili o dalších pár kamarádů. Z QA in Progress se dá vydat dvěma směry. U Slow Track ticketu se v případě, že vše funguje, klasicky přesuneme do známého stavu Closed. Pokud se ale jedná o Fast Track ticket, přesune se stav na Ready For Stage. Tím dává člověk, co danou věc testoval, najevo, že se může začít s bezvýpadkovým nasazením, tedy s mergem daného PR do master větve a spuštěním Jenkins jobu, který se postará o nasazení samotné. Další stavy jsou potom z obrázku očividné a jsou přepínány automaticky Jenkins jobem, který v různých fázích přepne ticket do příslušného stavu.

Kromě stavů jsme tiketům přidali pár nových fieldu. Prvním je služba (inteligentně schováná pod field Component), kterou používáme pro manuální nastavení jména služby (nastavuje autor změny), kterou má Jenkins job přenasadit. Druhým fieldem je commit hash, který nastavuje Jenkins job při releasu.

Díky tomu jsme schopni zjistit kdo, co a kdy dostal do produkce. Jeden ticket představuje jedno nasazení tedy jeden úspěšný merge do master větve. V případě nějakých oprav se vytváří další tiket.

Závěrem

Bezvýpadkové nasazování jsme v Zonky dotáhli do stavu, který nám funguje jak technicky, tak procesně. Neznamená to ale, že je vše dotaženo k dokonalosti. Snažíme se vše dělat iterativně a tak se stále najdou místa, která by se dalo vylepšit. Namátkou jich pár zmíním:

  • Ruční nastavení měněných služeb v Jira
  • Ruční spuštění Jenkins Jobu pro nasazení
  • Jedno Stage prostředí představuje úzké hrdlo při souběžném nasazování
  • Ruční rollback stavu Stage prostředí při chybě

I přes tyto nedostatky to pro nás byla veliká změna k lepšímu. Pokud by Vás na toto téma zajímalo něco víc, napište nám, nebo se k nám rovnou zajděte podívat a pokecat. Třeba nám s tím nakonec budete chtít i pomoci, noví zvídaví kolegové a kamarádi jsou vždy vítáni…

--

--