W logach pojawia się Liveness probe failed, a pod, który sekundę wcześniej normalnie obsługiwał ruch, dostaje restart. Nic nie jest zepsute. Aplikacja wykonywała w tym momencie pauzę garbage collectora — proces żył, tylko przez chwilę nie odpowiadał, bo maszyna wirtualna Javy porządkowała pamięć i wstrzymała wątki aplikacji. Kubelet nie ma jak odróżnić tych kilku sekund ciszy od zawieszenia.

I na tym polega problem. Liveness probe został zaprojektowany do wykrywania sytuacji, w których proces działa, ale nie robi postępów — na przykład zakleszczenia — i restartuje kontener, gdy sonda zawiedzie więcej razy niż ustawiony próg tolerancji (dokumentacja Kubernetesa). Pauza GC wygląda dla niej identycznie jak zakleszczenie: brak odpowiedzi w wyznaczonym czasie. Różnica jest taka, że po pauzie GC aplikacja wraca sama, a restart tylko niszczy zapytania, które akurat były w locie.

Skutek jest gorszy niż pojedynczy restart. Dokumentacja Kubernetesa ostrzega, że źle skonfigurowana sonda prowadzi do kaskadowych awarii — restartów pod dużym obciążeniem i nieudanych żądań klientów. Dla zespołu operacyjnego wygląda to jak losowa niestabilność.

Trzy sondy, trzy różne pytania

Zanim przejdziemy do progów, trzeba rozdzielić sondy, bo spora część problemów bierze się z mylenia ich ról. Każda odpowiada na inne pytanie i każda ma inną konsekwencję.

Sonda Pytanie Co się dzieje przy niepowodzeniu
startupProbe Czy aplikacja skończyła się uruchamiać? Kontener zostaje zabity, gdy nie zdąży w wyznaczonym czasie
livenessProbe Czy ten proces jeszcze żyje? Restart kontenera
readinessProbe Czy proces może teraz przyjmować ruch? Pod przestaje dostawać ruch, ale działa dalej

Ta ostatnia różnica jest sednem. Readiness wycofuje pod z rotacji na czas, gdy coś mu przeszkadza, i przywraca go, gdy przeszkoda minie — bez utraty stanu i bez zabijania trwających zapytań. Liveness nie ma takiego biegu pośredniego: jedyne, co potrafi, to zabić kontener.

Dlatego pytanie „czy da się teraz obsłużyć ruch" nigdy nie powinno trafić do liveness. A pytanie „czy proces się zaciął" nie powinno zależeć od niczego poza samym procesem.

Startup probe rozwiązuje osobny przypadek: aplikację, która wolno wstaje. Przy 30 próbach co 10 sekund daje 300 sekund na rozruch, zanim liveness w ogóle zacznie działać, a jeśli aplikacja nie zdąży — kontener zostaje zabity i podlega restartPolicy poda (dokumentacja Kubernetesa). To lepsze niż podnoszenie progu liveness na stałe, bo długi rozruch nie musi oznaczać pobłażliwości przez całe życie poda.

Ile czasu naprawdę masz — wzór

Trzy pola decydują, kiedy nastąpi restart, i mają domyślne wartości, których prawie nikt nie zmienia: failureThreshold (domyślnie 3), periodSeconds (domyślnie 10 s) i timeoutSeconds (domyślnie 1 s). Czas od pierwszego niepowodzenia sondy do restartu kontenera wynosi:

failureThreshold × periodSeconds + timeoutSeconds

Dla wartości domyślnych: 3 × 10 + 1 = 31 sekund. To wygląda na dużo i zwykle wystarcza. Problem pojawia się przy popularnym „przyspieszaniu" wykrywania awarii:

# Wygląda rozsądnie, a wywala pody podczas GC
livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
  periodSeconds: 1      # 1 × 1 + 1 = 2 sekundy tolerancji
  timeoutSeconds: 1
  failureThreshold: 1

Dwie sekundy. Opis wdrożenia z resolve.ai pokazuje ten mechanizm wprost: przy periodSeconds: 1 i timeoutSeconds: 1 kubelet oczekuje odpowiedzi w ciągu jednej sekundy, a aplikacje ze zmiennym opóźnieniem — w tym z pauzami GC — będą to sporadycznie zawodzić. Ten sam opis wskazuje, że skutkuje to bezsensownymi restartami, które zabijają zapytania w locie i bywają częstą przyczyną błędów 502 na ingressie.

