Wybór języka

Jak skrócić build korzystający z Testcontainers

Niektórzy ludzie narzekają, że nie wykonują testów integracyjnych “ponieważ zajmują za dużo czasu”. Dobrze, interakcja z rzeczywistymi zależnościami czasami nie może być skrócona, ale z pewnością istnieją sposoby na szybsze uruchamianie baz danych, brokerów wiadomości i innych, jeśli używasz do tego Testcontainers.

W tym wpisie będę używał języka Java. Jednakże, ponieważ Testcontainers jest (z pewnego punktu widzenia) wygodnym sposobem na współpracę z kontenerami zgodnymi z Dockerem przy użyciu języka programowania, który znasz, to samo podejście można zastosować również do innych technologii. Jestem pewien, że po zrozumieniu idei można ją przetłumaczyć na Go lub C#.

I oczywiście, ten wpis będzie miał parę liczb, przepraszam.

Ile razy uruchamiasz kontener?

Testcontainers można postrzegać jako narzędzie do Infrastructure-as-a-Code (infrastruktura jako kod, ang. IaaC). Ile razy zazwyczaj uruchamiasz bazy danych lub inne zależności Twojej infrastruktury podczas testowania, na przykład ręcznie lub za pomocą skryptu Bash? Robisz to przed każdym @Test, czy raczej uruchamiasz raz i pozwalasz na wykonanie całego zestawu (lub tyle, ile jest potrzebne)? Dokładnie. Z tego powodu (ponowne) uruchamianie kontenerów przed poszczególnymi przypadkami testowymi powinno być wyjątkiem, a nie domyślną praktyką.

Jeśli samodzielnie kontrolujesz cykl życia kontenerów, w Javie zazwyczaj wystarczy wywołać container.start() w metodzie statycznej, zazwyczaj oznaczonej adnotacją @BeforeAll.

Może to być mniej oczywiste, jeśli polegasz na integracji z frameworkiem testowym, na przykład poprzez oznaczenie klasy testowej JUnit adnotacją @Testcontainers, a pola kontenera adnotacją @Container. W takim przypadku zamiast tego:

1@Testcontainers
2class IntegrationTests {
3    @Container
4    final PosgreSQLContainer postgres = new ...
5
6    // tutaj są testy
7}

upewnij się, że oznaczone pole jest statyczne:

1@Testcontainers
2class IntegrationTests {
3    @Container
4    static final PosgreSQLContainer postgres = new ...
5
6    // tutaj są testy
7}

Z tego powodu zostanie uruchomiony tylko raz.

Ile masz rdzeni CPU? Krótka odpowiedź brzmi: wiele. Nie ma więc potrzeby uruchamiania kontenerów jeden po drugim w podejściu kaskadowym. Zamiast tego możesz uruchomić je równolegle. Na przykład, jeśli w Twoich testach potrzebujesz dwóch zewnętrznych zależności (powiedzmy PostgreSQL i Kafka), które uruchamiają się odpowiednio w ciągu 20 i 30 sekund, w większości przypadków cały blok @BeforeAll zajmie około 50 sekund z powodu następującego pseudokodu:

1final static PosgreSQLContainer postgres = new ...
2final static KafkaContainer kafka = new ...
3
4@BeforeAll
5public void setupRealDependencies() {
6    postgres.start(); // to blokuje wykonanie na 20 sekund
7    kafka.start(); // a po 20 sekundach ta metoda jest blokowana przez kolejne 30 sekund
8}

Jeśli Twój procesor jest zdolny do uruchamiania wielu procesów jednocześnie, dlaczego nie wykorzystać go odpowiednio? W kolejce uruchamiania takie sekwencyjne podejście to po prostu marnowanie mocy obliczeniowej (chyba że używasz jej do czegoś innego, ale kto przegląda Facebooka podczas budowania w chmurze w środowisku CI?)

