Wszystkie wpisy

Testcontainers Cloud - runtime w chmurze, ten sam kod (lipiec 2024)Testcontainers Cloud - runtime w chmurze, ten sam kod (lipiec 2024)

English

Testcontainers Cloud - runtime w chmurze, ten sam kod (lipiec 2024)

Lipiec 2024: Testcontainers Cloud po przejęciu AtomicJar. Offload kontenerów z laptopa i CI, bez przepisywania suite i bez unified plans z grudnia.

AutomatyzacjaDockerTestcontainers

Kiedy zrzucam runtime, a kiedy zostaję przy daemonie

Jest 15 lipca. Od ponad trzech lat moje testy integracyjne stoją na jednym założeniu: obok procesu testowego działa demon Dockera, z którym biblioteka rozmawia przez socket. W kwietniu 2021 był to Docker na moim laptopie i pierwsza baza MSSQL podniesiona z kodu testu, jeszcze przez TestcontainersBuilder. W listopadzie 2021 był to demon na hoście agenta w Azure Pipelines, a całe ryzyko sprowadzało się do pytania, czy job ten socket widzi. W sierpniu 2022 obok joba integracyjnego stanął drugi job, z Playwrightem, i założenie się nie zmieniło: Docker siedzi na hoście agenta, a job z Testcontainers stoi obok niego.

Testcontainers Cloud był przez ten czas poza blogiem, i to świadomie. Publiczna beta ruszyła 25 stycznia 2023, a w grudniowym podsumowaniu roku zapisałem ją jako temat, nie jako opinię, bo przez dwanaście miesięcy nie znalazłem dnia, żeby sprawdzić ją porządnie. 11 grudnia 2023 Docker kupił AtomicJar, firmę stojącą za Testcontainers, razem z jej dwoma produktami: Testcontainers Desktop i Testcontainers Cloud. Dziś, półtora roku po becie i siedem miesięcy po przejęciu, zamykam ten temat jednym pytaniem: kiedy zrzucam runtime kontenerów do chmury, a kiedy zostaję przy demonie na laptopie i na hostowanym agencie.

Moja teza na lipiec 2024 jest wąska. Testcontainers Cloud jest na tyle dojrzałą usługą, że mogę zrzucić runtime kontenerów z laptopa i z hostowanego agenta CI do chmury bez przepisywania testów opartych na ContainerBuilder. To nie zastępuje lekcji z 2021 roku. Lokalny demon i Docker na hoście agenta Azure nadal są u mnie kanonem i od nich zaczynam każdy nowy zestaw. Cloud jest opcją na dwa konkretne bóle: kiedy kontenery zjadają CPU i RAM maszyny, na której chodzi test, i kiedy jedyną drogą do Dockera na agencie byłby Docker-in-Docker w trybie uprzywilejowanym.

“Dojrzała” nie znaczy tu “po ogłoszeniu GA”. Osobnego ogłoszenia ogólnej dostępności nie znalazłem i nie wpisuję daty, której nie umiem pokazać w źródle. Dojrzałość sprawdzam po trzech rzeczach, które da się dziś przeczytać. Dokumentacja Cloud ma gotowy krok dla Azure Pipelines, akcję dla GitHub Actions i instrukcje dla kilku innych systemów CI. Model rozliczeń jest opisany, łącznie z planem darmowym dla osoby pracującej w pojedynkę. A biblioteka .NET wie o usłudze bez żadnej wtyczki, co w następnej sekcji pokażę na kodzie źródłowym wersji 3.9.0.

Stan warsztatu, żeby za rok było wiadomo, o czym mówię. Projekt testów jest na .NET 8, wydaniu LTS z 14 listopada 2023; podgląd .NET 9 z zeszłego tygodnia nie jest pinem do niczego w tym wpisie. NuGet Testcontainers to 3.9.0 z połowy czerwca, z notatkami wydania z 17 czerwca. Obok niego Npgsql 8.0.3, xUnit 2.9.0 i obraz postgres:16.3-alpine, przypięty z tego samego powodu, z którego w styczniu 2021 pisałem postgres:13.1, a nie postgres:latest. Playwright 1.45.1 z 2 lipca chodzi w sąsiednim jobie i w tym wpisie jest tłem, nie tematem.

