Wszystkie wpisy

Docker Compose do stawiania środowisk testowych
Docker Compose do stawiania środowisk testowych

English

Docker Compose do stawiania środowisk testowych

Jak w lutym 2021 składam kilka zależności testowych w docker-compose 1.28: profiles, depends_on, up i sprzątanie wolumenów.

QA

Kiedy jeden kontener to za mało

Miesiąc temu pisałem o tym, jak stawiam pojedynczą zależność testową Dockerem. Jedna baza, jedno docker run, jeden skrypt czekający na gotowość. To wystarcza dokładnie tak długo, jak długo aplikacja pod testem ma jedną zależność.

U mnie skończyło się to szybciej, niż myślałem. Scenariusz, który chciałem zautomatyzować, wyglądał tak: aplikacja czyta z Postgresa, a sesje i koszyk trzyma w Redisie. Test regresji na wylogowaniu nie ma sensu bez obu tych rzeczy naraz. Nagle zamiast jednej komendy miałem trzy, każdą z własnym zestawem flag, i skrypt bashowy, który zaczynał wyglądać jak mały system operacyjny: kolejność startu, nazwy, sieć, sprzątanie po błędzie w połowie.

W tym momencie przestaję pisać skrypt i sięgam po plik. Deklaratywny opis środowiska ma tę przewagę, że nie muszę pamiętać kolejności komend ani ich odtwarzać w głowie. Wkładam plik do repozytorium z testami i każdy, kto je klonuje, dostaje to samo środowisko.

Co mam zainstalowane

Compose to na ten moment osobne narzędzie napisane w Pythonie, uruchamiane poleceniem docker-compose z myślnikiem. Nie mieszam go z silnikiem, bo to dwa niezależne numery wersji:

$ docker version --format '{{.Server.Version}}'
20.10.3

$ docker-compose version --short
1.28.2

Engine 20.10.3 ma notatki z początku lutego. Na Windowsie i macOS nie instaluję silnika osobno, tylko biorę Docker Desktop - u mnie 3.1.0 ze stycznia. Compose w wersji 1.28.2 z 26 stycznia doinstalowałem sam, bo interesuje mnie funkcja, która weszła w 1.28.0: service profiles. W styczniu miałem jeszcze 1.27.4 i profili tam nie ma, więc jeżeli poniższe przykłady mają u kogoś działać, to wersja docker-compose jest pierwszą rzeczą, którą sprawdzam przy zgłoszeniu “u mnie nie startuje”.

Minimalny plik z trzema serwisami

Zaczynam od najkrótszej rzeczy, która robi cokolwiek sensownego. Plik docker-compose.yml ląduje w katalogu z testami:

version: "3.8"

services:
  db:
    image: postgres:13.2
    environment:
      POSTGRES_PASSWORD: test
      POSTGRES_DB: shop
    ports:
      - "55432:5432"

  cache:
    image: redis:6.0.10
    ports:
      - "56379:6379"

  api:
    image: rejestr.example/shop-api:2021.02
    environment:
      DATABASE_URL: postgres://postgres:test@db:5432/shop
      REDIS_URL: redis://cache:6379
    ports:
      - "58080:8080"
    depends_on:
      - db
      - cache

Kilka decyzji, które są tu celowe.

Tagi obrazów są przypięte. postgres:13.2, nie postgres:latest. Ta sama zasada, co przy pojedynczym docker run: latest to nie jest wersja, tylko ruchomy wskaźnik, a ja chcę, żeby przebieg z lutego dał się powtórzyć w kwietniu.

Porty po lewej stronie są wysokie i nietypowe. Compose tworzy dla projektu własną sieć i serwisy widzą się w niej po nazwach, dlatego DATABASE_URL wskazuje na db:5432, a nie na localhost. Publikuję porty na hosta tylko dlatego, że mój runner testów działa poza Compose i musi się gdzieś podłączyć. Gdyby testy też były serwisem w tym pliku, sekcje ports przy bazie i cache mógłbym w ogóle usunąć.

depends_on w tym kształcie mówi wyłącznie o kolejności uruchamiania. Compose wystartuje db i cache przed api, ale nie sprawdzi, czy Postgres skończył inicjalizację. To jest ta sama pułapka, którą opisywałem przy docker run: kontener działa nie znaczy aplikacja gotowa. Wrócę do tego za chwilę.

Profile, czyli po co mi UI przy teście API

To jest funkcja, dla której zaktualizowałem Compose. Do 1.27 docker-compose up podnosił wszystko, co jest w pliku. Miałem więc dwa wyjścia: trzymać kilka plików i sklejać je flagą -f, albo wypisywać nazwy serwisów ręcznie przy każdym uruchomieniu. Oba warianty się rozjeżdżały.

Od 1.28.0 mogę przypisać serwisy do profili i podnosić tylko to, czego potrzebuję:

version: "3.8"

services:
  db:
    image: postgres:13.2
    environment:
      POSTGRES_PASSWORD: test
      POSTGRES_DB: shop
    ports:
      - "55432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d shop"]
      interval: 5s
      timeout: 3s
      retries: 10

  cache:
    image: redis:6.0.10
    ports:
      - "56379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

  api:
    image: rejestr.example/shop-api:2021.02
    environment:
      DATABASE_URL: postgres://postgres:test@db:5432/shop
      REDIS_URL: redis://cache:6379
    ports:
      - "58080:8080"
    depends_on:
      - db
      - cache

  adminer:
    image: adminer:4.7.9
    ports:
      - "58081:8080"
    profiles: ["dev"]
    depends_on:
      - db

Zasada jest prosta i łatwo ją przeoczyć: serwis bez klucza profiles startuje zawsze, a serwis z profilem startuje tylko wtedy, gdy ten profil jawnie włączę. Dlatego rdzeń środowiska testowego zostawiam bez profili, a wszystko, co jest wygodą człowieka przy klawiaturze, wrzucam do profilu dev.