Ta wskazówka jest prosta: spróbuj uruchomić kontenery jak najszybciej i uruchom je wszystkie naraz (chyba że Twój scenariusz testowy obejmuje inżynierię chaosu, ponawianie prób itp.)

Jeśli kontrolujesz kontenery ręcznie, w Javie coś takiego powinno działać:

1static final PosgreSQLContainer postgres = new ...
2static final KafkaContainer kafka = new ...
3
4@BeforeAll
5public void setupRealDependencies() {
6    Stream.of(postgres, kafka).parallel().forEach(GenericContainer::start); // to wywołanie zajmuje max(20s,30s)
7}

Oczywiście, czasami są sytuacje, gdy ForkJoin Pool wpływa na działanie Stream.parallel(). W takim przypadku możesz użyć zamiast tego:

Startables.deepStart(postgres, kafka).join();

Robi praktycznie to samo, tylko czasem zajmuje trochę więcej czasu, aby to zrozumieć (np. nowym członkom zespołu).

Jeśli polegasz na integracji z JUnit przy użyciu adnotacji @Testcontainers i @Container, to (od wersji 1.18.0) Testcontainers może również uruchamiać kontenery dla Ciebie równolegle, korzystając z @Testcontainers(parallel = true), na przykład tak:

1@Testcontainers(parallel = true)
2class IntegrationTests {
3    @Container
4    static final PosgreSQLContainer postgres = new ...
5    @Container
6    static final KafkaContainer kafka = new ...
7
8    // tutaj umieść testy
9}

Załaduj swoje rzeczy zawczasu

Jeśli jesteś w domu, zazwyczaj nie interesuje Cię, czy Twój laptop jest naładowany, czy nie, ponieważ możesz go ładować w dowolnym momencie i nadal pracować. Jednak jeśli wybierasz się w długą podróż, możesz chcieć naładować swój samochód elektryczny z góry, aby uniknąć niepotrzebnych opóźnień w trakcie podróży.

Coś podobnego dotyczy również Testcontainers, ponieważ paliwem, na którym bazują, są obrazy Dockera.

Jeśli pracujesz na własnym komputerze i czasami uruchamiasz testy z wykorzystaniem Testcontainers (co powinniśmy robić, swoją drogą!), łatwo jest zapomnieć, że uruchamianie kontenerów nie jest czymś magicznym, lecz poważną inżynierią. Aby uruchomić kontener, biblioteka musi najpierw mieć obraz dostępny lokalnie, więc pobiera go (wykonuje docker pull, że tak powiem).

Na komputerze pragramistki/y to nie jest duży problem, ponieważ zazwyczaj dzieje się to tylko przy pierwszym uruchomieniu, a następnie obraz pozostaje na dysku. Dlatego wywołanie aContainer.start() przy drugim i kolejnych wywołaniach nie wymaga pobierania obrazu.

Jednak w przypadku chmurowych systemów CI jest bardzo często inaczej. Twój build jest uruchamiany na świeżo przydzielonym, nietrwałym workerze, który nie przechowuje obrazów używanych w poprzednich uruchomieniach. Oczywiście, chyba że skonfigurujesz build tak, aby je “cacheować”, tak jak zależności w plikach .jar.

Jeśli nie możesz “cacheować” tych zasobów (JAR-ów, obrazów Docker lub obu), poniższa technika wcześniejszego ładowania może być bardzo przydatna. Koncepcja jest dość prosta: zamiast pobierać obrazy podczas uruchamiania kontenerów za pomocą start() (uruchamianego bezpośrednio lub pośrednio), pobierzmy je wcześniej. W ten sposób, kiedy Testcontainers będzie ich wymagać, obrazy będą już załadowane, a nasze testy będą zablokowane jedynie na czas potrzebny do uruchomienia kontenera, a nie na pobranie obrazu i uruchomienie kontenera.

Pytanie brzmi: kiedy pobrać obrazy.

Jeśli zdecydujesz się pobrać je w kodzie Javy, może to być nieco późno, ponieważ jeśli uruchamiamy kontenery w metodzie statycznej, możemy osiągnąć pewne korzyści, ale nie ogromne. Czasami wręcz nieistotne.