Czego tu nie będzie. Nie będzie drugiego wprowadzenia do Testcontainers ani YAML-a dla Dockera na agencie pisanego od zera, bo oba są pod linkami wyżej i nadal obowiązują. Nie będzie daty GA, cen ani minut z prawdziwego konta, nazwy organizacji czy tokena. Będzie syntetyczny projekt Orders.IntegrationTests z repozytorium z przykładami do bloga: jedna klasa z Postgresem, ten sam test na dwóch runtime’ach, jedna zmieniona linia i wycinek joba w Azure Pipelines.

Co to jest, a czego nie

Testcontainers Cloud to trzy kawałki, z których jeden znam od 2021 roku. Pierwszy to biblioteka Testcontainers, ta sama co dotąd, w Javie, w .NET, w Node i w pozostałych językach. Drugi to worker w chmurze, czyli maszyna z demonem Dockera przydzielana na czas przebiegu. Trzeci to agent, który łączy jedno z drugim: na laptopie jest nim aplikacja Testcontainers Desktop, na CI osobny agent uruchamiany z linii poleceń z tokenem service account. Według dokumentacji agent otwiera tunel SSH do demona na workerze, obraz jest pobierany i kontener startuje po stronie workera, a po zakończeniu zestawu usługa chwilę czeka, zamyka połączenie i zwalnia zasoby.

Najważniejsze jest to, jak biblioteka znajduje tego demona, bo od tego zależy, czy kod testów musi cokolwiek wiedzieć o chmurze. Agent, zarówno Desktop, jak i ten z CI, wpisuje swój adres do pliku ~/.testcontainers.properties jako właściwość tc.host. Żeby nie opierać się na samym opisie, zajrzałem do kodu źródłowego Testcontainers dla .NET w wersji 3.9.0. Lista dostawców połączenia z Dockerem zaczyna się od tego, który czyta tc.host, a dopiero za nim są DOCKER_HOST, socket unixowy i pozostałe warianty. Wygrywa pierwszy dostawca, który ma konfigurację i odpowiada na ping. Komentarz nad klasą tego dostawcy mówi wprost, że jeśli na maszynie jest kilka runtime’ów, pierwszeństwo ma Testcontainers Cloud, o ile działa.

Z tego wynikają trzy rzeczy o znaczeniu praktycznym. Po pierwsze, przełącznik leży poza kodem: w menu Desktopu albo w kroku pipeline’u, a nie w ContainerBuilder. Po drugie, DOCKER_HOST nie służy do wybierania runtime’u, bo działający agent wygrywa z nim zawsze. Po trzecie, dokumentacja Cloud opisuje wyłączenie usługi dla pojedynczego projektu, ale przez plik testcontainers.properties na classpath, czyli w Javie. W .NET 3.9.0 takiego przełącznika nie znalazłem: każdy projekt na danej maszynie idzie tam, gdzie wskazuje plik w katalogu domowym.

Czego to nie jest. Nie jest to nowy framework ani nowa linia biblioteki, na którą trzeba migrować, i moje testy nie importują niczego z Cloud. Nie jest to zamiennik bibliotek open source: te zostają darmowe, a przy przejęciu Docker zapewnił, że ich licencje się nie zmieniają. Płaci się za runtime, nie za API. Nie jest to wreszcie zdalny Docker do wszystkiego. Desktop zakłada kontekst Dockera o nazwie tcc, przez który da się zajrzeć w kontenery komendą docker ps, ale dokumentacja Cloud ostrzega, że uruchamianie własnych komend Dockera na tym kontekście nie jest wspierane i potrafi przydzielić workera, który nic nie robi. docker compose puszczony na ten kontekst to ten sam, niewspierany przypadek.

Testcontainers Desktop warto oddzielić od Cloud, bo to dwie różne rzeczy. Desktop to aplikacja towarzysząca z zamkniętym kodem, na macOS, Windows i Linuksa, z darmowym planem dla pojedynczych osób i wymaganym kontem. Działa także bez chmury: pozwala przełączać się między lokalnymi runtime’ami, wystawić kontener z testu na stałym porcie, żeby podejrzeć bazę w narzędziu do SQL-a, i, na razie w becie, wstrzymać sprzątanie kontenerów, żeby obejrzeć dane po teście. Chmura jest jedną z pozycji w menu Desktopu.

