Jak przyspieszyć testy w Ruby i JavaScript na serwerze CI używając paralelizacji

Pracując nad większym projektem możesz borykać się z problemem coraz to bardziej rosnącego zestawu testów, który z czasem zaczyna wykonywać się wolniej na twoim serwerze ciągłej integracji (CI). Miałem ten problem pracując nad projektem w Ruby on Rails, gdzie testy w RSpec wykonywane na CircleCI trwały około 15 minut.

Artur Trzop. Twórca opensource’owej biblioteki knapsack do dzielenia testów pomiędzy równoległe serwery CI oraz autor narzędzia developerskiego Knapsack Pro, które pomaga programistom Ruby i JavaScript oszczędzić czas dzięki szybszemu wykonywaniu testów automatycznych poprzez optymalne zrównoleglenie ich pracy na serwerach CI. Od 2012 zawodowo Ruby Developer.


Nie dawało mi to spokoju, więc postanowiłem coś z tym zrobić, czego rezultatem była opensource’owa biblioteka knapsack (nazwa od problemu plecakowego), która zajmuje się dzieleniem testów pomiędzy równoległe serwery CI. W tym artykule zapoznasz się z dwoma podejściami do dzielenia testów na serwerach ciągłej integracji – statycznym i dynamicznym.

Dobrze wiesz, jak niewygodne dla programistów są zestawy testów, które wykonują się około kilkunastu minut albo czasem i nawet kilka godzin na serwerze CI. Gdy pracujesz nad jakimś nowym feature’em i wrzucasz nowy commit do repozytorium, musisz czekać aż twój serwer CI wykona CI build w ramach wspomnianego commita.

Czekanie kilkunastu minut lub godziny to opóźnianie informacji zwrotnej, jaką możesz dostać z CI serwera na temat testów, które mogły się nie wykonać (czerwonych testów). Docelowo chcemy jak najszybciej dostać informację, czy nasz CI build jest zielony, czy czerwony, aby nie blokować pracy programistów.

Problem z równoległym wykonaniem testów na serwerze CI

Aby przyspieszyć wykonanie CI builda możemy skorzystać z paralelizacji na CI serwerze, czyli uruchomieniu kilku równoległych maszyn CI (kontenerów CI, np. w Dockerze), gdzie każdy równoległy serwer wykona część zestawu testów. Pojawia się jednak problem, które testy powinny wykonać się na poszczególnych serwerach (CI node’ach) tak, aby ich rozłożenie było w miarę równe, abyśmy nie musieli czekać na CI node, który jest wąskim gardłem.

Poniżej przykład nieoptymalnego rozłożenia testów na czterech serwerach CI, gdzie drugi serwer oznaczony na czerwono jest wąskim gardłem, przez co czas oczekiwania na zakończenie całego CI build wynosi aż 20 minut.

Optymalne rozłożenie testów na równoległych serwerach CI

W idealnym scenariuszu testy powinny być rozłożone tak, aby wszystkie równoległe serwery CI kończyły pracę w podobnym momencie. W dalszej części pokażę jak można to osiągnąć.

Poniżej przykład optymalnego rozłożenia testów, gdzie każda równoległa maszyna CI wykonuje testy przez 10 minut, dzięki temu cały CI build trwa tylko 10 minut, a nie 20 jak we wcześniejszym przykładzie.

Statyczne dzielenie testów w sposób deterministyczny

Jednym ze sposobów na ustalenie, jak rozdzielić testy pomiędzy równoległymi maszynami na serwerze CI tak, aby każdy z serwerów wykonał testy w podobnym czasie, jest wykorzystanie zmierzonego czasu uruchomienia każdego z plików z zestawu testów. To było pierwsze podejście, które zaimplementowałem w knapsack Ruby gem.

Po zmierzeniu czasu wykonania testów jesteśmy w stanie tak przypisać poszczególne pliki z testami pomiędzy równoległe CI serwery, aby upewnić się, że CI build nie ma wąskiego gardła.

Przy pomocy biblioteki knapsack można uruchomić testy dla wielu test runnerów w języku Ruby, takich jak: RSpec, Minitest, Cucumber, Spinach oraz Turnip. Knapsack gem wykorzystując czas trwania testów jest w stanie zbudować listę testów, które mają być wykonane na konkretnym CI node’dzie.

Problem ze statycznym dzieleniem testów

