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.