Teraz kod, na którym to sprawdzam. Cała klasa PostgresFixture z syntetycznego projektu, w wersji po jednej poprawce, do której zaraz wrócę:

using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Npgsql;
using Xunit;

namespace Orders.IntegrationTests;

public sealed class PostgresFixture : IAsyncLifetime
{
    private const string Password = "test";

    private readonly IContainer _postgres = new ContainerBuilder()
        .WithImage("postgres:16.3-alpine")
        .WithEnvironment("POSTGRES_PASSWORD", Password)
        .WithEnvironment("POSTGRES_DB", "shop")
        .WithPortBinding(5432, true)
        .WithResourceMapping(new FileInfo("db/init.sql"), "/docker-entrypoint-initdb.d/")
        .WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted(
            new[] { "pg_isready", "--host", "127.0.0.1", "--dbname", "shop", "--username", "postgres" },
            o => o.WithTimeout(TimeSpan.FromMinutes(2))))
        .Build();

    public string ConnectionString => new NpgsqlConnectionStringBuilder
    {
        Host = _postgres.Hostname,
        Port = _postgres.GetMappedPublicPort(5432),
        Username = "postgres",
        Password = Password,
        Database = "shop",
    }.ConnectionString;

    public Task InitializeAsync() => _postgres.StartAsync();

    public Task DisposeAsync() => _postgres.DisposeAsync().AsTask();
}

Test korzysta z niej przez IClassFixture i ma jedną asercję: w tabeli orders, którą zakłada i wypełnia db/init.sql, są trzy zamówienia.

public sealed class OrdersTests : IClassFixture<PostgresFixture>
{
    private readonly PostgresFixture _db;

    public OrdersTests(PostgresFixture db) => _db = db;

    [Fact]
    public async Task Seeded_orders_are_readable()
    {
        await using var connection = new NpgsqlConnection(_db.ConnectionString);
        await connection.OpenAsync();

        await using var command = new NpgsqlCommand("SELECT count(*) FROM orders", connection);
        var count = (long)(await command.ExecuteScalarAsync())!;

        Assert.Equal(3, count);
    }
}

Kilka rzeczy jest tu celowych. Używam gołego ContainerBuilder, a nie modułu Testcontainers.PostgreSql, bo teza dotyczy kodu, który ludzie już mają, a nie kodu napisanego pod chmurę. Port na hoście losuje biblioteka, a connection string składam z Hostname i GetMappedPublicPort(5432), czyli pytam runtime, gdzie wystawił kontener. W styczniu 2021 przy gołym docker run wpisywałem do konfiguracji localhost:55432 i to był dobry wybór dla jednego demona na jednej maszynie. Tutaj sztywny adres przestałby działać w chwili, w której demon wyjedzie z mojej maszyny.

Czekanie ma jawny limit dwóch minut, bo domyślny limit strategii czekania w 3.9.0 to godzina, a na workerze rozliczanym w minutach godzina wiszenia na zepsutym kontenerze ma konkretną cenę. pg_isready pytam przez 127.0.0.1, a nie przez socket unixowy, bo obraz Postgresa na czas wykonywania skryptów z docker-entrypoint-initdb.d uruchamia tymczasowy serwer, który słucha wyłącznie na sockecie. Bez --host sprawdzenie gotowości potrafi odpowiedzieć “gotowe” w trakcie ładowania init.sql. W pętli gotowości z lutego 2021 tego parametru nie miałem i dziś bym go dopisał.

Desktop vs CI

Na laptopie pracuję na Linuksie z Docker Engine, więc punktem odniesienia jest przebieg na lokalnym demonie. Żeby zobaczyć, z czym biblioteka się połączyła, uruchamiam testy z loggerem konsoli na poziomie normal. Przy domyślnym poziomie dotnet test nie pokazuje tego, co Testcontainers pisze na standardowe wyjście, a tam jest najważniejsza linia tego wpisu:

$ dotnet test -c Release --logger "console;verbosity=normal"
[testcontainers.org 00:00:00.04] Connected to Docker:
  Host: unix:///var/run/docker.sock
