Skip to content
← Wszystkie wpisy
6 min czytania Dawid Skłodowski

2 CPU = 2× testów: zrównoleglanie zestawu RSpec

Twój zestaw testów działa na jednym rdzeniu, a pozostałe trzy stoją bezczynnie. Rzetelny przewodnik po rozdzielaniu RSpec na procesy z parallel_tests — osobne bazy, grupy balansowane czasem, błędy izolacji, które ujawnia, i skalowanie na węzły CI.

Oto lekko zawstydzający fakt o większości zestawów testów w Railsach: działają na jednym rdzeniu, podczas gdy reszta maszyny się przygląda. Kupiłeś czterordzeniowy laptop, a twoje testy używają mniej więcej 25% jego mocy. Tymczasem zestaw rozrósł się z dwóch minut do ośmiu — a wolny zestaw to zestaw, który ludzie przestają odpalać przed pushem, co po cichu podkopuje cały sens jego posiadania.

Rozwiązaniem jest rozłożenie testów na procesy, po jednym na rdzeń, każdy z własną bazą. Gem parallel_tests czyni to niemal mechanicznym. Słowo „niemal” sporo tu dźwiga, bo zrównoleglenie nie tyle wprowadza nowe błędy, ile awansuje klasę ukrytych — zależność od kolejności i przecieki współdzielonego stanu — z „przechodzi na mojej maszynie” do „zawodzi sporadycznie w CI”. Oto więc pełny obraz z konfiguracji na realnych zestawach: mechanika, jedna gałka strojenia, która ma znaczenie, i błędy, które po drodze wypłuczesz.

Dlaczego nie po prostu wątki?

Najpierw oczywiste pytanie. Ruby ma wątki; po co odpalać całe procesy? Z dwóch powodów. Globalny lock interpretera w MRI sprawia, że kod związany z CPU nie działa naprawdę równolegle między wątkami — a zestaw testów jest związany z CPU. Bardziej zasadniczo, twoje testy współdzielą zmienny stan globalny, którego nigdy nie zaprojektowano do dotykania współbieżnie: przede wszystkim bazę, ale też memoizację na poziomie klasy, podmianki Time, załadowane fixtures, pliki tymczasowe. Wątki współdzieliłyby to wszystko i tratowałyby się nawzajem. Osobne procesy systemowe dostają osobną pamięć i — z odrobiną konfiguracji — osobne bazy: prawdziwą izolację. Kosztem jest narzut startu procesu, dlatego dostajesz silne, lecz nigdy idealnie liniowe przyspieszenie.

Sedno: jeden proces, jedna baza

Najgłośniejszym kawałkiem współdzielonego stanu jest baza, więc model brzmi: jeden proces na CPU, a każdy proces dostaje własną bazęapp_test, app_test2, app_test3 i tak dalej. parallel_tests koordynuje to zmienną środowiskową TEST_ENV_NUMBER, którą każdy pracownik może odczytać. Wpinasz ją w database.yml:

test:
  adapter: postgresql
  database: app_test<%= ENV['TEST_ENV_NUMBER'] %>
  pool: 5

Pierwszy pracownik widzi puste TEST_ENV_NUMBER (czyli app_test), drugi widzi 2 (app_test2) i tak dalej. Następnie tworzysz i ładujesz schemat do wszystkich naraz:

rake parallel:create
rake parallel:prepare    # ładuje schemat do każdej bazy testowej

O kroku parallel:prepare ludzie zapominają, a wynikająca awaria jest zagadkowa: dodajesz migrację, uruchamiasz db:migrate tylko na app_test, testy przechodzą seryjnie, a potem połowa zawodzi pod parallel:spec, bo pracownicy 2–4 wskazują na bazy ze starym schematem. Uczyń parallel:prepare częścią rutyny „po pobraniu main”, a problem znika.

Uruchamianie

Gdy bazy są na miejscu, runner dzieli pliki testów na grupy, po jednej na pracownika:

rake parallel:spec               # używa wszystkich rdzeni
rake parallel:spec[4]            # wymuś 4 pracowników

Na czterordzeniowej maszynie ośmiominutowy zestaw zwykle spada do dwóch–trzech minut. Nie czyste 4× — i warto rozumieć dlaczego, bo to mówi, gdzie idzie reszta czasu. Jest stały koszt na pracownika (bootowanie Railsów cztery razy zamiast raz) i jest prawo Amdahla: zestaw kończy się dopiero, gdy skończy najwolniejsza grupa, więc każda nierównowaga między grupami to zmarnowane rdzenie. Ten drugi czynnik masz w pełni pod kontrolą i jest najużyteczniejszą rzeczą do dostrojenia.

Balansuj czasem, nie liczbą plików

Domyślnie gem dzieli pliki na grupy mniej więcej równe liczebnie. To zła miara. Jeden wolny test feature pełen interakcji przeglądarki w Capybarze potrafi trwać dłużej niż pięćdziesiąt szybkich testów modelu razem wziętych. Jeśli ten potwór trafi sam do grupy pierwszej, trzy procesy skończą w 40 sekund, a czwarty będzie mielił przez dwie minuty — a twój czas zegarowy wyznacza ten maruder, podczas gdy trzy rdzenie stoją bezczynnie.

