Docker Compose healthcheck: jak poprawnie skonfigurować
Dowiedz się, dlaczego parametr depends_on nie gwarantuje gotowości usług. Poznaj mechanizm kodów wyjścia oraz gotowe wzorce healthcheck dla Postgres i aplikacji w Docker.
Na czym faktycznie polega healthcheck w Docker Compose
Healthcheck w Docker Compose to pojedyncze polecenie, które Docker uruchamia wewnątrz kontenera zgodnie z harmonogramem. Docker nie analizuje logów, nie monitoruje portów ani nie sprawdza listy procesów. Wykonuje polecenie, odczytuje kod wyjścia i zapisuje jeden stan kontenera: starting, healthy lub unhealthy. Kod wyjścia 0 oznacza stan zdrowy (healthy). Każdy inny kod oznacza stan niezdrowy (unhealthy), przy czym kod 2 jest zarezerwowany przez Dockera, więc nie należy go zwracać celowo.
To cały mechanizm. Niemal każdy problem z healthcheckiem wynika z tego samego: polecenie odpowiada na inne pytanie, niż zamierzano. Ten przewodnik zakłada, że wiesz już jak przygotować plik compose na VPS i skupia się na sytuacjach, w których stos usług uruchamia się 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 przyjmuje dwie użyteczne formy. Lista zaczynająca się od CMD uruchamia polecenie bezpośrednio, bez powłoki, więc potoki (pipes), && oraz rozwijanie zmiennych nie działają. Lista zaczynająca się od CMD-SHELL przekazuje resztę jako jeden ciąg znaków do /bin/sh -c wewnątrz kontenera, co jest wymagane, gdy sprawdzanie wymaga składni powłoki. Zwykły ciąg znaków jest traktowany jako CMD-SHELL. Lista zawierająca dokładnie ["NONE"] usuwa healthcheck zdefiniowany w obrazie przez Dockerfile.
Sprawdzanie odbywa się wewnątrz kontenera, więc każdy plik binarny musi istnieć w danym obrazie. Należy to zweryfikować w pierwszej kolejności, ponieważ "odchudzony" obraz bez curl spowoduje, że kontener będzie trwale w stanie niezdrowym z powodu, który nigdy nie pojawi się w logach aplikacji. Należy przetestować to ręcznie:
docker compose exec api curl --versionBrak pliku binarnego skutkuje błędem OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown. Obrazy oparte na Alpine zazwyczaj zawierają BusyBox wget, więc polecenie sprawdzające powinno przyjąć postać ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"].
Współdziałanie parametrów interval, retries oraz start_period
Pięć ustawień steruje harmonogramem sprawdzania. Ich wartości domyślne pochodzą z Docker Engine, a nie z Compose.
interval: czas między dwoma sprawdzeniami po zakończeniu okresu startowego kontenera. Wartość domyślna to 30s.timeout: czas, po którym Docker przerywa sprawdzenie i uznaje je za nieudane. Wartość domyślna to 30s.retries: liczba kolejnych niepowodzeń wymagana do zmiany stanu naunhealthy. Wartość domyślna to 3.start_period: okres karencji po uruchomieniu kontenera. Wartość domyślna to 0s.start_interval: częstotliwość wykonywania sprawdzeń w trakcie okresu startowego. Wartość domyślna to 5s; wymaga Docker Engine w wersji 25.0 lub nowszej.
Kluczowa zasada: w trakcie okresu startowego nieudane sprawdzenie nie wlicza się do retries, a kontener pozostaje w stanie starting. Pierwsze udane sprawdzenie powoduje przejście kontenera w stan healthy i natychmiastowe zakończenie okresu startowego, nawet jeśli nie upłynął cały przewidziany czas. Jeśli okres startowy upłynie, a sprawdzenia nadal kończą się niepowodzeniem, rozpoczyna się standardowe odliczanie i kontener wymaga retries kolejnych niepowodzeń, aby zostać oznaczonym jako unhealthy.
Zatem najgorszy scenariusz czasu od startu kontenera do unhealthy to start_period plus retries pomnożone przez interval plus timeout. Przy wartościach z powyższego pliku jest to 30 plus 5 razy 13, czyli 95 sekund. Należy zanotować tę wartość przed ustawieniem limitu czasu wdrożenia (deploy timeout), ponieważ proces wdrażania, który przerywa działanie po 60 sekundach, nigdy nie pozwoli temu kontenerowi osiągnąć stanu końcowego.
Częstym błędem jest zwiększanie retries w celu wydłużenia czasu na powolny start. Działa to tylko raz, a następnie powoduje trwałe obniżenie odporności: usługa, która potrzebowała 8 prób do uruchomienia, będzie teraz tolerować 8 kolejnych awarii w środowisku produkcyjnym, zanim system zareaguje. Należy używać start_period, ponieważ parametr ten ma zastosowanie wyłącznie przed pierwszym udanym sprawdzeniem.
Dlaczego depends_on samo w sobie niczego nie gwarantuje
Krótka forma depends_on jest źródłem większości nieporozumień.
api:
depends_on:
- dbOznacza to tylko jedno: uruchom kontener db przed kontenerem api. Compose czeka na utworzenie i uruchomienie kontenera. Nie czeka na zakończenie pierwszej inicjalizacji PostgreSQL ani na moment, w którym port 5432 zacznie akceptować połączenia. Aplikacja startuje sekundę później, próbuje połączyć się z portem, na którym nikt jeszcze nie nasłuchuje, i kończy działanie. W logach widoczny jest błąd Connection refused lub FATAL: the database system is starting up, gdy serwer działa, ale wciąż trwa jego odzyskiwanie.
Długa forma jest tym, czego użytkownicy faktycznie potrzebują:
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullycondition przyjmuje trzy wartości. service_started działa tak samo jak forma krótka. service_healthy wstrzymuje usługę zależną do momentu, aż zależność zgłosi stan healthy, co ma znaczenie tylko wtedy, gdy zależność definiuje healthcheck w pliku compose lub w swoim obrazie. service_completed_successfully czeka, aż kontener typu one-shot, na przykład migracja bazy danych, zakończy działanie z kodem wyjścia 0.
Obok condition znajdują się dwa dodatkowe pola. restart: true nakazuje Compose zrestartowanie usługi po aktualizacji usługi zależnej. required: false obniża rangę brakującej zależności z błędu do ostrzeżenia.
Oto ograniczenie, które często sprawia problemy. Warunki te są sprawdzane w momencie uruchamiania stosu. Określają one kolejność startu, a nie zasady nadzoru. Jeśli baza danych zrestartuje się o trzeciej nad ranem, nic nie sprawdza ponownie service_healthy i nic nie restartuje aplikacji w celu ponownego spełnienia tego warunku. Kod aplikacji musi samodzielnie obsługiwać ponowne nawiązywanie połączenia. docker compose up --no-deps api z założenia pomija cały ten mechanizm, podobnie jak uruchamianie kontenera bezpośrednio za pomocą docker start.
Napisz test sprawdzający gotowość, a nie tylko istnienie procesu
Test typu pgrep nginx potwierdza jedynie obecność wpisu w tablicy procesów. Nie dowodzi on, czy usługa jest w stanie 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 w stanie poprawnym przez cały czas trwania awarii.
Wymuś na kontenerze wykonanie zadania, do którego jest przeznaczony:
- W przypadku usługi HTTP, wyślij żądanie do rzeczywistego punktu końcowego.
curl -fsSkończy się kodem innym niż zero dla każdego statusu 400 lub wyższego dzięki-f, więc błąd 500 z uszkodzonej aplikacji oznacza nieudany test. - Dla PostgreSQL użyj
pg_isready, który zwraca 0, gdy serwer akceptuje połączenia, 1, gdy je odrzuca, 2, gdy w ogóle nie odpowiada, oraz 3, gdy przekazane parametry są błędne. - Dla Redis użyj
redis-cli ping, który wypisujePONGi kończy się kodem 0. - Dla MariaDB oficjalny obraz zawiera skrypt
healthcheck.sh, ahealthcheck.sh --connect --innodb_initializedjest formą zalecaną przez opiekunów obrazu.
pg_isready posiada jedną pułapkę, o której warto wiedzieć. Przy pierwszym uruchomieniu z pustym katalogiem danych, oficjalny obraz postgres przeprowadza inicjalizację na serwerze tymczasowym, który nasłuchuje tylko na gnieździe Unix. pg_isready bez argumentu hosta korzysta z tego gniazda, więc może odpowiedzieć „akceptuję połączenia”, podczas gdy port TCP 5432 jest jeszcze zamknięty dla aplikacji. Skieruj test jawnie na TCP, a problem zniknie, ponieważ serwer tymczasowy nie odpowiada na tym porcie.
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 samodzielnie podczas odczytu pliku, co spowodowałoby wstawienie wartości ze środowiska hosta do testu. $$ eskapuje go do pojedynczego $, dzięki czemu powłoka wewnątrz kontenera rozwija go w oparciu o środowisko samego kontenera.
Stos postgres i aplikacji uruchamiany w odpowiedniej 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. Poprawnie działająca para wskazuje Up 41 seconds (healthy) w obu wierszach. Podczas inicjalizacji bazy danych db wskazuje Up 4 seconds (health: starting), a api nie znajduje się na liście, ponieważ Compose jeszcze go nie utworzył.
Aby sprawdzić przyczynę powodzenia lub niepowodzenia testu, należy odczytać dziennik stanu zdrowia:
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker przechowuje kilka ostatnich wyników, z których każdy zawiera czas rozpoczęcia, czas zakończenia, ExitCode oraz Output polecenia. Zapisane dane wyjściowe są ucinane, więc test wypisujący dużą treść strony generuje bezużyteczny wpis w dzienniku. Testy powinny działać w trybie cichym.
Co robi Docker, gdy kontener przechodzi w stan unhealthy
Nic. To odpowiedź, która najbardziej zaskakuje użytkowników.
Docker Engine na pojedynczym hoście nie restartuje kontenera w stanie unhealthy. Polityka restart: unless-stopped reaguje na zakończenie głównego procesu, a kontener w stanie unhealthy nie został zakończony. Może on pozostawać w stanie unhealthy przez tydzień, podczas gdy Compose nie podejmuje żadnych działań. Tryb Swarm zastępuje zadania w stanie unhealthy, ale standardowy stos Compose na jednym serwerze tego nie robi.
Pozostają dwie uczciwe opcje. Należy sprawić, aby proces kończył działanie, gdy wykryje awarię, dzięki czemu polityka restartu będzie miała podstawę do działania. Alternatywnie, należy monitorować stan z zewnątrz i otrzymywać powiadomienia. Skierowanie monitora Uptime Kuma na ten sam punkt końcowy, który odpytuje healthcheck, sprawia, że uszkodzona zależność jest widoczna w obu miejscach, a powiadomienie pochodzi z monitora, a nie od użytkownika. Jeśli ruch dociera do aplikacji przez reverse proxy Traefik, należy pamiętać, że własny stan backendu w proxy jest niezależny od stanu zdrowia w Dockerze, więc jeden nie zastępuje drugiego.
Diagnostyka sprawdzania, które nie przechodzi w stan gotowości
Należy samodzielnie wykonać polecenie wewnątrz tego samego kontenera i sprawdzić kod wyjścia:
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"exit=0 w tym miejscu, podczas gdy kontener nadal zgłasza stan unhealthy, oznacza, że konfiguracja test różni się od wpisanej komendy. Zazwyczaj wynika to z użycia CMD tam, gdzie wymagana była składnia powłoki.
Większość pozostałych problemów wynika z dwóch błędów. Pierwszym jest błędny port. Healthcheck działa wewnątrz kontenera, więc musi korzystać z portu kontenera, a nie z portu opublikowanego na hoście. Przy ports: - "8080:3000" aplikacja nasłuchuje na porcie 3000, a sprawdzanie pod adresem http://localhost:8080 będzie zawsze kończyć się niepowodzeniem, mimo że strona działa poprawnie w przeglądarce. Drugim błędem jest błędny host. Wewnątrz sprawdzania localhost odnosi się do tego samego kontenera, co jest poprawne dla autodiagnostyki, ale błędne dla sprawdzania sąsiedniej usługi, gdzie należy użyć nazwy usługi, na przykład db.
Ostatni przypadek wymaga uwagi: healthcheck przechodzi pomyślnie, podczas gdy użytkownicy widzą błędy. Dzieje się tak, gdy endpoint zwraca statyczny kod 200 bez weryfikacji rzeczywistego stanu. Endpoint gotowości, który nie odpytuje bazy danych, nie wykryje jej awarii. Należy skonfigurować go tak, aby wykonywał jedno, mało obciążające, rzeczywiste zapytanie.
FAQ
Dlaczego moja aplikacja nadal nie może się połączyć, mimo że depends_on wskazuje, iż baza danych jest w stanie healthy?
Ponieważ condition: service_healthy jest sprawdzane tylko raz, w momencie uruchamiania stosu. Nie nadzoruje ono niczego później. Jeśli kontener bazy danych zrestartuje się w późniejszym czasie, Compose nie restartuje aplikacji w celu ponownego spełnienia warunku, dlatego kod aplikacji musi posiadać własną logikę ponawiania połączeń. Warunek ten nie działa również w przypadku uruchamiania pojedynczego kontenera za pomocą docker start lub docker compose up --no-deps.
Czy potrzebuję healthcheck, jeśli obraz już go definiuje?
Zazwyczaj nie, a jego nadpisywanie często jest krokiem wstecz, ponieważ opiekun obrazu wie, co oznacza gotowość danego oprogramowania. Własny test należy dodać tylko wtedy, gdy test obrazu jest nieprawidłowy dla danej konfiguracji, na przykład gdy sprawdza port, który został zmieniony. Aby wyłączyć healthcheck obrazu, należy ustawić test: ["NONE"] lub disable: true w definicji usługi.
Czy healthcheck powinien używać curl czy wget?
Należy użyć narzędzia, które już znajduje się w obrazie i potwierdzić jego obecność za pomocą docker compose exec <service> curl --version przed poleganiem na nim. Wiele obrazów opartych na Debianie nie posiada żadnego z nich. Obrazy oparte na Alpine zawierają wget z pakietu BusyBox. Nie należy dodawać pakietu do obrazu tylko w celu wykonania healthcheck, jeśli oprogramowanie dostarcza własnego klienta, takiego jak pg_isready lub redis-cli.
Czy kontener w stanie unhealthy jest restartowany automatycznie?
Nie przez Docker Engine na pojedynczym hoście. Polityki restartu reagują na zakończenie procesu, a nie na stan zdrowia, więc kontener w stanie unhealthy pozostaje uruchomiony i uszkodzony, dopóki nie zadziała na niego inny mechanizm. Należy albo wymusić zakończenie procesu po wykryciu awarii, albo uruchomić zewnętrzny monitor, który powiadomi o zmianie stanu.
Jak długi powinien być start_period?
Wystarczająco długi dla najwolniejszego poprawnego pierwszego uruchomienia, które zostało zmierzone, z uwzględnieniem marginesu. Należy zmierzyć ten czas za pomocą docker compose up na pustym wolumenie, ponieważ pierwsze uruchomienie bazy danych jest znacznie wolniejsze niż każde kolejne. Zbyt długi start period jedynie opóźnia pierwszy werdykt unhealthy. Zbyt duża liczba ponownych prób osłabia skuteczność testu przez cały czas działania kontenera, co jest gorszym skutkiem.