Do testów uruchamiam więc trzy serwisy:

$ docker-compose up -d

A kiedy sam chcę zajrzeć do bazy przez przeglądarkę, dokładam profil:

$ docker-compose --profile dev up -d

Alternatywnie ustawiam zmienną COMPOSE_PROFILE=dev w swoim lokalnym .env, żeby nie dopisywać flagi za każdym razem. W 1.28.2 nazwa jest w liczbie pojedynczej. Na maszynie CI tej zmiennej nie ma, więc CI z definicji podnosi tylko rdzeń.

Gotowość, czyli otwarty port to nie to samo co zdrowie

Dodałem wyżej healthcheck do bazy i do cache, i chcę wyjaśnić dlaczego, bo to jest najczęstsze źródło migających testów w takim ustawieniu.

Sprawdzenie “czy port 55432 jest otwarty” zwraca prawdę bardzo wcześnie. Docker mapuje port hosta w chwili startu kontenera, więc połączenie TCP potrafi się nawiązać, zanim Postgres wykona swoją pierwszą inicjalizację i utworzy bazę shop. Test dostaje wtedy błąd uwierzytelnienia albo “database does not exist” i wygląda to na problem aplikacji, a nie na wyścig.

healthcheck pyta o coś, co potrafi odpowiedzieć dopiero gotowa usługa: pg_isready u Postgresa, redis-cli ping u Redisa. Stan widzę potem w statusie:

$ docker-compose ps
     Name                   Command                State                Ports
---------------------------------------------------------------------------------------
shop_api_1       /app/entrypoint.sh            Up             0.0.0.0:58080->8080/tcp
shop_cache_1     docker-entrypoint.sh redis    Up (healthy)   0.0.0.0:56379->6379/tcp
shop_db_1        docker-entrypoint.sh postgres Up (healthy)   0.0.0.0:55432->5432/tcp

Warto wiedzieć, że samo docker-compose up nie czeka na healthy. W 1.28.2 dłuższa forma depends_on z condition: service_healthy jest honorowana i Compose poczeka, aż serwis zgłosi zdrowie. Ja i tak wolę pętlę gotowości w bashu, bo na starszym Compose w CI tej formy nie ma. Jest taka sama jak w skrypcie ze stycznia:

#!/usr/bin/env bash
set -euo pipefail

docker-compose up -d

for _ in $(seq 1 30); do
  if docker-compose exec -T db pg_isready -U postgres -d shop > /dev/null 2>&1; then
    echo "Srodowisko gotowe"
    exit 0
  fi
  sleep 2
done

echo "Baza nie wstala, logi ponizej:"
docker-compose logs --no-color db
exit 1

Flaga -T jest tu istotna, bo bez niej exec próbuje przydzielić terminal i na agencie CI potrafi się wywrócić. docker-compose logs bez nazwy serwisu pokazuje wyjście wszystkich kontenerów przeplecione i z prefiksem - przy trzech serwisach to nadal czytelne i zwykle to jest pierwsza rzecz, którą wklejam do zgłoszenia.

Sprzątanie

Ta część decyduje o tym, czy środowisko naprawdę jest na żądanie.

$ docker-compose down

down zatrzymuje i usuwa kontenery oraz sieć projektu, ale zostawia wolumeny z danymi. To bywa pożądane, kiedy chcę obejrzeć stan bazy po nieudanym teście. Natomiast domyślnym trybem u mnie jest wersja bezlitosna:

$ docker-compose down -v

-v usuwa też wolumeny, więc następny up dostaje pustą bazę i świeży katalog danych. Jeżeli kiedykolwiek widzieliście test, który przechodzi tylko za pierwszym razem albo tylko za drugim, to bardzo często odpowiedzią są dane, które przetrwały poprzedni przebieg. Czysty start jest tańszy niż debugowanie takiej zależności.

Drobiazg, który oszczędza nerwów: nazwa projektu. Compose domyślnie bierze ją z nazwy katalogu, więc dwa repozytoria o katalogu tests będą się nawzajem podgryzać. Ustawiam ją jawnie flagą -p albo zmienną COMPOSE_PROJECT_NAME, zwłaszcza na agencie CI, gdzie równolegle może stać kilka przebiegów.

Czego ten wpis nie pokrywa

Trzy rzeczy zostawiam świadomie na osobne wpisy.

Farmę przeglądarek. Da się ją wpiąć jako kolejne serwisy w tym samym pliku i nawet schować za profilem, ale skalowanie i stabilność takiego zestawu to temat sam w sobie.

Mock HTTP zamiast prawdziwej usługi zewnętrznej. W praktyce to po prostu kolejny serwis w Compose, tylko cała trudność leży w tym, czym go nakarmić i jak wersjonować odpowiedzi.

Sterowanie cyklem życia kontenerów z poziomu kodu testu, żeby to test decydował, co podnieść, a nie plik obok niego. Chcę to sprawdzić, ale nie mam jeszcze własnych wyników, więc nie będę o tym pisał tutaj.

Compose nie robi z mojego zestawu testów lepszego zestawu. Robi tylko tyle, że środowisko przestaje być czymś, co się rezerwuje i o co się prosi, a staje się plikiem w repozytorium. To i tak jest duża zmiana, ale warto pamiętać, po co ona jest, i co jakiś czas wrócić do szerszego obrazka, czyli do tego, jak wygląda cały proces testowy. Kiedy zaczynałem od ręcznie przygotowanego środowiska, pół dnia schodziło na doprowadzenie maszyny do stanu, w którym testy w ogóle ruszają. Teraz to jest jedna komenda i trzydzieści sekund.