pondělí 21. dubna 2014

Postupný rollout

Kolikrát už jsem jenom zažil tu situaci. Mám testy, kterým věřím, jsem skálopevně přesvědčený, že tahle změna nemůže nic rozbít, a pak se nakonec ukáže, že přece jenom rozbila. Tohle není litanie proti testům, v tomto článku se pokusím o zamyšlení nad tím, že kromě baterie testů, kterým věříte, potřebujete i způsob, kterým minimalizujete škody, které způsobí neznámé neznámo. Článek jsem zařadil do kategorie cynického software, protože účelově testovat na uživateli mi přijde dosti cynické. Nakonec drobná míra cynismu není na škodu, pokud je to win-win strategie.

Neznámé neznámo

Přihlašování je jedna z mála věcí jakéhokoliv systému, která když se pokazí, vede k jeho nedostupnosti. V GoodData máme několik způsobů, přes které je možné dělat Single Sign On (SSO) tj. uživatel se přihlásí v mateřském systému a jeho identita se propaguje dále do GoodData, kde nevyžadujeme již její ověření uživatelem. Výsledkem je, že uživatel se přihlásí právě a jenom jednou. Starší způsob je založený na tom, že Identity provider posílá podepsanou a šifrovanou zprávu - JSON dokument. JSON obsahuje login uživatele a validitu (timestamp platnosti), tedy do kdy platí GoodData autentizační kontext vydaný na základě této zprávy.

{"email": "user@domain.com","validity": 123456789}

Z důvodů většího zabezpečení jsme tuto strukturu rozšiřovali o dva nepovinné atributy (notBefore, notOnOrAfter), definující platnost samotné zprávy. Úplně původně byl kód obsluhující tento typ přihlášení napsaný v Perlu. Při přepisu do Javy se JSON parsoval ručně. Při rozšiřování jsem nechtěl mít v kódu takovou věc, jako je ruční pársování JSONu. Čuchal jsem trochu problémy se zpětnou kompatibilitou a proto jsem v kódu nechal původní metodu jako fallback. Kód vypadal následovně.

try{
 return parseStrictly(loginSessionMessage, defaultNotBefore, defaultNotOnOrAfter);
} catch(IOException e) {
 log.info("Cannot parse login message '{}'. Using fallback method for parsing its content.", loginSessionMessage);
 return parseQuirkly(loginSessionMessage, defaultNotBefore, defaultNotOnOrAfter);
}

Pokud by kód nebyl validní JSON, Jackson knihovna na práci s JSON by vyhodila potomka IOException. S důvěrou jsem tenhle kód nasadil na produkci. První problém se objevil po pár hodinách a znamenal, že jsme museli celou funkcionalitu revertovat. Na vinně byl následující řádek uvnitř metody parseStrictly.

final Long validity = (Long) map.get(VALIDITY_FIELD_NAME);

Bohužel někdo občas poslal v JSONU místo čísla řetězec a fallback catch blok nechytil ClassCastException. Největší potupa byla poslouchat jízlivé poznámky vedle sedících Perl programátorů, že tohle by se jim v Perlu nestalo.

Tohle je jeden z mnoha případů, kdy by ani sebelepší code review nebo testování nepomohlo. Všechny testy operují nad množinou stavů, která je vám předem známá. Pokud tedy nepočítáte s nějakým stavem, těžko ho můžete otestovat. Jedná se o takzvané neznámé neznámo. V tomhle případě jsme problém mohli odhalit jenom ve chvíli, kdy jsme kód vypustili na produkci.

Tak trochu jiné testování na uživateli

Vývojáři občas, při malém pokrytí testy, utrousí, že testují na uživatelích. To je samozřejmě špatný přístup. Někdy ovšem neexistuje jiný stejně efektivní způsob a nebo vůbec cesta, jak prozkoumat neznáme neznámo. V jakémkoliv software je alespoň jeden neodhalený bug a testy jsou jenom pomůckou k objevení toho zbytku. Pokud přistoupíte na tento fakt, a já se obávám, že nic jiného nám ani nezbývá, musíte nutně dojít k tomu, že je potřeba minimalizovat škody, které může tento bug nebo bugy napáchat. Jednou z technik, která k tomu pomáhá je postupné uvolňování nových vlastností nebo verzí.

Myšlenka je to velmi jednoduchá. Namísto toho, aby se k nové verzi dostali všichni uživatelé najednou, uvolňujete ji postupně. Pokud se tam objeví chyba, nezasáhne ihned všechny uživatele, ale pouze jejich omezené množství. Pokud máte navíc v systému dobrou telemetrii, můžete problém detekovat automaticky a udělat rollback na původní verzi nebo v případě složitějších změn zastavit uvolňování nové verze dalším uživatelům. Právě postupné uvolňování nových verzí a telemetrie s automatickým rollbackem je něco na čem teď v GoodData pracujeme a co bychom chtěli mít v blízké době v provozu.

Je potřeba říci a to velmi důrazně, že ve skutečnosti netestujeme na uživateli. Výše popsaný mechanismus slouží k minimalizaci škod, ke kterým může vždy dojít. Vždy musí platit, že kód, který uvolňujeme na produkci, prošel všemi mechanismy zajištujícími odpovídající kvalitu. Jinými slovy, mechanismus neslouží jako náhražka testů, ale jako doplněk a ochranná bariéra.

V další části textu najdete moje postřehy, které odpovídají míře pochopení problému v danou chvíli a diskuzím, které jsme kolem tématu zatím vedli. Vlastní implementace se může v budoucnu odlišovat, ale zřejmě pouze v detailech. Rovněž nepředpokládám, že dáme vše na první ránu a ke konečnému stavu postupně dojdeme.

Vypustíme kanárky

Uvolňování nové verze a její deployment se může dělat pomocí Canary releasingu. Nejdříve se nasadí nová verze, která funguje paralelně s tou původní. Zároveň se uživatelé začnou směrovat na novou verzi. Pokud jde vše podle plánu, přesměrují se všichni na novou verzi, a stará verze se zahodí. V případě GoodData máme jednu vstupní bránu, přes kterou tečou všechny HTTP požadavky, proto není problém na vstupu požadavek distribuovat do staré nebo nové verze dané služby. Afinita (příslušnost) k verzi je možné držet v cookies (může být problém pro programatické REST klienty) a nebo pomocí HTTP hlaviček. V případě HTML aplikace máme mechanismus, který umožňuje bootstrap konkrétní verzí. Tento mechanismus již používáme v rámci podpory více datových center, kde mohou být rovněž nasazeny různé verze.

Největší oříšek představuje požadavek na zpětnou kompatibilitu. Interně jsou GoodData vystavěné jako služby, které spolu komunikují přes REST rozhraní. Každá služba má svůj vlastní subcluster a v podstatě i vlastní životní cyklus. Některé služby jsou vyvíjeny poměrně agilně, to znamená, že i několikrát za týden je uvolněná jejich nová verze. Je proto nutné, aby všechny změny, uvolňované tímhle způsobem, byly zpětně kompatibilní. Pokud změna není zpětně kompatibilní, musí být nasazena v plánovaném okně pro odstávku celé platformy. To je jediný způsob, jak koordinovaně nasadit nekompatibilní změny přes více služeb.

Strategie uvolňování tedy výběr uživatelů je možný po několika liniích. V GoodData jsou to uživatelé nebo projekty, případně větší celky, do kterých patří. Čili uvolňování řežete na úrovni uživatelů a nebo projektů, které používají. Například nová verzi SSO dává smysl uvolnit po ose uživatelů. Mě osobně se hodně libí metoda, které říkám dog fooding, tedy že se nová verze nejdříve uvolní interním uživatelům z řad GoodData. To v praxi vypadá tak, že uživatelé s loginem @gooddata.com ,případně ty přistupující přes naše interní adresy, by dostaly vždy nejnovější verzi. Pokud by se neobjevila regrese, bylo by možné pokračovat v uvolňování dalším uživatelům. Podobný model má například Facebook.

Chcíplý kanárek

Celý proces uvolňování a případného rollbacku musí být dostatečně automatický. Jednou z klíčových součástí je detekce anomálii, které mohou signalizovat, že v nové verzi je něco v nepořádku. V současné době sbíráme celou řadu dat z telemetrie provozu, díky kterým bychom měli dokázat rozpoznat běžný provozní stav od toho neobvyklého, který se projeví nějakou anomálií. Anomálie je prakticky cokoliv, co se odlišuje od normálů - například poměr HTTP statusů (vnitřní chyba serveru nebo chyba klienta) může celkem spolehlivě indikovat nějaký problém. Když tuhle myšlenku - detekce anomálií - rozvinu dále do budoucnosti, potřebujeme něco co se naučí rozpoznávat anomálie ze historických dat o provozu. Umělou inteligenci, kterou vytrénujeme na historických datech a které periodicky předhazujeme aktuální data z provozu. Při detekci anomálie (automatika) můžeme rozhodnout (člověk) jestli pokračovat v uvolňování a nebo jej docčasně zastavit a nebo provést rollback.

Závěr

Bez ohledu na to, kam se nám celou myšlenku podaří dopracovat, je zjevné že testy v klasické podobě postačují pouze k pokrytí stavů, které jsou nám známe. Pro stavy, o kterých nevíme, musíme použít přístup, kdy případné problémy zůstanou izolované pouze na omezenou a ideálně velmi malou skupinu uživatelů. K tomu nám dopomáhají výšše zmiňované metody.