...
[testcontainers.org 00:00:00.36] Copy tar archive to container: Content length: 259 byte(s), Docker container: 5489a53300e7
[testcontainers.org 00:00:00.38] Start Docker container 5489a53300e7
[testcontainers.org 00:00:00.49] Execute "pg_isready --host 127.0.0.1 --dbname shop --username postgres" at Docker container 5489a53300e7
[testcontainers.org 00:00:01.56] Execute "pg_isready --host 127.0.0.1 --dbname shop --username postgres" at Docker container 5489a53300e7
[testcontainers.org 00:00:01.64] Docker container 5489a53300e7 ready
  Passed Orders.IntegrationTests.OrdersTests.Seeded_orders_are_readable [124 ms]

Blok Connected to Docker wypisuje też wersję serwera, system i pamięć demona, ale na tym przebiegu interesuje mnie jedna linia: Host: unix:///var/run/docker.sock. To jest mój demon, mój dysk i mój RAM. Widać też, że init.sql trafia do kontenera jako archiwum tar, zanim kontener wystartuje, i że pg_isready za pierwszym razem odpowiedział “jeszcze nie”.

Potem w menu Desktopu wybieram “Run with Testcontainers Cloud”. Dokumentacja Desktopu obiecuje dokładnie to, czego szukam: kontenery w chmurze na żądanie, bez zużywania lokalnych zasobów i bez lokalnego demona. Dla osób na Macach z procesorem ARM dochodzi argument, który mnie na Linuksie nie dotyczy: worker ma zawsze architekturę Linux x86, zwykle taką samą jak produkcja. Darmowy plan dla pojedynczej osoby daje 300 minut chmury miesięcznie, bez trybu Turbo, i na repozytorium z przykładami do bloga to wystarcza.

Pierwsza wersja PostgresFixture tej zmiany by nie przeżyła i to jest najciekawsza część tego wpisu. Zamiast kopiować init.sql, montowała go z dysku:

.WithBindMount(Path.GetFullPath("db/init.sql"), "/docker-entrypoint-initdb.d/init.sql")

Lokalnie to działa, bo demon czyta plik z tego samego dysku, na którym leży projekt. Na Cloud działać nie może, bo plik jest na moim dysku, a kontener powstaje na workerze, który tego dysku nie widzi. Dokumentacja Cloud mówi to jednym zdaniem: montowanie plików z lokalnego systemu plików nie jest zaimplementowane, zamiast tego trzeba kopiować pliki do kontenera. W .NET kopiowaniem jest WithResourceMapping. Biblioteka pakuje plik do archiwum i wysyła je przez API Dockera między utworzeniem a startem kontenera, więc działa na każdym demonie, z którym ma połączenie.

Mechanizm sprawdziłem bez chmury, bo chciałem wiedzieć, co dokładnie pada i dlaczego. Postawiłem drugiego demona Dockera z obrazu docker:26.1-dind, wystawionego na TCP z przekierowanym zakresem portów, i wpisałem jego adres do tc.host, zostawiając DOCKER_HOST ustawione na lokalny socket. Biblioteka połączyła się z adresem z tc.host, a nie z DOCKER_HOST, i w bloku Connected to Docker pokazała Host: tcp://127.0.0.1:23750/. Obraz bazy pobrała od nowa, na drugim demonie, choć lokalny miał go w cache. Wariant z kopiowaniem przeszedł. Wariant z montowaniem padł przy tworzeniu kontenera, zanim Postgres w ogóle wystartował:

