Docker Compose healthcheck: poprawna konfiguracja
Sprawdź, jak Compose ocenia healthcheck, dlaczego samo depends_on na nic użytecznego nie czeka oraz jak przygotować gotowe testy gotowości dla Postgres i aplikacji.
Jak faktycznie działa healthcheck Docker Compose
Healthcheck Docker Compose to jedno polecenie, które Docker uruchamia wewnątrz kontenera w określonych odstępach czasu. Docker nie odczytuje logów, nie monitoruje portu ani nie sprawdza listy procesów. Uruchamia polecenie, odczytuje kod zakończenia i zapisuje jeden stan kontenera: starting, healthy lub unhealthy. Kod zakończenia 0 oznacza stan healthy. Każdy inny kod zakończenia oznacza stan unhealthy, a kod zakończenia 2 jest zarezerwowany przez Docker, dlatego nie należy zwracać go celowo.
To cały mechanizm. Prawie każdy problem z healthcheckiem wynika z tego samego: napisane polecenie odpowiada na inne pytanie niż to, które miało zostać zadane. W tym przewodniku przyjęto, że znany jest już sposób zapisywania pliku compose na VPS, a opis rozpoczyna się od momentu, w którym stos jest uruchamiany w niewłaściwej kolejności.
services:
api:
image: ghcr.io/example/api:1.4.0
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30sWartość test ma dwie użyteczne postacie. Lista rozpoczynająca się od CMD uruchamia polecenie bezpośrednio, bez powłoki, dlatego potoki, && i rozwijanie zmiennych nie działają. Lista rozpoczynająca się od CMD-SHELL przekazuje pozostałą część jako jeden ciąg do /bin/sh -c wewnątrz kontenera. Należy użyć tej postaci, gdy test wymaga składni powłoki. Zwykły ciąg jest traktowany jako CMD-SHELL. Lista zawierająca dokładnie ["NONE"] usuwa healthcheck dodany do obrazu w jego Dockerfile.
Test jest uruchamiany wewnątrz kontenera, dlatego każdy wskazany w nim plik binarny musi istnieć w tym obrazie. Należy najpierw to zweryfikować, ponieważ obraz slim bez curl powoduje, że kontener pozostaje stale w stanie unhealthy, a przyczyna nie pojawia się w logu aplikacji. Test można wykonać ręcznie:
docker compose exec api curl --versionBrak pliku binarnego jest sygnalizowany przez OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown. Obrazy oparte na Alpine zwykle zawierają zamiast niego wget z pakietu BusyBox, dlatego test przyjmuje postać ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"].
Jak współdziałają ustawienia interval, retries i start_period
Pięć ustawień określa harmonogram. Ich wartości domyślne pochodzą z Docker Engine, a nie z Compose.
interval: czas między kolejnymi sprawdzeniami po zakończeniu okresu startowego kontenera. Wartość domyślna: 30s.timeout: maksymalny czas wykonania pojedynczego sprawdzenia, zanim Docker je przerwie i zaliczy jako nieudane. Wartość domyślna: 30s.retries: liczba kolejnych nieudanych sprawdzeń wymagana do zmiany stanu naunhealthy. Wartość domyślna: 3.start_period: okres ochronny po uruchomieniu kontenera. Wartość domyślna: 0s.start_interval: częstotliwość wykonywania sprawdzenia w okresie startowym. Wartość domyślna: 5s; wymagany jest Docker Engine 25.0 lub nowszy.
Najważniejsza zasada jest następująca: w okresie startowym nieudane sprawdzenie nie jest wliczane do retries, a kontener pozostaje w stanie starting. Przy pierwszym pomyślnym sprawdzeniu kontener przechodzi do stanu healthy, a okres startowy kończy się natychmiast, nawet jeśli większość tego okresu nie została wykorzystana. Jeżeli okres startowy upłynie, a sprawdzenie nadal będzie nieudane, rozpoczyna się zwykłe odliczanie. Kontener musi wtedy uzyskać retries kolejnych nieudanych sprawdzeń, aby został oznaczony jako unhealthy.
Zatem najdłuższy czas od uruchomienia kontenera do uzyskania stanu unhealthy wynosi start_period plus retries pomnożone przez interval plus timeout. Dla wartości z wcześniejszego pliku wynosi to 30 plus 5 razy 13, czyli 95 sekund. Należy zapisać tę wartość przed ustawieniem limitu czasu wdrożenia, ponieważ wdrożenie przerwane po 60 sekundach nigdy nie zobaczy, że ten kontener osiągnął stan końcowy.
Częstym błędem jest zwiększanie retries w celu uwzględnienia powolnego uruchamiania. Rozwiązanie to działa jednorazowo, a następnie stale powoduje problemy: usługa, która potrzebowała 8 ponowień, aby się uruchomić, będzie tolerować 8 kolejnych nieudanych sprawdzeń w środowisku produkcyjnym, zanim zostanie to wykryte. Należy użyć start_period, ponieważ to ustawienie obowiązuje tylko przed pierwszym pomyślnym sprawdzeniem.
Dlaczego samo depends_on niczego nie gwarantuje
Skrócona forma depends_on jest źródłem większości nieporozumień.
api:
depends_on:
- dbOznacza to tylko jedno: uruchomienie kontenera db przed kontenerem api. Compose czeka na utworzenie i uruchomienie kontenera. Nie czeka na zakończenie pierwszej inicjalizacji PostgreSQL ani na rozpoczęcie akceptowania połączeń na porcie 5432. Aplikacja uruchamia się około sekundy później, próbuje połączyć się z portem, na którym nic jeszcze nie nasłuchuje, i kończy działanie. W dzienniku pojawia się Connection refused albo FATAL: the database system is starting up, gdy serwer działa, ale nadal przechodzi odzyskiwanie.
Długa forma odpowiada rzeczywistym potrzebom:
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullycondition ma trzy wartości. service_started działa tak samo jak forma skrócona. service_healthy wstrzymuje uruchomienie usługi zależnej do czasu zgłoszenia przez zależność stanu zdrowia. Ma to znaczenie tylko wtedy, gdy zależność definiuje healthcheck w pliku Compose albo w swoim obrazie. service_completed_successfully czeka na zakończenie kontenera jednorazowego, takiego jak kontener wykonujący migrację bazy danych, ze statusem 0.
Obok condition znajdują się dwa dodatkowe pola. restart: true nakazuje Compose ponownie uruchomić tę usługę po zaktualizowaniu usługi zależności. required: false zmienia brak zależności z błędu na ostrzeżenie.
Istnieje jednak ograniczenie, które często prowadzi do problemów. Te warunki są sprawdzane podczas uruchamiania stosu. Określają kolejność uruchamiania, a nie zasady nadzorowania usług. Jeśli baza danych uruchomi się ponownie o trzeciej nad ranem, service_healthy nie zostanie ponownie sprawdzone i aplikacja nie zostanie ponownie uruchomiona w celu ponownego spełnienia tego warunku. Kod aplikacji nadal musi samodzielnie ponawiać połączenia. docker compose up --no-deps api celowo pomija cały ten mechanizm. To samo dotyczy bezpośredniego uruchomienia kontenera za pomocą docker start.
Należy przygotować test gotowości, a nie tylko sprawdzać istnienie procesu
Test taki jak pgrep nginx potwierdza istnienie wpisu w tabeli procesów. Nie potwierdza, że usługa może odpowiedzieć na żądanie. Aplikacja internetowa może utrzymywać otwarte gniazdo nasłuchujące długo po awarii puli połączeń z bazą danych, a test procesu pozostanie poprawny przez cały czas trwania awarii.
Należy zlecić kontenerowi wykonanie zadania, do którego został przeznaczony:
- W przypadku usługi HTTP należy zażądać rzeczywistego endpointu.
curl -fsSkończy działanie z kodem innym niż 0 dla każdego kodu statusu 400 lub wyższego z powodu-f, dlatego kod 500 zwrócony przez uszkodzoną aplikację oznacza nieudany test. - W przypadku PostgreSQL należy użyć
pg_isready. Polecenie kończy działanie z kodem 0, gdy serwer akceptuje połączenia, z kodem 1, gdy je odrzuca, z kodem 2, gdy w ogóle nie odpowiada, oraz z kodem 3, gdy przekazane parametry są nieprawidłowe. - W przypadku Redis należy użyć
redis-cli ping. Polecenie wyświetlaPONGi kończy działanie z kodem 0. - W przypadku MariaDB oficjalny obraz zawiera skrypt
healthcheck.sh, ahealthcheck.sh --connect --innodb_initializedjest formą opisaną w dokumentacji jego opiekunów.
pg_isready ma jedną istotną pułapkę. Przy pierwszym uruchomieniu z pustym katalogiem danych oficjalny obraz postgres wykonuje inicjalizację względem tymczasowego serwera, który nasłuchuje wyłącznie na gnieździe Unix. pg_isready bez argumentu hosta korzysta z tego gniazda, dlatego może zgłosić „akceptowanie połączeń”, mimo że port TCP 5432 jest nadal zamknięty dla aplikacji. Należy jawnie wskazać protokół TCP w teście. Problem zniknie, ponieważ tymczasowy serwer nie odpowiada za pośrednictwem TCP.
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30sPodwójne znaki dolara nie są błędem. Compose rozwija $VAR podczas odczytywania pliku, co spowodowałoby zapisanie w teście wartości pochodzącej ze środowiska hosta. $$ ucieka ten zapis do pojedynczego $, dzięki czemu powłoka wewnątrz kontenera rozwija go względem środowiska samego kontenera.
Stos PostgreSQL i aplikacji uruchamiany we właściwej kolejności
services:
db:
image: postgres:17.5
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
restart: unless-stopped
api:
image: ghcr.io/example/api:1.4.0
environment:
DATABASE_URL: postgres://appuser:${DB_PASSWORD}@db:5432/appdb
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30s
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
volumes:
pgdata:Uruchom stos i obserwuj zmiany stanów:
docker compose up -d
docker compose psKolumna STATUS zawiera stan zdrowia w nawiasach. Zdrowa para ma wartość Up 41 seconds (healthy) w obu wierszach. Podczas inicjalizacji bazy danych db ma wartość Up 4 seconds (health: starting), a element api nie występuje na liście, ponieważ Compose nie utworzył go jeszcze.
Aby sprawdzić, dlaczego test zakończył się powodzeniem lub niepowodzeniem, odczytaj dziennik stanu zdrowia:
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker przechowuje kilka ostatnich wyników. Każdy zawiera czas rozpoczęcia, czas zakończenia, ExitCode oraz Output polecenia. Zapisane dane wyjściowe są skracane. Dlatego test, który wypisuje dużą zawartość strony, tworzy bezużyteczny wpis w dzienniku. Testy powinny generować niewielką ilość danych wyjściowych.
Co Docker robi, gdy kontener staje się niezdrowy
Nic. To odpowiedź, która najbardziej zaskakuje.
Docker Engine na pojedynczym hoście nie uruchamia ponownie niezdrowego kontenera. Zasada restart: unless-stopped reaguje na zakończenie głównego procesu, a niezdrowy kontener nie zakończył działania. Może pozostawać w stanie unhealthy przez tydzień, a Compose nadal nie podejmie żadnych działań. Tryb Swarm zastępuje niezdrowe zadania, ale zwykły stos Compose na jednym serwerze tego nie robi.
Pozostają dwie rozsądne możliwości. Należy zakończyć działanie procesu, gdy wykryje on, że działa nieprawidłowo, aby zasada ponownego uruchamiania mogła zareagować. Można też monitorować stan z zewnątrz i generować alerty. Skierowanie monitora Uptime Kuma na ten sam endpoint, który wywołuje healthcheck, powoduje, że niedziałająca zależność jest widoczna w obu miejscach. Informacja dociera wtedy z monitora, a nie od użytkownika. Jeśli ruch sieciowy dociera do aplikacji przez odwrotny proxy Traefik, należy pamiętać, że własny widok proxy dotyczący backendu jest niezależny od stanu zdrowia Docker. Jeden mechanizm nie zastępuje więc drugiego.
Debugowanie kontroli, która nigdy nie uzyskuje stanu healthy
Uruchom dokładnie to polecenie w tym samym kontenerze i sprawdź kod zakończenia:
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"exit=0 tutaj, gdy kontener nadal zgłasza stan unhealthy, oznacza, że konfiguracja compose test różni się od właśnie wpisanej. Zwykle użyto CMD w miejscu, w którym potrzebna była składnia powłoki.
Za większość pozostałych przypadków odpowiadają dwa błędy. Pierwszym jest nieprawidłowy port. Kontrola kondycji jest uruchamiana wewnątrz kontenera, dlatego musi używać portu kontenera, a nie opublikowanego portu hosta. W przypadku ports: - "8080:3000" aplikacja nasłuchuje na porcie 3000. Kontrola skierowana do http://localhost:8080 będzie więc kończyć się niepowodzeniem, mimo że witryna działa poprawnie w przeglądarce. Drugim błędem jest nieprawidłowy host. W ramach kontroli localhost oznacza ten sam kontener. Jest to właściwe przy sprawdzaniu tego kontenera, ale nie przy sprawdzaniu sąsiedniego kontenera. W takim przypadku należy użyć nazwy usługi, na przykład db.
Warto wskazać jeszcze jeden przypadek: kontrola kondycji kończy się powodzeniem, ale użytkownicy widzą błędy. Dzieje się tak, gdy punkt końcowy zwraca statyczny kod 200 bez sprawdzania rzeczywistych zależności. Punkt końcowy gotowości, który nigdy nie wykonuje zapytania do bazy danych, nie wykryje jej niedostępności. Należy zlecić mu wykonanie jednego prostego, rzeczywistego zapytania.
FAQ
Dlaczego moja aplikacja nadal nie może się połączyć, gdy depends_on wskazuje, że baza danych jest zdrowa?
Ponieważ condition: service_healthy jest oceniane raz, podczas uruchamiania stosu. Później niczego nie nadzoruje. Jeśli kontener bazy danych uruchomi się ponownie, Compose nie uruchomi ponownie aplikacji, aby ponownie spełnić ten warunek. Kod aplikacji musi więc mieć własną logikę ponawiania prób i ponownego nawiązywania połączenia. Warunek nie działa również przy uruchamianiu pojedynczego kontenera za pomocą docker start lub docker compose up --no-deps.
Czy potrzebna jest dyrektywa healthcheck, jeśli obraz już ją definiuje?
Zwykle nie. Zastępowanie jej często pogarsza sytuację, ponieważ opiekun obrazu wie, co oznacza gotowość danego oprogramowania. Własną kontrolę należy dodać tylko wtedy, gdy kontrola zdefiniowana w obrazie jest nieprawidłowa dla danego środowiska, na przykład sprawdza port, który został zmieniony. Aby wyłączyć kontrolę healthcheck z obrazu, należy ustawić test: ["NONE"] lub disable: true dla usługi.
Czy w healthcheck należy użyć curl czy wget?
Należy użyć narzędzia, które już znajduje się w obrazie, i potwierdzić jego dostępność za pomocą docker compose exec <service> curl --version przed wykorzystaniem go w kontroli. W wielu obrazach opartych na Debianie nie ma żadnego z tych narzędzi. Obrazy oparte na Alpine zawierają BusyBox wget. Nie należy dodawać pakietu do obrazu wyłącznie po to, aby uruchamiać healthcheck, jeśli oprogramowanie zawiera własnego klienta, takiego jak pg_isready lub redis-cli.
Czy niezdrowy kontener jest automatycznie uruchamiany ponownie?
Nie przez Docker Engine na pojedynczym hoście. Zasady ponownego uruchamiania reagują na zakończenie procesu, a nie na stan zdrowia. Niezdrowy kontener pozostaje więc uruchomiony i niesprawny, dopóki inne działanie tego nie zmieni. Należy albo doprowadzić do zakończenia procesu po wykryciu awarii, albo uruchomić zewnętrzny monitor, który generuje alerty na podstawie tego stanu.
Jak długa powinna być wartość start_period?
Powinna obejmować najdłuższy zmierzony prawidłowy czas pierwszego uruchomienia oraz dodatkowy margines. Czas należy zmierzyć za pomocą docker compose up przy użyciu pustego woluminu, ponieważ pierwsze uruchomienie bazy danych trwa znacznie dłużej niż każde kolejne. Zbyt długi okres startowy jedynie opóźnia pierwszy werdykt unhealthy. Zbyt duża liczba ponowień osłabia kontrolę przez cały czas działania kontenera, co jest poważniejszą awarią.