← Kubernetes: разбор поломок
7 мин · жёстко · Урок 11 из 12

CrashLoop по выходным: агрессивный livenessProbe

Почему сервис падает без нагрузки и как подобрать параметры probe под холодный старт.

Алерт приходит в субботу ночью: сервис в CrashLoopBackOff, счётчик рестартов растёт. К понедельнику всё само чинится, и тикет закрывают как «непонятно, воспроизвести не удалось». Через неделю повторяется.

Привязка ко времени суток это не мистика, а подсказка. Симптом «работает под нагрузкой, падает в покое» почти всегда означает liveness плюс холодный старт.

Почему «по выходным» это диагноз

В будни на сервис идёт трафик. JIT прогрет, кэши заполнены, коннекты к базе установлены, health-check отвечает за 50 мс. В выходные нагрузки нет: Pod пересоздаётся (rolling update, eviction, обслуживание ноды) и новый контейнер стартует с холодного. Первый запрос после старта может ехать секунд десять.

Если livenessProbe бьёт каждые periodSeconds: 10 и терпит failureThreshold: 3 промаха, kubelet убьёт контейнер секунд через 30 после старта. Новый контейнер стартует так же медленно и умирает так же. Круг замкнулся.

Реже за тем же симптомом стоит утечка памяти, которую в будни маскирует GC. Ещё реже таймер, который тикает только без трафика.

Какие параметры probe ломают всё

initialDelaySeconds

Сколько ждать до первого опроса. Дефолт 0 и это плохой выбор: Pod ещё не открыл порт, probe ловит refused и считает это провалом. На холодном старте ставят с запасом: 30 для типичного http-сервиса, 60-120 для JVM.

periodSeconds

Интервал между опросами. Дефолт 10. Уменьшать ниже 5 редко имеет смысл: получаешь нагрузку на приложение без полезного сигнала.

timeoutSeconds

Сколько ждать ответа от probe. Дефолт 1. Маленькое значение для тяжёлой Java или нагруженного Python превращает медленный, но живой ответ в провал. Нормальный диапазон 3-5.

failureThreshold

Сколько неудач подряд считается падением probe. Дефолт 3. При значении 1 контейнер убьёт любая мелкая ошибка. Здравая середина 3-5.

Формула «когда kubelet убьёт контейнер»

totalTimeToKill ≈ initialDelaySeconds + (periodSeconds × failureThreshold)

Агрессивная probe:

livenessProbe:
  httpGet: { path: /, port: 80 }
  initialDelaySeconds: 0
  periodSeconds: 5
  timeoutSeconds: 1
  failureThreshold: 2

Время до kill: 0 + 5*2 = 10 секунд. Любое приложение со стартом дольше 10 секунд гарантированно уходит в CrashLoop.

Путь probe важен не меньше таймингов. / на веб-сервере отвечает 200 раньше, чем приложение готово, а health-эндпоинт с походом в базу превращает деградацию базы в одновременный перезапуск всех Pod.

Безопаснее:

livenessProbe:
  httpGet: { path: /healthz, port: 80 }
  initialDelaySeconds: 30
  periodSeconds: 10
  timeoutSeconds: 3
  failureThreshold: 3

Время до kill: 30 + 10*3 = 60 секунд. Старт до минуты выживет.

холодный старт      первый ответ ~10 сек
агрессивная probe    0 + 5×2  = 10 сек
безопасная probe    30 + 10×3 = 60 сек

Как это выглядит в диагностике

# Сколько раз контейнер уже перезапускался
kubectl get pod -l app=web

# Свежий exit-код и причина
kubectl describe pod -l app=web | grep -A5 -E 'Last State|Reason|Exit Code'

# События про probe
kubectl describe pod -l app=web | grep -A2 Probe

# Логи предыдущего контейнера, уже убитого
kubectl logs -l app=web --previous

В kubectl describe pod появится строка вроде:

Warning  Unhealthy  kubelet  Liveness probe failed: Get "http://10.42.0.17:80/": context deadline exceeded

Это значит, что probe не дождалась ответа. Если рядом стоит Last State: Terminated с Reason: Error и Exit Code: 137, контейнер получил SIGKILL. Приложение не падало, его убил kubelet.

Логи предыдущего контейнера дают вторую половину картины. Если там нормальный старт без ошибок и обрыв на середине инициализации, версия с probe подтверждается.

startupProbe как отдельный инструмент

Появилась в 1.16 как alpha, стабильна с 1.20. Пока startupProbe не вернула success, liveness и readiness даже не запускаются. Приложение прогревается, агрессивный liveness в это время не работает. Подходит для очень долгого старта: Java, ML-модели, прогрев индексов.

startupProbe:
  httpGet: { path: /healthz, port: 80 }
  periodSeconds: 5
  failureThreshold: 30        # 30 * 5 = 150 секунд на прогрев
livenessProbe:
  httpGet: { path: /healthz, port: 80 }
  periodSeconds: 10
  timeoutSeconds: 3
  failureThreshold: 3         # обычная жёсткая проверка после прогрева

Приложение получает до 150 секунд на холодный старт, а зависший в рантайме контейнер всё равно перезапустится за 30 секунд.

Как подбирать параметры

  1. Замерь холодный старт: time curl localhost:port/healthz сразу после запуска. Удвой результат и поставь в initialDelaySeconds.
  2. Время ответа здорового приложения умножь на 3 и поставь в timeoutSeconds.
  3. failureThreshold: 3 это здравый дефолт для большинства сервисов.
  4. periodSeconds: 10 без причины не уменьшай.
  5. Старт дольше 30 секунд выноси в startupProbe, а liveness оставляй жёстким.

Домашка

Теорию закрепляет задание на своём кластере: 1 шаг с проверкой. Кластер поднимается по треку Kubernetes локально, задания лежат в репозитории k8s-practice.

./run.sh start k8s-weekend-crashloop     # развернуть стартовое состояние
./run.sh verify k8s-weekend-crashloop    # проверить текущий шаг

Коротко

Прошли материал до конца? Отметьте урок пройденным.