Warto zapamiętać jeszcze jedno pole: successThreshold domyślnie wynosi 1 i dla liveness oraz startup probe musi pozostać 1.

Dlaczego 200 ms z dokumentacji nie jest gwarancją

Tu leży najczęstsze nieporozumienie. Domyślny cel pauzy G1, MaxGCPauseMillis, wynosi 200 milisekund (dokumentacja Oracle) — i stąd bierze się przekonanie, że dwusekundowy budżet ma ogromny zapas.

To jednak cel, do którego kolektor dąży, a nie twardy limit. G1 w domyślnej konfiguracji nie dąży ani do maksymalnej przepustowości, ani do najniższego opóźnienia, tylko do względnie małych, jednolitych pauz przy wysokiej przepustowości (dokumentacja Oracle).

Full GC to zupełnie inny tryb pracy. Dokumentacja Oracle opisuje go jako proces często bardzo czasochłonny, który zachodzi wtedy, gdy aplikacja alokuje więcej obiektów, niż da się szybko odzyskać — zwykle dlatego, że współbieżne znakowanie nie zdążyło się zakończyć na czas. Innymi słowy: 200 ms to budżet na zwykłą pauzę, a nie na Full GC.

Współbieżne znakowanie startuje, gdy zajętość całej sterty osiąga próg InitiatingHeapOccupancyPercent, domyślnie 45%. Gdy nie zdąży na czas, dochodzi do Full GC. Jest też pułapka konfiguracyjna: ustawienie sztywnego rozmiaru młodej generacji jedną wartością zamiast zakresu praktycznie wyłącza kontrolę czasu pauzy G1. Jeśli heap był kiedyś „dostrojony" w ten sposób, cel 200 ms już nie obowiązuje — a liveness probe i tak zakłada, że obowiązuje.

Jak dobrać progi

Kolejność jest odwrotna niż w większości tutoriali. Nie zaczynaj od liczb z przykładu — zacznij od własnych logów GC i znajdź najdłuższą zaobserwowaną pauzę. Dopiero ta liczba jest wejściem do wzoru.

Praktyczne progi zbierane przez kubernetes.recipes to failureThreshold ≥ 3, timeoutSeconds ≥ 5 s, ponieważ pauzy GC potrafią przekroczyć 1–2 sekundy, oraz periodSeconds ≥ 10 s dla liveness:

# Punkt wyjścia — wynik wzoru zestaw z własnymi logami GC
livenessProbe:
  httpGet:
    path: /healthz     # lekki endpoint, bez zależności zewnętrznych
    port: 8080
  periodSeconds: 10
  timeoutSeconds: 5
  failureThreshold: 3   # 3 × 10 + 5 = 35 sekund tolerancji

Trzydzieści pięć sekund to punkt wyjścia, nie odpowiedź. Dopóki nie zestawisz tej liczby z najdłuższą pauzą Full GC z własnych logów, nie wiesz, czy wystarcza.

Sama sonda jest tania: przy httpGet kubelet czyta odpowiedź tylko do 10KiB, a o powodzeniu decyduje wyłącznie kod statusu. Częstym wzorcem jest użycie tego samego, taniego endpointu co dla readiness, ale z wyższym failureThreshold — to rozsądna domyślna decyzja, bo nie dodaje osobnej ścieżki kodu, którą trzeba utrzymywać.

Antywzorzec, który kosztuje najwięcej

Drugi błąd jest niezależny od GC: liveness odpytująca endpoint zależny od bazy danych. Gdy baza zwalnia, wszystkie repliki zawodzą sondę jednocześnie i restartują się razem — dokładnie wtedy, gdy najmniej Ci to potrzebne. Zamiast jednej wolnej usługi masz całe wdrożenie w pętli restartów.

Liveness ma odpowiadać na pytanie „czy ten proces żyje", nie „czy cała jego zależność działa". To drugie zadaj readiness — wtedy pody wypadają z rotacji na czas awarii bazy i wracają, gdy minie, zamiast się restartować.

Jeśli masz dziś zmienić jedną rzecz: odetnij liveness od bazy danych, a próg policz wobec najdłuższej zaobserwowanej pauzy Full GC z własnych logów — nie wobec liczby skopiowanej z przykładu.