Jak często profilujesz lub stosujesz telemetrię w swoim buildzie? Mam na myśli prawdziwy profiler i tak dalej, aby sprawdzać wykorzystanie CPU, pamięci RAM, wejścia-wyjścia i sieci oraz doprowadzać je do granic wydajności? Kiedyś to robiłem, więc pozwól, że podzielę się kilkoma wnioskami: nie chcesz, aby któreś z tych zasobów były zablokowane, gdy już są nasycone, ale zamiast tego możesz wykonać pewne obliczenia wstępne lub wcześniejsze pobranie, gdy nie wpływa to na inne działania w Twoim buildzie. Kiedy Twój build pobiera pół internetu (szczególnie z npm), nie chcesz, aby pobierał drugą połowę w tym samym momencie.

Dlatego optymalizacja takiego rodzaju:

  • pobranie obrazów Docker,
  • pobranie wszystkich zależności za pomocą Gradle lub Maven

w tym samym czasie

może nie przynieść wielkiego zysku, lub co gorsza: faktycznie spowolnić cały proces. Chyba że masz skonfigurowane zdalne środowisko kontenerowe, wtedy jest w porządku, ponieważ to czyjaś sieć będzie zajęta pobieraniem obrazu Localstack o rozmiarze 1,3 GB, nie Twoja ;-).

Dlatego też poniższe podejście (w pseudo-shellowym skrypcie) może nie być najlepsze w Twoim buildzie:

1docker pull -q postgres:version &
2docker pull -q confluentinc/cp-kafka:version &
3./gradlew build

ponieważ narzędzia mogą konkurować o ten sam zasób: sieć. To, czym Gradle lub Maven zazwyczaj rozpoczynają budowanie, to rozwiązywanie zależności projektu, co wiąże się z pobraniem plików JAR. Tak, korzystając z tej samej sieci, która łączy Cię z Docker Hub.

Zamiast tego możemy zrobić:

1./gradlew dependencies
2docker pull -q postgres:version &
3docker pull -q confluentinc/cp-kafka:version &
4# lub jakiekolwiek inne kontenery, które będą później potrzebne
5./gradlew build

Pierwsze (blokujące) wywołanie ./gradlew dependencies w praktyce pobierze wszystkie JAR-y potrzebne do kompilacji i testowania projektu. Następnie, gdy się zakończy, możemy z radością rozpocząć pobieranie/“pullowanie” obrazów, które wiemy, że będą używane później w teście, kiedy zostanie wywołane zadanie test w ramach zadania build. Zwykle, po pobraniu zależności, build nie generuje dużej aktywności sieciowej, więc możemy wykorzystać sieć (oraz nieco CPU i IO) do wcześniejszego pobrania obrazów, aby były one dostępne lokalnie, gdy będą potrzebne. W ten sposób, aContainer.start() nie będzie czekał na sieć (zwykle Docker Hub), ale będzie gotowy do natychmiastowego rozpoczęcia start()owania kontenera.

Zwróć proszę uwagę na szczegóły i upewnij się, że wszystkie polecenia docker pull ... w skrypcie Bash kończą się &, w przeciwnym razie będą blokowały kompilację (i inne zadania w zadaniu build), a tym samym nie będzie żadnej poprawy wydajności.

Avoid cascade

Podsumowanie

Mam nadzieję, że udało mi się wyjaśnić, że niektóre rzeczy związane z Testcontainers (które są infrastrukturą jako kod) nie powinny być wykonywane sekwencyjnie/kaskadowo, ale równolegle (jeśli infrastruktura to umożliwia).

Dotyczy to zarówno uruchamiania kontenerów, jak i pobierania obrazów.

Oto relaksujące zdjęcie tego, czego należy unikać. ;-)

Zdjęcie autorstwa Martina Herfurta z Pixabay.

Wybór języka