Zbierając informację od użytkowników okazało się, że dzielnie testów w sposób statyczny nie zawsze jest dobrym rozwiązaniem. Czasem zdarza się, że pewne testy mają losowy czas wykonania, który zależy np. od tego, jak bardzo obciążony jest CI serwer lub od samego faktu, że test nie przechodzi z powodu błędu w oprogramowaniu i kończy pracę szybciej niż zwykle.

Przykładowo: testy używając przeglądarki potrafią mieć wahania w czasie wykonania testu (testy w Capybara w Ruby lub E2E testy w JavaScript).

Problem rozrasta się też w zależności od tego, jakiego serwera CI używamy. Czy każda z równoległych maszyn CI ma podobną wydajność, czy może współdzieli zasoby jak CPU lub RAM? Czy kontener CI działa w środowisku współdzielonym? Jeśli CI node będzie obciążony, to siłą rzeczy nasze testy mogą wykonywać się wolniej.

Dodatkowo pojawią się problemy z tym, czy wszystkie równoległe maszyny startują w podobnym momencie, czy nie. Jeśli masz wykupioną pulę równoległych serwerów CI, to ktoś może z niej korzystać, np. inny CI build z obecnego projektu lub w ramach innego projektu z twojej organizacji.

Jeśli nie wszystkie CI node’y wystartują w tym samym momencie lub boot time pewnych kroków w środku CI node’a potrafi zająć losowy czas, to chcielibyśmy być w stanie i tak upewnić się, że wszystkie maszyny CI kończą pracę w podobnym momencie. Słabe lub wolne maszyny CI powinny wykonać mniej testów, a te serwery, które zaczęły wcześniej pracę, mogą spokojnie wykonać jej więcej.

Istotne jest to, aby wszystkie równoległe CI node’y kończyły pracę w podobnym momencie, aby uniknąć wąskiego gardła, czyli zbyt załadowanego testami serwera.

Dynamiczne dzielenie testów

Rozwiązaniem powyższego problemu jest dzielenie testów w sposób dynamiczny pomiędzy równolegle działające maszyny w ramach jednego CI builda. To problem, nad którym pracowałem przez ostatnie lata, czego efektem jest biblioteka Knapsack Pro i tryb Queue Mode dla języka Ruby i JavaScript ze wsparciem dla kilku popularnych test runnerów jak Jest czy Cypress.

Idea jest prosta. Mamy zestaw testów, który znajduje się w kolejce na serwerze Knapsack Pro. Poszczególne równoległe maszyny CI konsumują kolejkę z Knapsack Pro API tak długo, aż kolejka się skończy. Dzięki temu testy są rozłożone między CI serwery w optymalny sposób, a tym samym można uniknąć wąskiego gardła w postaci przeładowanego serwera CI (zbyt wolnego). Poniżej przykład:

Dzielenie testów w sposób dynamiczny rozwiązuje nam problem z losowym czasem wykonania testów, ze zbyt wolno startującymi serwerami CI lub z serwerami, które są zbyt obciążone i pracują wolniej. Nieważne kiedy zaczną pracę lub kiedy ją kończą – ważne, że nie pobierają zbyt dużej ilości testów do wykonania dopóki nie skończą obecnej pracy.

Implementacja Knapsack Pro w Ruby i JavaScript

Knapsack Pro ma natywne wsparcie dla wielu popularnych serwerów CI. Jest także narzędziem CI agnostycznym, więc możesz używać dowolnego serwera CI. Jedyne co musisz zrobić, to skonfigurować odpowiednio komendę Knapsack Pro dla każdego równoległego serwera CI działającego w ramach jednego CI builda. Poniżej ogólny przykład jak mógłby wyglądać config yaml dla serwera CI wraz z Knapsack Pro:

Podsumowanie

Knapsack Pro wspiera Ruby oraz kilka test runnerów w JavaScript, takich jak Jest i Cypress, ale w planach jest dodanie wsparcia dla większej ilości test runnerów i języków programowania. Chętnie chciałbym usłyszeć czego używasz do testowania aplikacji i jakiego serwera CI. Możesz się ze mną skontaktować na LinkedIn. Mam nadzieję, że ten artykuł był dla Ciebie przydatny.


Zdjęcie główne artykułu pochodzi z unsplash.com.

Zapraszamy do dyskusji

Patronujemy

 
 
More Stories
Programiści JavaScript, których warto obserwować. Prasówka Technologiczna: 25.01.2020 r.