Docker.DotNet.DockerApiException : Docker API responded with status code=BadRequest,
response={"message":"invalid mount config for type \"bind\": bind source path does not exist:
.../bin/Release/net8.0/db/init.sql"}

Ten demon nie jest Testcontainers Cloud i nie udaję, że odtwarza go w szczegółach; komunikat na workerze może brzmieć inaczej. Odtwarza za to dwie rzeczy, które mnie tu interesują: demon, który nie dzieli ze mną systemu plików, nie zobaczy mojego init.sql, a tc.host wygrywa z DOCKER_HOST.

Ostatnia obserwacja z tego eksperymentu jest najważniejsza. Kiedy pod adresem z tc.host przestało cokolwiek nasłuchiwać, biblioteka bez ostrzeżenia w logu wróciła do lokalnego socketu i test przeszedł. Zielony wynik, inny runtime. Wrócę do tego przy CI.

Z poprawioną linią ten sam test przechodzi i na lokalnym demonie, i na drugim, a kopiowanie jest dokładnie tą drogą, którą dokumentacja Cloud wskazuje zamiast montowania. Na Cloud linia Host: pokazuje adres tcp://, który agent wpisał do tc.host, a pola serwera opisują workera, nie mój laptop. Przykładowy log w dokumentacji Cloud ma w polu wersji serwera dopisek testcontainerscloud, więc chmurę od lokalnego demona odróżnię po samym logu, bez zaglądania do menu Desktopu. Test i asercja nie zmieniły się wcale. Tak rozumiem “bez przepisywania”: nie zero linii w diffie, tylko zero zmian w tym, co test sprawdza. Linia, którą zmieniłem, i tak była długiem, bo zakładała, że demon i projekt dzielą dysk.

Na CI kontrast z 2021 rokiem jest większy niż na laptopie. Listopadowy YAML stał na tym, że job widzi socket demona na hoście agenta: dlatego job szedł na host, a nie do container:, i dlatego ostrzegałem przed mapDockerSocket: false. Agent Cloud socketu nie potrzebuje, a strona produktu obiecuje, że na CI nie wymaga uprawnień systemowych. To ma znaczenie tam, gdzie Dockera na agencie nie ma, na przykład na agencie self-hosted uruchomionym w kontenerze. Zostawały tam dotąd dwie drogi i obie wymagają rozmowy z administratorem: Docker-in-Docker w trybie uprzywilejowanym albo socket hosta zamontowany do kontenera agenta. Hostowany agent Microsoftu ma Dockera na pokładzie, więc zysk jest inny: kontenery przestają dzielić z buildem i procesem testowym dwa rdzenie, 7 GB RAM i 14 GB dysku SSD, a sesja w chmurze ma dla nich osobne 8 GB.

Job integracyjny w Azure Pipelines wygląda u mnie tak:

trigger:
  - main

pr: none

pool:
  vmImage: ubuntu-22.04

variables:
  buildConfiguration: Release

steps:
  - task: UseDotNet@2
    displayName: SDK
    inputs:
      packageType: sdk
      version: 8.0.x

  - script: bash ci/tcc-agent.sh
    displayName: Agent Testcontainers Cloud
    timeoutInMinutes: 2
    env:
      TC_CLOUD_TOKEN: $(TC_CLOUD_TOKEN)

  - task: DotNetCoreCLI@2
    displayName: dotnet test
    inputs:
      command: test
      projects: "**/*IntegrationTests.csproj"
      arguments: '--configuration $(buildConfiguration) --logger trx --logger "console;verbosity=normal" --results-directory $(Agent.TempDirectory)'

Token service account jest zmienną sekretną pipeline’u i trafia tylko do kroku agenta. Azure sam z siebie nie wystawia sekretów skryptom jako zmiennych środowiskowych, trzeba je zmapować w env:, i dobrze, bo dotnet test tego tokena nie potrzebuje. Dokumentacja Cloud pokazuje ten krok jako Bash@3 z jednolinijkowcem, w którym jest $(curl ...). To działa, bo Azure zostawia w spokoju wyrażenie $( ), które nie jest nazwą zmiennej, ale od sierpnia 2022 nie trzymam w YAML-u Azure składni, którą najpierw czyta podstawianie zmiennych, a dopiero potem powłoka. Instalacja mieszka więc w ci/tcc-agent.sh. Skrypt pobiera agenta, uruchamia go w tle z flagą --terminate i blokuje krok wbudowaną komendą wait, dopóki agent nie połączy się z Testcontainers Cloud. Flagę --terminate dokumentacja opisuje właśnie z myślą o CI: workery kończą się razem z agentem, zamiast czekać na limit bezczynności.

Krok agenta ma limit dwóch minut i nie ma warunku na obecność tokena. Jeżeli tokena nie ma albo agent nie połączy się z usługą, chcę czerwonego kroku, a nie zielonego przebiegu na Dockerze agenta. Po eksperymencie z laptopa wiem, że biblioteka po cichu wróciłaby do lokalnego socketu, a na hostowanym agencie lokalny socket istnieje. Taki przebieg przeszedłby i nikt by nie zauważył, gdzie stały kontenery.

Logger konsoli na poziomie normal zostawia w logu przebiegu blok Connected to Docker i to on zastępuje dwa kroki, które od 2021 roku stały w moim jobie integracyjnym. Krok “Co ma agent” z docker version opisywałby teraz lokalnego demona agenta, a nie runtime, na którym startują kontenery testu. Krok “Pull obrazu bazy” ściągałby obraz do Dockera agenta, z którego test nie korzysta, bo pull dzieje się na workerze. Oba usunąłem z tego joba. Czas pobierania obrazu widać teraz w logu biblioteki, przy linii Docker image postgres:16.3-alpine created, która pojawia się tylko wtedy, gdy obraz był pobierany.

Job e2e z sierpnia 2022 zostaje bez zmian. Aplikację stawia w nim docker compose, a Playwright, dziś w 1.45.1, przechodzi przez UI na Dockerze agenta. Cloud dotyczy kontenerów, które tworzy biblioteka, a nie komend Dockera wpisanych w YAML. Agenta Cloud nie uruchamiam też w jobach, które nie mają testów na Testcontainers, bo dokumentacja wprost ostrzega, że worker zostanie wtedy przydzielony i będzie czekał bezczynnie.

Kiedy nie włączam

Najczęstsza odpowiedź brzmi: przy małym zestawie. Jeden Postgres i kilkadziesiąt testów integracyjnych mieszczą się na hostowanym agencie z zapasem, a Docker jest tam od zawsze. Cloud dokłada do takiego joba sekret, skok sieciowy i kolejnego dostawcę, w zamian za zasoby, których nie brakowało. Lokalnie jest tak samo: jeżeli laptop podnosi zestaw bez zadyszki, chmura rozwiązuje problem, którego nie mam.

Drugi powód to kształt kosztu. Na CI dokumentacja liczy minuty workera od chwili przydzielenia do chwili, w której worker zostanie zamknięty po okresie bezczynności, stąd --terminate w moim skrypcie. Na desktopie płaci się za osobę, która z niego korzysta, niezależnie od liczby minut, w granicach polityki fair use. Cen ani minut z żadnego konta tu nie podaję. Opisuję model rozliczeń z dokumentacji, którą czytam w lipcu, i jeżeli Docker go zmieni, zestarzeje się ten akapit, a nie reszta wpisu.

Trzeci powód to trigger. We wrześniu 2022 rozdzieliłem testy na trzy koszyki i integracja z kontenerami trafiła do main i do nocy, a nie do każdego pull requesta. Cloud nie zasługuje na osobny trigger “bo chmura”. Zmienia to, gdzie stoją kontenery w koszyku, do którego i tak należą. Bramka na pull requeście zostaje taka, jaka była, i agent Cloud do niej nie wchodzi.

Czwarty powód to nadzieja, że Turbo zrobi równoległość za mnie. Tryb Turbo daje osobne środowisko w chmurze każdemu procesowi testowemu, a nie każdemu testowi. W .NET procesem jest host testowy VSTest, uruchamiany osobno dla każdego assembly, więc mój jeden projekt testowy dostanie jednego workera, nawet jeśli xUnit puszcza klasy równolegle w tym procesie. Turbo jest poza planem darmowym, a --max-concurrency ma domyślnie wartość 4, czyli do czterech workerów naraz, z których każdy liczy minuty osobno. Dokumentacja sama radzi obniżyć tę wartość, jeżeli czas przebiegu się od tego nie zmienia.

Piąty powód jest najważniejszy, bo dotyczy tego, czego Cloud nie zmienia. Zestaw, który migocze lokalnie, będzie migotał w chmurze. W październiku 2021 nazwałem wspólną bazę zabójcą numer trzy i to zdanie nie zależy od tego, na której maszynie stoi Postgres. Testy w jednej klasie dzielą tu jeden kontener, więc dzielą dane, i każdy z nich musi zakładać własne wiersze albo sprzątać wyłącznie po sobie. Cloud przenosi bazę na inną maszynę. Nie rozdziela danych i nie zwalnia mnie z izolacji.

Szósty powód dotyczy projektów, które nie są moje. Zestaw klienta uruchomiony w Cloud oznacza, że jego obrazy, dane testowe i poświadczenia do rejestru przechodzą przez infrastrukturę, na którą klient się nie umawiał. To jest jego decyzja, a nie moja, i nie zapada po cichu. Ponieważ w .NET nie ma przełącznika na projekt, na maszynie, na której uruchamiam zestaw klienta, Desktop nie stoi w trybie chmury.

Pułapki lipca

Żadna z poniższych rzeczy nie jest powodem, żeby Cloud odrzucić. Każda jest powodem, żeby przeczytać log, zanim uzna się czerwony przebieg za flaka.

Sieć do workera. Każde zapytanie testu do bazy idzie teraz przez tunel agenta, a nie po pętli zwrotnej mojej maszyny. Dokumentacja Cloud mówi, że opóźnienie połączenia powinno być poniżej 20 ms, a Desktop pokazuje ostatni pomiar w podmenu połączenia. Test, który wykonuje kilka zapytań, tego nie odczuje. Zestaw, który przy każdym teście wysyła setki drobnych zapytań, zapłaci to opóźnienie przy każdym z nich. Dochodzi też nowe miejsce, w którym może pęknąć połączenie, a zerwany tunel w połowie testu wygląda w logu jak błąd bazy. Przy czerwonym przebiegu na Cloud najpierw czytam blok Connected to Docker, a dopiero potem SQL.

Zimny pull. Obraz jest pobierany na workerze, nie na agencie i nie przez moje łącze. Cache obrazów na agencie self-hosted, który przez lata oszczędzał minuty, przestaje tu pomagać. Dokumentacja radzi, żeby przy pierwszej próbie uruchomić testy dwa razy pod rząd i poczuć rozgrzane środowisko. Na laptopie to uczciwa rada. Na CI każdy przebieg startuje nowego agenta, a z --terminate worker kończy się razem z nim, więc na rozgrzany cache między przebiegami nie liczę. Pierwszy pull jest częścią ceny każdego przebiegu, dokładnie tak jak na hostowanym agencie w 2021 roku.

Prywatny rejestr. Worker widzi rejestry wystawione do internetu, takie jak Docker Hub. Rejestru firmowego, który do internetu nie wychodzi, nie widzi. Dokumentacja opisuje na to proxy rejestru konfigurowane osobno na każdej maszynie z agentem, wyłącznie do pobierania, z listą dozwolonych nazw obrazów. Opisuje też dwie rzeczy, które trzeba powiedzieć właścicielowi rejestru, zanim się to włączy. Poświadczenia do rejestrów są widoczne dla biblioteki i dla Testcontainers Cloud: przechodzą przez usługę, ale nie są w niej przechowywane. Obrazy z prywatnego rejestru zostają w maszynie wirtualnej workera, dopóki ta nie zostanie usunięta po mniej więcej 30 minutach bezczynności. To jest rozmowa, a nie przełącznik.

Sieć w drugą stronę. Kontener na workerze nie widzi mojego laptopa. Usługa uruchomiona lokalnie obok testów, serwis za VPN-em i nazwa hosta, którą rozwiązuje firmowy DNS, po stronie workera nie istnieją, bo hostem kontenera jest worker, a nie moja maszyna. Za pierwszym razem wygląda to jak flaki DNS. Flakiem nie jest: nazwa, której worker nie rozwiąże, nie rozwiąże się nigdy, i to akurat dobra wiadomość, bo błąd jest powtarzalny. Testcontainers dla .NET ma w 3.9.0 mechanizm wystawiania kontenerom portów hosta, TestcontainersSettings.ExposeHostPortsAsync, ale nie sprawdzałem go z Cloud i nie polecam go w ciemno.

Pamięć. Każda sesja w chmurze dostaje 8 GB RAM i według dokumentacji nie da się tego ani zwiększyć, ani ograniczyć. To więcej, niż ma cały hostowany agent, ale to jest sufit jednej sesji, a nie nieskończona chmura, i bez trybu Turbo wszystkie kontenery z danego agenta trafiają do jednego środowiska. Zestaw, który podnosi naraz bazę, kolejkę i kilka usług, musi się w tym zmieścić.

Sekret. Token service account jest sekretem jak każdy inny i ma w pipelinie jedno miejsce: krok agenta. Pull requesty z forków go nie dostają i nie muszą, bo job z agentem chodzi na main. W GitHub Actions dokumentacja daje gotową akcję z warunkiem if: env.TC_CLOUD_TOKEN != '' i osobno ostrzega przed jobami uruchamianymi przez Dependabota, które nie widzą zwykłych sekretów repozytorium. Ten warunek znaczy, że bez sekretu krok agenta zostaje pominięty, a testy po cichu lecą na Dockerze runnera. Dla pull requestów z forków to rozsądne zachowanie, pod warunkiem że ktoś czyta linię Host:.

Telemetria Desktopu. Desktop śledzi sesje testowe, także te na lokalnym runtime, i zbiera je w panelu, który łączy dane z desktopu i z CI od wszystkich osób w organizacji. Panel odpowiada między innymi na pytanie, jakich obrazów zespół używa najczęściej. Na repozytorium z przykładami do bloga nie mam z tym problemu. Na maszynie, na której chodzi zestaw klienta, to jest pytanie do klienta przed instalacją, tego samego rodzaju co pytanie, gdzie mają stać jego kontenery.

Na koniec rzecz, która w lipcu wraca w każdej rozmowie, a do tego wpisu nie należy. Model językowy nie jest tu tematem. GPT-4o jest dostępny od 13 maja, Claude 3.5 Sonnet od 21 czerwca i żaden z nich nie zmienia tego, gdzie stoi demon Dockera ani kto płaci za minuty workera.

Podsumowanie

Testcontainers Cloud jest w lipcu 2024 narzędziem na ciężkie zależności i ciasny runner, a nie nowym sposobem pisania testów. Kanon bloga zostaje: kod definiuje kontener, izolacja idzie najpierw, trigger potem, a runtime wybieram na końcu i poza kodem.

Trzy zdania, które chcę móc przeczytać za rok. Testcontainers 3.9.0 wybiera runtime poza kodem testu: tc.host z katalogu domowego wygrywa z DOCKER_HOST, a kiedy nie odpowiada, biblioteka bez ostrzeżenia wraca do lokalnego socketu. Przygotowanie syntetycznego zestawu pod chmurę kosztowało jedną linię, WithBindMount zamienione na WithResourceMapping, bo worker nie widzi mojego dysku, a test i asercja zostały nietknięte. Na CI Cloud zdejmuje z agenta potrzebę socketu i uprawnień, ale kosztuje minuty workera, sekret i skok sieciowy, więc trafia tylko do joba na main, który naprawdę uruchamia testy na Testcontainers.

Czego z tego nie wynika. Nie wynika, że kasuję Dockera z agentów: job e2e dalej stawia aplikację przez Compose, a plik compose nie stał się zbędny. Nie wynika, że chmura zwalnia z izolacji, bo wspólna baza psuje testy na workerze tak samo jak na laptopie. Nie wynika, że Cloud zastępuje biblioteki open source, bo bez nich nie byłoby czego zrzucać. I nie wynika z tego żadna data GA.

Rozliczenie kosztów z grudnia 2022 też zostaje bez zmian. Host w pamięci procesu biorę na logikę warstwy API, kontener na to, o czym SQL wie więcej niż moje wyobrażenie o SQL-u, kontrakt na granicę z cudzym systemem, a przeglądarkę na to, co widzi człowiek. Cloud nie dopisuje do tej listy piątej pozycji. Przenosi drugą na inną maszynę, kiedy moja przestaje wystarczać.

Warunek, po którym Cloud stanie się domyślnym runtime’em mojego joba integracyjnego, a nie opcją, formułuję tak. Dwa przebiegi tej samej klasy PostgresFixture, lokalny i w chmurze, dają ten sam wynik bez żadnej gałęzi w kodzie. Job pada, zamiast po cichu wrócić na Dockera agenta, kiedy agent się nie połączy. Minuty workera na przebieg są widoczne i tańsze niż czas tracony na zbyt ciasnym agencie. A jeśli zestaw należy do klienta, klient wie, gdzie stoją jego kontenery, i się na to zgodził. Pod pierwsze dwa warunki kod repozytorium z przykładami jest gotowy już dziś: fixture nie ma gałęzi zależnej od runtime’u, a krok agenta nie ma warunku na token. Dwóch pozostałych nie sprawdzę na syntetycznym projekcie, więc do tego czasu zostaję przy kanonie z 2021 roku wszędzie tam, gdzie agent udźwignie obrazy, a Cloud włączam tam, gdzie nie udźwignie.