Mit csinál a Docker, ha unhealthy lesz a konténered? Semmit.

A HEALTHCHECK-től sokan önjavító konténert várnak, pedig a sima Docker az unhealthy állapotra nem reagál: nem indít újra, nem állít le, csak átszínezi a docker ps kimenetét. Kipróbáltam, ki olvassa valójában ezt a státuszt, ki cselekszik belőle, és ki az, aki soha le sem futtatja.

Van egy megnyugtató kis szertartás a konténeres életben: az ember beír a Dockerfile-ba egy HEALTHCHECK sort, aztán a docker ps kimenetében gyönyörködik a (healthy) feliratban. Az agy innen automatikusan továbbírja a történetet: ha egyszer az app megakad, a Docker majd észreveszi, és újraindítja. Ez a történet ott hibádzik, hogy a második fele nincs megírva. A sima Docker az unhealthy állapotra nem reagál. Semmit nem csinál. Lemértem, kipróbáltam, és összeszedtem, ki az, aki tényleg olvassa ezt a státuszt, ki az, aki cselekszik is belőle, és ki az, aki tüntetően ignorálja.

Kétsávos diagram: felül egy unhealthy jelvényű Docker-konténer az idő múlásával is változatlanul fut tovább, az újraindítás áthúzva; alul egy orchestrator ütemezője az unhealthy konténert friss, egészséges példányra cseréli.

Egy státuszgép, három állapottal

A HEALTHCHECK utasítás annyit tesz, hogy a daemon időnként lefuttat egy parancsot a konténerben: alapból 30 másodpercenként (interval), 30 másodperces timeout-tal, és három egymást követő bukás (retries) után vált a státusz starting-ból vagy healthy-ből unhealthy-ba. A parancs exit kódja dönt: 0 = healthy, 1 = unhealthy, a 2-es pedig hivatalosan „reserved – don’t use”, ami elég ritka műfaj egy exit kód életében. Két kevésbé ismert opció is van: a start-period türelmi ideje alatt a bukott próba nem számít bele a retries-be, a Docker Engine 25.0 (2024. január) óta létező start-interval pedig azt mondja meg, hogy a türelmi idő alatt milyen sűrűn próbálkozzon — erre még visszatérünk, mert hasznosabb, mint amilyen ismeretlen.

Unhealthy. És akkor mi van?

A kísérlet: node:22-alpine konténer, benne egy app, ami tíz másodpercig egészséges, utána minden /health kérésre 500-at ad. Kétmásodperces interval, három retry, és — hogy izgalmasabb legyen — --restart always. Egy perccel az indítás után:

$ docker inspect hc-demo --format 'Status={{.State.Status}} RestartCount={{.RestartCount}} Health={{.State.Health.Status}} FailingStreak={{.State.Health.FailingStreak}}'

Status=running RestartCount=0 Health=unhealthy FailingStreak=30

Harminc egymás utáni bukott próba, always restart policy, és a konténer rezzenéstelenül fut tovább. A --restart ugyanis kizárólag arra reagál, ha a főprocessz kilép — a health státusz nem tartozik rá. Amit az unhealthy állapotért cserébe kapsz: egy zárójeles felirat a docker ps-ben, egy health_status esemény a docker events streamben, az utolsó öt próba naplója a docker inspect kimenetében (a próbaparancs kimenetének első 4096 bájtjával), és egy szűrő: docker ps --filter health=unhealthy. Ez monitoring. Nem gyógyítás.

És ez nem egy elfelejtett feature: a moby/moby#28400 issue („trigger restart from unhealthy status”) 2016 novembere óta van nyitva. Idén novemberben lesz tíz éve. A közösségi kerülőmegoldás az autoheal konténer, ami a Docker socketet figyelve újraindítgatja az unhealthy társait — működik, csak épp egy plusz konténernek adsz teljes daemon-hozzáférést azért a funkcióért, amit a platformtól várnál.

Aki mégis cselekszik: Compose és Swarm

A státusznak ott van értelme, ahol valaki olvassa is. A Compose a depends_on + condition: service_healthy feltétellel pontosan erre épít: a függő szolgáltatás el sem indul, amíg a függősége healthy nem lesz. Lemértem: egy nyolc másodperc alatt „bemelegedő” api mögött a web konténer 8,6 másodperccel később indult — pontosan az első sikeres próba pillanatában, mert a start_interval: 1s miatt másodpercenként kérdezett rá. Ez a civilizált alternatívája a sleep 10-nek és a wait-for-it.sh-nak:

services:
  api:
    image: sajat-api:latest
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health || exit 1"]
      interval: 5s
      retries: 3
      start_period: 30s
      start_interval: 1s
  web:
    image: sajat-web:latest
    depends_on:
      api:
        condition: service_healthy
        restart: true

A restart: true opcióval a lánc explicit újraindításnál is működik: a docker compose restart api után a Compose megvárta az api healthy állapotát, majd újraindította a webet is. Viszont — és ez a rész szokott kimaradni — a Compose nem daemon: a felállás után senki nem figyeli tovább a státuszt. Ha az api futás közben válik unhealthy-vá, a Compose pont annyit tesz, mint a sima Docker: semmit.

