Kwiecień był lokalnie - listopad jest CI
W kwietniu opisałem, jak uruchamiam bazę z poziomu kodu testu. Cały ten wpis dział się na mojej maszynie, gdzie Docker po prostu jest, a docker version odpowiada od razu. Pytanie, które od tamtej pory dostaję najczęściej, brzmi inaczej: dobrze, ale czy to przejdzie na CI.
Ten post jest odpowiedzią na to pytanie i tylko na nie. Nie powtarzam tu kodu fixture, nie pokazuję drugi raz budowania kontenera bazy i nie tłumaczę, po co w ogóle podnosić zależność z testu. To wszystko jest pod linkiem wyżej. Tutaj bohaterem jest plik azure-pipelines.yml i demon Dockera na agencie.
Od kwietnia zmieniła się jedna rzecz po stronie biblioteki, którą warto odnotować: DotNet.Testcontainers ma stabilne 1.5.0 (lipiec 2021). Pakiet nazywa się nadal DotNet.Testcontainers, repozytorium to HofmeisterAn/dotnet-testcontainers, i to jest wersja, na której robię ten przebieg. W pliku projektu mam ją przypiętą wprost:
<PackageReference Include="DotNet.Testcontainers" Version="1.5.0" />Bez zakresu, bez gwiazdki. Jeżeli test integracyjny wywali się na CI, chcę wiedzieć, czy zmienił się mój kod, czy zmieniła się biblioteka pod spodem. Przy wersji z gwiazdką nigdy nie mam tej pewności.
Jeszcze jedna uwaga na wstępie, żeby nie było niedomówień. Tydzień temu wyszło .NET 6. To nie jest post o migracji SDK i specjalnie nie przełączam projektu na szóstkę tylko po to, żeby zrobić ten przebieg. Zostaję na tym, co już mam skonfigurowane.
Microsoft-hosted ubuntu-20.04
Najtańsza wersja tej całej historii wygląda tak: na agencie hostowanym przez Microsoft na Linuksie Docker jest zainstalowany i uruchomiony, więc DotNet.Testcontainers ma z kim rozmawiać. Nie muszę instalować niczego dodatkowego, nie muszę podnosić usługi, nie muszę kombinować z uprawnieniami użytkownika.
Wybieram jawnie ubuntu-20.04, a nie ubuntu-latest. Dzisiaj to jest ten sam obraz, bo ubuntu-latest wskazuje już na 20.04 po migracji z przełomu 2020 i 2021 roku. Ale latest to jest ruchomy wskaźnik dokładnie w tym samym sensie, w jakim latest jest ruchomym wskaźnikiem przy tagu obrazu. Kiedyś przeskoczy na kolejne LTS-y i zrobi to bez mojego udziału. Wolę zmienić jedną linijkę świadomie niż tłumaczyć zespołowi, dlaczego pipeline zaczął się sypać w poniedziałek rano.
Zanim cokolwiek zbuduję, każę agentowi powiedzieć, z czym pracuję:
- script: |
docker version
docker info --format '{{.OperatingSystem}}'
displayName: Co ma agentTen krok kosztuje sekundę, a oszczędza godzinę zgadywania. W logu przebiegu widzę wersję klienta i serwera Dockera oraz system operacyjny demona. Nie przepisuję tutaj konkretnego numeru Engine jako faktu, bo obraz agenta jest odświeżany regularnie i wersja, którą ja mam dzisiaj, nie musi być tą, którą Ty zobaczysz za dwa tygodnie. Ważne jest to, że ta linijka w ogóle się pojawia, bo to znaczy, że demon odpowiada. Jeżeli krok kończy się błędem połączenia z socketem, to nie ma sensu czytać dalej stacktrace’u z testów, bo cały problem jest tutaj.
docker info z filtrem na system operacyjny daje mi drugą ważną informację: to jest demon linuksowy, więc obrazy, które podnosi test, też muszą być linuksowe.
Minimalny azure-pipelines.yml bez container job
Tak wygląda całość, bez skracania. Jeden job, na hoście agenta, bez sekcji container::
trigger:
- main
pr:
- main
pool:
vmImage: ubuntu-20.04
variables:
buildConfiguration: Release
steps:
- task: UseDotNet@2
displayName: SDK
inputs:
packageType: sdk
version: 5.0.x
- script: |
docker version
docker info --format '{{.OperatingSystem}}'
displayName: Co ma agent
- task: DotNetCoreCLI@2
displayName: dotnet restore
inputs:
command: restore
projects: "**/*.csproj"
- task: DotNetCoreCLI@2
displayName: dotnet test
inputs:
command: test
projects: "**/*Tests.csproj"
arguments: "--configuration $(buildConfiguration) --logger trx --results-directory $(Agent.TempDirectory)"To jest cały mechanizm. Nie ma tu kroku, który podnosi bazę, nie ma docker run przed testami, nie ma docker-compose up. Kontener podnosi test, tak samo jak u mnie lokalnie, a rolą pipeline’u jest wyłącznie dostarczyć mu działającego demona i SDK.
Warto zauważyć, czego tu nie ma. Nie ma zadania Docker@2 ani DockerInstaller@0. Zadanie Docker@2 służy do budowania i pushowania obrazów w ramach pipeline’u, a ja nie buduję żadnego obrazu. Potrzebuję tylko socketu, który już jest.
Krok dotnet test uruchamiam przez DotNetCoreCLI@2, a nie przez goły script, głównie dlatego że dostaję za darmo publikację wyników. Wrócę do tego na końcu.
Dlaczego windows-2019 nie odpali obrazu Linuxowego
To jest pierwsza rzecz, o którą się potknąłem, kiedy próbowałem wcisnąć te testy do istniejącego pipeline’u, który budował się na Windowsie.
Kusi, żeby po prostu zmienić vmImage na windows-2019, bo tam też jest Docker. Problem polega na tym, że demon Dockera pracuje w jednym trybie naraz. Na hostowanym agencie Windows domyślnym trybem są kontenery Windows, a obraz bazy, który podnosi mój test, jest obrazem linuksowym. Efekt jest taki, że pull kończy się komunikatem o niezgodności platformy obrazu z platformą hosta. To nie jest kwestia konfiguracji Testcontainers ani wersji pakietu, tylko tego, co siedzi po drugiej stronie socketu.
Na własnej maszynie z Docker Desktop tego problemu nie widać, bo przełącznik między kontenerami Windows a Linux jest jednym kliknięciem w menu i większość z nas siedzi na trybie linuksowym od instalacji. Na agencie nie mam tego kliknięcia i nie chcę budować pipeline’u wokół przełączania trybu demona.
Wniosek jest krótki: job, który podnosi linuksowe kontenery z testu, ma stać na linuksowym agencie. Jeżeli mój produkt buduje się na Windowsie, to rozdzielam to na dwa joby w tym samym pipelinie - budowanie tam, gdzie musi, i testy integracyjne na ubuntu-20.04. To jest znacznie prostsze niż walka z platformą obrazów.
Container job vs Testcontainers: socket, mapDockerSocket, localhost
Druga pułapka jest ciekawsza i kosztowała mnie więcej czasu.
Azure Pipelines pozwala uruchomić cały job wewnątrz kontenera. Wystarczy sekcja container: i mam powtarzalne środowisko z konkretną wersją SDK, niezależne od tego, co akurat jest wgrane na obraz agenta:
pool:
vmImage: ubuntu-20.04
container:
image: mcr.microsoft.com/dotnet/sdk:5.0
steps:
- script: dotnet test --logger trx
displayName: dotnet test w container jobWygląda niewinnie i w wielu projektach jest to sensowny wzorzec. Tylko trzeba rozumieć, co się wtedy dzieje, bo to nie jest zagnieżdżony Docker. Wewnątrz mojego job containera nie startuje drugi demon. Agent montuje do niego socket hosta, czyli /var/run/docker.sock, i dzięki temu klient Dockera w środku steruje demonem, który stoi obok, na hoście. Kontenery, które podnosi mój test, są rodzeństwem job containera, a nie jego dziećmi.
Z tego wynikają dwie konsekwencje, które trzeba mieć z tyłu głowy.
Pierwsza: to działa dopóki socket jest zamontowany. W schemacie YAML jest właściwość mapDockerSocket i ustawienie jej na false wyłącza ten mount. Jeżeli ktoś w zespole doda tę linijkę z powodów bezpieczeństwa, testy przestaną się uruchamiać z błędem połączenia z demonem, a przyczyna będzie kilka poziomów dalej niż miejsce, w którym wybuchło.
container:
image: mcr.microsoft.com/dotnet/sdk:5.0
mapDockerSocket: false # to psuje TestcontainersDruga konsekwencja jest bardziej podstępna, bo nic tu nie wybucha na starcie. Testcontainers domyślnie składa adres do kontenera z localhost i portu opublikowanego na hoście. To założenie jest prawdziwe, kiedy test biegnie na hoście. W container jobie localhost mojego procesu testowego to jest sieciowa przestrzeń nazw job containera, a nie hosta, więc port opublikowany przez rodzeństwo jest gdzie indziej. Test przechodzi przez start kontenera, a potem wisi na połączeniu i kończy się timeoutem, który wygląda jak flaky test, a nim nie jest.
Da się to obejść: łącząc oba kontenery wspólną siecią użytkownika i łącząc się po nazwie kontenera zamiast po localhost (1.5.0 dorzuciło do buildera obsługę sieci), albo celując w adres bramy hosta zamiast w localhost. Obie ścieżki działają i obie wymagają, żeby ktoś w zespole pamiętał, dlaczego connection string wygląda inaczej na CI niż lokalnie.
Dlatego moja rekomendacja jest jednoznaczna: dla testów z Testcontainers zostaję przy ścieżce z poprzedniej sekcji, czyli job stoi na hoście agenta. Container job jest dobrym narzędziem, ale w tym konkretnym połączeniu dokłada warstwę sieciową, za którą nie dostaję nic w zamian. Wersję SDK potrafię przypiąć zadaniem UseDotNet@2, a to był jedyny powód, dla którego w ogóle sięgałem po container:.
Na marginesie, hostowane runnery u innego dostawcy CI mają dokładnie ten sam model z Dockerem na hoście, więc rozumowanie się przenosi. Ten post jest jednak o Azure Pipelines i tam zostaję.
Czas pulla, pin tagów, sprzątanie
Lokalnie obraz bazy ściągam raz i potem tylko go używam. Agent hostowany działa inaczej: dostaję świeżą maszynę, więc każdy przebieg jest zimnym startem i każdy przebieg ściąga obraz od nowa. To nie jest awaria, to jest cena wejścia, ale trzeba ją policzyć zamiast się nią zdziwić.
Mierzę to nie na oko, tylko z logu. Osobny krok przed testami pokazuje mi, ile z całkowitego czasu joba zjada samo pobranie obrazu:
- script: |
date -u +"start pull: %H:%M:%S"
docker pull $(dbImage)
date -u +"koniec pull: %H:%M:%S"
displayName: Pull obrazu bazyTen krok nie jest wymagany do działania testów, bo biblioteka sama pobierze obraz przy starcie kontenera. Jest wymagany do rozmowy z zespołem, kiedy ktoś pyta, dlaczego pipeline trwa dłużej niż wcześniej. Mając tę liczbę osobno, wiem, czy szukać oszczędności w testach, czy w obrazie.
Tag przypinam tak samo, jak przypinałem go w styczniu przy zwykłym docker run. Wersja obrazu bazy siedzi u mnie w jednym miejscu w konfiguracji testów, a w pipelinie ląduje w zmiennej, żeby krok pobierania i test mówiły o tym samym. latest na CI to jest zaproszenie do sytuacji, w której zielony przebieg z wtorku i czerwony ze środy różnią się czymś, czego nie ma w historii repozytorium.
Sprzątanie na agencie hostowanym jest w praktyce darmowe, bo cała maszyna znika po zakończeniu joba. Nie znaczy to jednak, że mogę nie zamykać kontenerów w kodzie testu. Po pierwsze, ten sam zestaw uruchamiam lokalnie, a tam nikt mi maszyny nie kasuje. Po drugie, prędzej czy później część przebiegów wyląduje na agencie self-hosted, gdzie porzucone kontenery zostają na dysku razem z zajętymi portami. Kontener ma być zamknięty przez ten sam kod, który go otworzył, a nie przez sprzątaczkę na końcu pipeline’u.
Na agencie self-hosted dokładam jeszcze krok kontrolny, który wykonuje się zawsze, także po nieudanych testach:
- script: |
docker ps -a
docker container prune -f
condition: always()
displayName: Co zostało po przebieguSam docker ps -a w logu jest tu ważniejszy niż prune, bo pokazuje mi, czy w ogóle mam wyciek, zamiast po cichu go zamiatać.
I jedna rzecz, której ten wpis nie naprawia. Testy integracyjne z prawdziwą bazą potrafią być niestabilne z powodu wyścigu między zapisem a odczytem, i to jest problem niezależny od tego, gdzie stoi kontener. Opisałem go osobno przy okazji polityki ponawiania. Jeżeli po przeniesieniu na CI widzisz losowe czerwone przebiegi, sprawdź najpierw, czy to nie jest ten wzorzec, zanim zaczniesz obwiniać agenta.
Publish Test Results zostawiam jako znany mechanizm ADO
Świadomie nie robię z publikacji wyników drugiego tematu w tym poście.
Zadanie DotNetCoreCLI@2 w trybie test samo publikuje wyniki do zakładki Tests, dlatego w moim YAML-u jest tylko --logger trx i katalog na pliki. Jeżeli uruchamiam testy gołym script, dokładam PublishTestResults@2 z formatem VSTest i wskazuję te same pliki .trx, koniecznie z condition: always(), bo wyników z czerwonego przebiegu potrzebuję bardziej niż z zielonego.
To jest mechanizm Azure DevOps, który działa tak samo od dawna i nie zmienia się przez to, że kontener podnosi test zamiast pipeline’u. Zakładka Tests nie wie i nie musi wiedzieć, skąd wzięła się baza. Dla mnie to jest dowód, że przeniesienie Testcontainers na CI zrobiłem dobrze: raport wygląda dokładnie tak samo jak wcześniej, a zniknął cały kawałek konfiguracji, w którym pipeline musiał ręcznie stawiać zależności.
Gdybym ten sam zestaw zależności składał deklaratywnie obok testów, wróciłbym do pliku compose. Różnica jest taka, że wtedy to pipeline odpowiada za cykl życia środowiska, a przy Testcontainers odpowiada za niego test. Obie drogi są uczciwe, ale trzeba wybrać jedną, a nie prowadzić obu naraz.
Na koniec przypomnienie, które sobie powtarzam za każdym razem, gdy coś przenoszę na CI: samo uruchomienie testów na agencie nie poprawia jakości ani o krok. Poprawia ją to, że wynik jest szybki, powtarzalny i czytany przez zespół, czyli proces wokół testów. Docker na agencie jest tylko po to, żeby ten wynik pojawiał się bez proszenia kogokolwiek o dostęp do wspólnej bazy.

