500% zrychlení build procesu

Tomáš Vaněk
zonky-developers
Published in
6 min readMar 14, 2018

Poslední dobou jsme se potýkali s nespokojeností vývojářů, že doba buildu našeho projektu je neúnosně dlouhá. A jejich nespokojenost byla zcela oprávněná, protože čekat 45 minut než proběhnou všechny testy, aby se pak vývojář nakonec dozvěděl, že tam má nějakou drobnou chybku a musí absolvovat celé kolečko znovu, to není zrovna příjemná představa.

Nějakou dobu jsme se v tomto stavu snažili přežívat, ale problém se pouze více a více prohluboval. Nových vývojářů přibývalo a ti samozřejmě generovali více kódu a další testy. Takže jsme se před pár týdny rozhodli, že problém je potřeba začít řešit a hledali někoho, kdo by měl chuť a čas se tím problémem zabývat. Zadání znělo docela ambiciózně. Běh všech unit testů by měl trvat pod 1 sekundu a celý build pak pod 5 minut.

Nakonec jsem se tohoto úkolu zhostil já, protože už dříve jsem se zabýval optimalizací embedované Postgres databáze, kterou používáme pro integrační testy. Na základě těchto dřívějších optimalizací dokonce vznikla knihovna, kterou jsme před pár měsíci vypublikovali jako open source projekt na GitHubu. Pokud tedy v produkčním prostředí používáte PostgreSQL databázi a rádi byste ji jednoduše používali i v případě integračních testů, tak vám tuto naši knihovničku mohu vřele doporučit.

Naše infrastruktura

Ještě než se pustím do nějakého detailnějšího rozebírání mnou provedených optimalizací, tak se pokusím stručně popsat náš projekt, nástroje a infrastrukturu, kterou na Zonky pro buildování používáme. Protože všechny tyto věci spolu úzce souvisí. Takže velmi stručně. Pro kontinuální integraci používáme Jenkins, který provozujeme v AWS Cloudu. Jenkins slaves běží na instancích typu m4.4xlarge, které poskytují 16 jádrový procesor a 64 GB operační paměti. Pro buildování projektu používáme Maven, kdy některé složitější kroky máme napsané v Jenkins Pipeline.

Samotný projekt je zasazen do monorepa, ve kterém máme jeden větší historický modul, kterému u nás říkáme “Monolit” a ostatní moduly jsou microservisy. Vývoj probíhá klasicky přes pull requesty, kdy se nad každým pull requestem při každé změně spouští kompletní build se všemi testy. Když je úkol naimplementovaný a build zelený, tak může být zamergeovaný do hlavní vývojové větve.

Ze 45 minut na 15 minut paralelizací

Jako první jsme se rozhodli pro paralelizaci buildu, protože od toho jsme si slibovali největší přínos s nejmenším úsilím. Předpoklad byl, že se tímto krokem sice nezredukuje výpočetní čas nutný pro vykonání buildu, ale pokud bude build probíhat na výkonném stroji s hodně jádry, tak se zkrátí celkový čas nutný pro jeho běh. Jinými slovy, po finanční stránce se nic moc neušetří, spíše naopak, ale vývojáři by měli být spokojeni, protože build proběhne rychleji. A naše očekávání se opravdu naplnila.

Paralelizací buildu jsme nyní schopni naplno vytížit celý jeden Jenkins slave, takže už na něm nemusíme pouštět několik nezávislých buildů současně, ale pouze jeden jediný. Tím se nám čas buildu snížil z původních 45 na těžko uvěřitelných 15 minut.

Paralelizace maven buildu

Paralelizaci provádíme jak na úrovni Maven buildu, kdy je možné přes parametr -T nastavit v kolika vláknech má build probíhat, tak i na úrovni Surefire a Failsafe pluginů. Zde je potřeba upozornit, že paralelně lze buildovat pouze moduly, které jsou na sobě nezávislé. Pokud tedy budete mít modul A, který závisí na modulu B, tak Maven není schopen tyto dva moduly buildovat paralelně, ale nejdřív musí zbuildovat modul B a až pak modul A.

mvn clean install -T 0.5C

Paralelizace unit testů

Co se týká paralelizace testů, tak zde používáme odlišné přístupy pro unit testy a pro integrační testy. Jen poznamenám, že do kategorie integračních testů u nás patří jakýkoliv test, který startuje Springový kontext. K unit testům tedy přistupujeme tak, že je spouštíme v pouze jednom procesu a paralelizace probíhá na úrovni vláken. To má tu výhodu, že taková paralelizace je velmi rychlá a efektivní. Nevýhodou je, že pokud jsou testy špatně napsané a odkazují se na nějakou statickou nebo thread local proměnnou, tak se mohou navzájem ovlivňovat a náhodně padat.

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<parallel>classesAndMethods</parallel>
<perCoreThreadCount>true</perCoreThreadCount>
<threadCount>1</threadCount>
</configuration>
</plugin>

Paralelizace integračních testů

U integračních testů se oproti tomu pro každý paralelní běh spouští nový JVM proces. To sice zaručuje úplnou nezávislost paralelně zpracovávaných testů, na druhou stranu to sebou nese i obrovský overhead v podobně opětovné inicializace Springového kontextu. Tím, že jsou totiž procesy úplně oddělené, tak každý takový proces má i svoji vlastní nezávislou cache kontextu. Z toho důvodu je velmi důležité zvolit optimální počet paralelních procesů. Jinak se může stát, že přílišná paralelizace běh testů naopak zpomalí.

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<configuration>
<forkCount>4</forkCount>
<reuseForks>true</reuseForks>
</configuration>
</plugin>

Partial build, 8 minut

Dalším krokem, který jsme podnikli, a který měl přinést jak zrychlení buildu, tak už i nějakou finanční úsporu, byla implementace tzv. partial buildu, neboli buildování pouze změněných modulů. Vzhledem k tomu, že Maven pro toto nemá žádnou oficiální podporu, tak jsme stáli před rozhodnutím, zda celé monorepo zmigrovat na Gradle anebo být kreativní a nějak si to vyřešit po svém. Protože máme v backlogu spoustu jiné důležité práce a nemohli jsme si dovolit strávit jen tak několik týdnů přepisem Maven buildu do Gradle, tak jsme zvolili tu druhou variantu.

Nakonec jsme ale zjistili, že to vlastní řešení není zas až tak komplikované, jak se z počátku zdálo. Maven už určitou podporu pro částečné buildování má. Poskytuje přepínače -pl, -am a -amd, kterými je možné zbuildit pouze vybrané moduly, jejich závislosti a na nich závisející moduly. Jediné co tak bylo potřeba doimplementovat, byla detekce změněných modulů. K tomu jsme využili příkaz git diff, který s vhodně zvolenými parametry vygeneruje požadovaný seznam změněných modulů. Aplikováním tohoto částečného buildování jsme se dostali do stavu, kdy build nějaké menší microservisy trvá pod 5 minut a build “Monolitu” okolo 8 minut.

Zjednodušená ukázka našeho partial buildu

Flaky testy

Dalším problémem, se kterým jsme se u nás dost potýkali a vlastně ještě stále potýkáme, akorát nás to už tolik nepálí, jsou flaky testy. Jedná se o testy, které jsou nestabilní a nahodile padají. A s nimi samozřejmě i celý build. Jak Surefire, tak i Failsafe plugin mají od novějších verzí pro tento typ problému elegantní řešení. Skrze rerunFailingTestsCount property je možné nadefinovat, kolikrát se má provedení testu opakovat, než je test označen jako failed. Princip je takový, že pokud test selže pouze jednou, ale při nějakém z dalších pokusů projde, tak je pouze označen jako flaky test, ale nezpůsobí selhání celého buildu.

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<configuration>
<rerunFailingTestsCount>2</rerunFailingTestsCount>
</configuration>
</plugin>

Tohle nás samozřejmě pouze odstíní od problému, který najednou nebude tak palčivý. Určitě ale není dobrý přístup tvářit se, že je tímto problém vyřešen. Proto jsme si kromě zapnutí výše uvedené funkcionality udělali i detailní reportování flaky testů (dáváme do InfluxDB a vizualiuzujeme v Grafane jako zbytek našeho monitoringu), jež nám pak do budoucna umožní jednoduše dohledat problematické testy a opravit je, viz obrázek níže.

Další optimalizace

Provedli jsme ještě některé další optimalizace, ty už ale neměly tak zřetelný dopad na dobu buildu a cílily spíše na komfort vývojářů. Změny se týkaly zejména redukce času nutného pro inicializaci Spring kontextu u integračních testů. V případě kompletního buildu nás toto tolik netrápilo, protože tam se dlouhé startování integračních testů eliminuje cacheováním Springového kontextu, což umožňuje sdílení jednoho kontextu mezi vícero testy. Tento mechanismus ale moc dobře nefunguje při lokálním vývoji, kdy zrovna ladíte nějaký jeden konkrétní test a spouštíte jej opakovaně několikrát dokola.

Pro toto zrychlení jsme provedli např. optimalizaci Flyway migrací, kdy jsme pro potřeby testů sloučili velké množství opakovaně spouštěných migračních skriptů do jednoho jediného. Dále jsme pro inicializaci JPA využili tzv. “backgroud bootstrap mode”, kdy příprava entity manager factory probíhá na pozadí v jiném vlákně. Jako třešničku na dortu jsme si pak udělali speciální bean factory procesor, který zajišťuje lazy inicializaci testovacího Springového kontextu. To vše snížilo čas potřebný k inicializaci kontextu z cca 1 minuty na výsledných 20 vteřin.

Jak je patrné, tak tyto optimalizace se pohybují v řádu desítek vteřin. V kontrastu toho, že předchozími technikami se nám podařilo zkrátit čas buildu o více jak půl hodiny, se to může zdát jako zbytečně vynaložený čas. Na druhou stranu, pokud si představíme situaci, kdy máte 40 vývojářů a každý z nich spustí průměrně 10 integračních testů denně, tak při zrychlení startu o pouhých 30 vteřin ušetříte 200 minut každý den. Navíc vývojáři budou zas o něco spokojenější, což je podle mě taky celkem důležitý faktor.

Pár slov závěrem

Vzhledem k tomu, že psaní testů tvoří podstatnou část našeho vývoje, tak do budoucna určitě plánujeme ještě nějaká další vylepšení. Např. bychom chtěli začít používat Spring Boot slice testy, které umožňují načíst jen určitý typ bean a testovat tak jen určitou část aplikace. Takže pak můžete mít specializované testy pro testování perzistentní vrstvy, kontrolerů atd.

Na závěr bych chtěl dodat, že optimalizace a vývoj obecně je nikdy nekončící boj. Na jedné straně se já snažím o zrychlení buildu, zatímco na druhé straně se do projektu už zas postupně dostávají změny, které build naopak zpomalují. Např. teď naposledy jsme řešili problém, kdy se podařilo vytvořit závislost mezi “Monolitem” a jednou z microservis. No a jak už jsem výše zmiňoval, v takovém případě se budou moduly buildovat sériově a z původních 8 minut se build rázem protáhne na 13 minut. Z tohoto důvodu je vhodné mít nějaký monitoring, který vám pomůže takové situace včas odhalit.

--

--