Rozwiązaniem jest balansowanie zapisanym czasem wykonania. Uruchom zestaw raz, by wygenerować log, a potem każ podziałowi pakować grupy czasem:

rake parallel:spec                       # zapisuje tmp/parallel_runtime_rspec.log
rake "parallel:spec[,, --group-by runtime]"

Gdy log istnieje, grupy same się wyrównują: jeden wolny test dostaje grupę niemal dla siebie, a szybkie testy wypełniają resztę, by dorównać. Utrwalaj log (commituj go albo cache’uj w CI), by balansowanie przeżyło świeży checkout. Ta jedna zmiana rutynowo daje więcej czasu zegarowego niż dodanie kolejnego rdzenia.

Pułapka: testy, które nigdy nie były naprawdę izolowane

To część, przed którą nikt cię nie ostrzega. Zrównoleglenie nie tworzy błędów; ujawnia te już czające się w testach, które po cichu zależą od siebie albo od współdzielonych zasobów. Spodziewaj się, że pierwsze popołudnie zejdzie na ich naprawie — i ciesz się, bo to były prawdziwe błędy.

Współdzielone zasoby zewnętrzne. Dwaj pracownicy zapisujący do tego samego pliku w tmp/, obaj wiążący ten sam port fałszywego SMTP lub serwera Capybary, obaj zasiewający tę samą bazę Redis, obaj odtwarzający tę samą kasetę VCR. Nazwij przestrzeniami każdy taki zasób przez TEST_ENV_NUMBER dokładnie jak bazę:

RSpec.configure do |config|
  config.before(:suite) do
    n = ENV["TEST_ENV_NUMBER"].presence || "1"
    Redis.current = Redis.new(db: n.to_i)            # osobna baza Redis na pracownika
    Capybara.server_port = 9887 + n.to_i             # osobny port na pracownika
  end
end

Strategia DatabaseCleaner. Wewnątrz jednego pracownika wciąż potrzebujesz czystego stanu między przykładami. Transakcje są najszybsze, ale test Capybary uruchamiający aplikację w osobnym wątku (prawdziwy sterownik przeglądarki) nie zobaczy niezacommitowanej transakcji — więc testy feature z JavaScriptem potrzebują strategii :truncation, gdy reszta używa :transaction. Zrównoleglenie tego nie zmienia, ale sprawia, że błędna konfiguracja zawodzi sporadycznie, co zamienia szybką poprawkę w długą sesję debugowania, jeśli nie rozumiesz już tego podziału.

Zależność od kolejności. Test, który przechodzi tylko dlatego, że wcześniejszy zostawił wiersz, zawiedzie w chwili, gdy podział umieści oba w różnych pracownikach. Uruchom zestaw z --order random zanim zrównoleglisz, by je najpierw wypłukać; inaczej będziesz debugował prawdziwy błąd izolacji, obwiniając przy tym nową konfigurację równoległą. Każdy test musi sam budować swój świat i go sprzątać.

Przeplatane wyjście i artefakty. Logi z czterech pracowników przeplatają się w nieczytelną papkę, a zrzuty ekranu czy zrzuty porażek z różnych pracowników mogą się nadpisywać. Oznaczaj linie logu i nazwy plików artefaktów przez TEST_ENV_NUMBER, by porażkę dało się prześledzić do pracownika, który ją wyprodukował.

CI: większa wygrana

Lokalnie zrównoleglenie jest miłym dodatkiem. W CI to różnica między szybką pętlą sprzężenia zwrotnego a przerwą na kawę — a ten sam mechanizm skaluje się na maszyny, nie tylko rdzenie. Podziel zestaw na N grup i uruchom każdą na osobnym węźle build, balansując czasem, by każdy węzeł kończył mniej więcej w tym samym momencie:

# węzeł i z n
rake "parallel:spec[$CI_NODE_TOTAL,, --group-by runtime]" \
     TEST_ENV_NUMBER=$CI_NODE_INDEX

Cztery węzły zamieniają dwudziestominutowy zestaw CI w pięciominutowy, a balansowanie czasem trzyma każdy węzeł zajętym aż do końca, zamiast pozwalać jednemu maruderowi blokować zielony znaczek, gdy reszta stoi. To samo podejście działa dla Cucumbera (parallel:features), jeśli obok testów masz zestaw feature.

Czy było warto?

Bezsprzecznie. Konfiguracja to jedno popołudnie, którego większość zejdzie na naprawie dwóch–trzech testów po cichu zależnych od siebie — a to były błędy, o których i tak chciałeś wiedzieć. Nagrodą jest zestaw, który używa sprzętu już opłaconego, działa w ćwierć czasu, a więc faktycznie bywa odpalany przed pushem. Prawdziwa szybkość zestawu testów to nie to, jak szybko się wykonuje, ale jak często wolno mu się wykonać. Zrównoleglenie przesuwa tę gałkę bardziej niż niemal cokolwiek innego, co da się zrobić w jedno popołudnie.