Az egyetlen hely, ahol a Docker maga tényleg cselekszik, a Swarm mód. Ugyanazt a tíz másodperc után elromló szervert service-ként elindítva a task másodpercek alatt repül:

$ docker service ps hc-svc --no-trunc

hc-svc.1  Running 9 seconds ago
hc-svc.1  Failed 17 seconds ago   "task: non-zero exit (137): dockerexec: unhealthy container"
hc-svc.1  Failed 46 seconds ago   "task: non-zero exit (137): dockerexec: unhealthy container"

SIGKILL (137-es exit kód), „unhealthy container”, és már jön is a friss példány — ami tíz másodperc múlva szintén elromlik, úgyhogy a Swarm türelmesen cseréli tovább, a végtelenségig. Ugyanaz a daemon, ugyanaz a healthcheck: docker run alatt dísz, Swarm alatt kivégzőosztag. Ebből az is következik, hogy Swarmban egy rosszul megírt healthcheck nem kozmetikai hiba, hanem restart-loop.

A Kubernetes pedig meg sem hallgatja

A Kubernetes a Dockerfile-os HEALTHCHECK-et nem rosszul használja, és nem is felülbírálja: soha le sem futtatja. A kubelet a saját liveness, readiness és startup próbáit kezeli, amiket a pod manifestben írsz le. Az OCI image-specifikációban a Healthcheck mező hivatalosan is csak „reserved […] to maintain compatibility” — egy Docker-örökség, amit a szabvány udvariasan eltűr, de definiált jelentése nincs, és a containerd nem is kezd vele semmit. A vicc az, hogy a dockershim-korszakban pont fordítva volt baj: a Docker daemon lefuttatta a HEALTHCHECK-et, a kubelet meg a saját próbáit, vagyis az app duplán kapta a terhelést — erről szól a kubernetes#50703 issue. Ma egyszerűbb a képlet: a Kubernetesbe szánt image HEALTHCHECK utasítása halott kód. Nem árt senkinek, csak hamis biztonságérzetet ad annak, aki azt hiszi, ezzel le van tudva a health check.

A hazug healthcheck: exit 127

A másik klasszikus hiba nem az, hogy a healthcheck nem csinál semmit, hanem az, hogy nem igazat mond. Beírod a mindenhonnan másolt curl -f http://localhost:3000/health sort egy node:22-alpine alapú image-be, az app hibátlanul válaszol, a konténer mégis unhealthy lesz:

exit: 127  out: "/bin/sh: curl: not found"

Az alpine-os Node-image-ekben ugyanis nincs curl. (BusyBox wget van, az működik.) Distroless image-ben viszont se shell, se wget, se curl — ott csak arra számíthatsz, ami biztosan ott van: magára a runtime-ra. A Node 18 óta beépített fetch-csel a legrobusztusabb megoldás egy exec formos egysoros, amihez shell sem kell:

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
            --start-period=30s --start-interval=2s \
  CMD ["node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]

A start-period + start-interval páros pedig azt a dilemmát oldja fel, hogy induláskor sűrűn akarsz próbálkozni (hogy a Compose ne várjon feleslegesen), üzem közben viszont felesleges pár másodpercenként plusz processzt indítgatni a konténerben. Így néz ki a próbanapló egy hat másodperc alatt beinduló appnál, a fenti beállításokkal:

09:05:53.108  exit: 1   ← indulás után 2 mp-enként próbálkozik
09:05:55.158  exit: 1
09:05:57.200  exit: 0   ← első siker: a konténer healthy
09:06:27.249  exit: 0   ← innentől 30 mp-enként
09:06:57.299  exit: 0

Az első sikerig kétmásodperces tempó, utána pontosan harminc. A váltás automatikus: az első sikeres próba lezárja a start-periódust, akkor is, ha annak amúgy még lenne hátra ideje.

Mikor éri meg, mikor nem

Compose-alapú dev környezetben és CI-ban a healthcheck + service_healthy szerintem kötelező darab: olcsó, determinisztikus, és kigyomlálja a sleepeket meg a flaky integrációs teszteket. Swarmban ez maga az önjavító mechanizmus — ott tényleg számít, hogy jól van-e megírva. Kubernetesbe szánt image-be viszont felesleges HEALTHCHECK-et írni: senki nem futtatja le, a valódi próbák a manifestben élnek. A sima docker run + restart policy felállásnál pedig tudd, mit veszel: státuszjelzőt a monitoringodnak, nem újraindítást. Ha az utóbbi kell, az vagy Swarm, vagy egy systemd unit, vagy az autoheal — de a HEALTHCHECK magától soha.

A tanulság szárazabb, mint szeretném: a HEALTHCHECK soha nem ígért öngyógyítást, a happy endet mi képzeltük hozzá. A dokumentáció pontosan azt írja, amit a rendszer csinál — csak épp az ember általában akkor olvassa el azt a bekezdést, amikor hajnali kettőkor rácsodálkozik egy hetek óta unhealthy státuszban vidáman futó konténerre.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük