В этой статье мы покажем, как управлять жизненным циклом приложения.
Остановка процессов в контейнерах
При остановке контейнера всем процессам в нём отправляется сигнал, указанный в STOPSIGNAL
(обычно это TERM
). Но не все приложения умеют правильно реагировать на него и делать graceful shutdown, который бы корректно отработал и для приложения, запущенного в Kubernetes.
Например, чтобы сделать корректную остановку nginx, нам понадобится preStop-хук вроде этого:
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -ec
- |
sleep 3
nginx -s quit
-
sleep 3
здесь для страховки от race conditions, связанных с удалением endpoint; -
nginx -s quit
инициирует корректное завершение работы для nginx. Хотя в свежих образах nginx эта строка больше не понадобится, т. к. тамSTOPSIGNAL: SIGQUIT
установлен по умолчанию.
Более подробно про graceful shutdown для nginx в связке с PHP-FPM вы можете узнать из этой статьи.
Корректно ли ваше приложение обработает STOPSIGNAL
, зависит только от него. На практике для большинства приложений приходится гуглить, как оно обрабатывает указанный для него STOPSIGNAL
. И если оказывается, что не так, как надо, то делается preStop-хук, который эту проблему решает, либо же STOPSIGNAL
меняется на тот, который приложение сможет обработать корректно и штатно завершиться.
Ещё один важный параметр, связанный с остановкой приложения, — terminationGracePeriodSeconds
. Он отвечает за то, сколько времени будет у приложения на корректное завершение. Если приложение не успеет завершиться в течение этого времени (30 секунд по умолчанию), то приложению будет послан сигнал KILL
. Таким образом, если вы ожидаете, что выполнение preStop-хука и/или завершение работы приложения при получении STOPSIGNAL
могут занять более 30 секунд, то terminationGracePeriodSeconds
нужно будет увеличить. Например, такое может потребоваться, если некоторые запросы у клиентов веб-сервиса долго выполняются (вроде запросов на скачивание больших файлов).
Стоит заметить, что preStop-хук выполняется блокирующе, т. е. STOPSIGNAL
будет послан только после того, как preStop-хук отработает. Тем не менее, отсчет terminationGracePeriodSeconds
идёт и в течение работы preStop-хука. А процессы, запущенные в хуке, равно как и все процессы в контейнере, получат сигнал KILL
после того, как terminationGracePeriodSeconds
закончится.
Также у некоторых приложений встречаются специальные настройки, регулирующие время, в течение которого приложение должно завершить свою работу (к примеру, опция --timeout
у Sidekiq). Оттого для каждого приложения надо убеждаться, что если у него есть подобная настройка, то она выставлена в значение немного меньшее, чем terminationGracePeriodSeconds
.
Liveness probe
В Kubernetes пробы (healthcheck’и) используются для того, чтобы определить, можно ли переключить на приложение трафик (readiness) и не нужно ли приложение перезапустить (liveness). Они играют большую роль при обновлении Deployment’ов и при запуске новых Pod’ов в целом.
Общая рекомендация для всех проб: выставляйте высокий
timeoutSeconds
. Значение по умолчанию в одну секунду — слишком низкое. Особенно критично дляreadinessProbe
иlivenessProbe
. Слишком низкийtimeoutSeconds
будет приводить к тому, что при увеличении времени ответов у приложений в Pod’ах (что обычно происходит для всех Pod’ов сразу благодаря балансированию нагрузки с помощью Service) либо перестанет приходить трафик почти во все Pod’ы (readiness), либо, что ещё хуже, начнутся каскадные перезапуски контейнеров (liveness).
На практике вам не так часто нужна liveness probe (дословно: «проверка на жизнеспособность»), насколько вы думаете. Её предназначение — перезапустить контейнер с приложением, когда livenessProbe
перестаёт отрабатывать, например, если приложение намертво зависло. На практике подобные deadlock’и скорее исключение, чем правило. Если же приложение работает, но не полностью (например, приложение не может само восстановить соединение с БД, если оно оборвалось), то это нужно исправлять в самом приложении, а не накручивать «костыли» с livenessProbe
.
И хотя как временное решение можно добавить в livenessProbe
проверку на подобные состояния, по умолчанию livenessProbe лучше либо совсем не использовать, либо делать очень простую liveness-пробу, вроде проверки возможности TCP-соединения (обязательно выставьте большой таймаут). В таком случае это поможет приложению перезапуститься при возникновении очевидного deadlock’а, но при этом приложение не подвергнется риску войти в цикл перезапусков, когда перезапуск не может помочь.
И риски, которые плохая livenessProbe
несёт, весьма серьезные. Самые частые случаи: когда livenessProbe
перестаёт отрабатывать по таймауту из-за повышенной нагрузки на приложение, а также когда livenessProbe
перестаёт работать, т. к. проверяет (прямо или косвенно) состояние внешних зависимостей, которые сейчас отказали. В последнем случае последует перезагрузка всех контейнеров, которая при лучшем раскладе ни к чему не приведет, а при худшем — приведет к полной (и, возможно, длительной) недоступности приложения. Полная длительная недоступность приложения может происходить, если при большом количестве реплик контейнеры большинства Pod’ов начнут перезагружаться в течение короткого промежутка времени. При этом какие-то контейнеры, скорее всего, поднимутся быстрее других, и на это ограниченное количество контейнеров теперь придется вся нагрузка, которая приведет к таймаутам у livenessProbe
и заставит контейнеры снова перезапускаться.
Также, если все-таки используете livenessProbe
, убедитесь, что она не перестает отвечать, если у вашего приложения есть лимит на количество установленных соединений и этот лимит достигнут. Чтобы этого избежать, обычно требуется зарезервировать под livenessProbe
отдельный тред/процесс самого приложения. Например, запускайте приложение с 11 тредами, каждый из которых может обрабатывать одного клиента, но не пускайте извне в приложение более 10 клиентов, таким образом гарантируя для livenessProbe
отдельный незанятый тред.
И, конечно, не стоит добавлять в livenessProbe
проверки внешних зависимостей.
Подробнее о проблемах с liveness probe и рекомендациях по предотвращению таких проблем вы можете узнать из этой статьи.
Readiness probe
Дизайн readinessProbe
(дословно: «проверка на готовность [к обслуживанию запросов]»), пожалуй, оказался не очень удачным. Она сочетает в себе две функции: проверять, что приложение в контейнере стало доступным при запуске контейнера, и проверять, что приложение остаётся доступным уже после его запуска. На практике первое нужно практически всегда, а второе примерно настолько же часто, насколько оказывается нужной livenessProbe
. Проблемы с плохими readinessProbe
примерно те же самые, что и с плохими livenessProbe
, и в худшем случае также могут приводить к длительной недоступности приложения.
Когда readinessProbe
перестаёт отрабатывать, то на Pod перестаёт приходить трафик. В большинстве случаев такое поведение мало помогает, т. к. трафик обычно балансируется между Pod’ами более-менее равномерно. Таким образом, чаще всего readinessProbe
либо работает везде, либо не работает сразу на большом количестве Pod’ов. Есть ситуации, когда подобное поведение readinessProbe
может понадобиться, но в нашей практике это скорее исключение.
Тем не менее, у readinessProbe
есть другая очень важная функция: определить, когда только что запущенное в контейнере приложение стало способно принимать трафик, чтобы не пускать трафик в ещё не доступное приложение. Эта же функция readinessProbe
, напротив, нужна нам почти всегда.
Получается странная ситуация, что одна функция readinessProbe
обычно очень нужна, а другая очень не нужна. Эта проблема была решена введением startupProbe
, которая появилась в Kubernetes 1.16 и перешла в Beta в 1.18. Таким образом, рекомендуем для проверки готовности приложения при его запуске в Kubernetes < 1.18 использовать readinessProbe
, а в Kubernetes >= 1.18 — использовать startupProbe
. readinessProbe
всё ещё можно использовать в Kubernetes >= 1.18, если у вас есть необходимость останавливать трафик на отдельные Pod’ы уже после старта приложения.
Startup probe
startupProbe
(дословно: «проверка на запуск») реализует первоначальную проверку готовности приложения в контейнере для того, чтобы пометить текущий Pod как готовый к приёму трафика, или же для того, чтобы продолжить обновление/перезапуск Deployment’а. В отличие от readinessProbe
, startupProbe
прекращает работать после запуска контейнера. Проверять внешние зависимости в startupProbe
не лучшая идея, потому что если startupProbe
не отработает, то контейнер будет перезапущен, что может приводить к переходу Pod’а в состояние CrashLoopBackOff
. При этом состоянии между попытками перезапустить неподнимающийся контейнер будет делаться задержка до пяти минут. Это может означать простой в том случае, когда приложение уже может подняться, но контейнер всё ещё выжидает CrashLoopBackOff
перед тем, как снова попробовать запуститься.
Такая проверка обязательна к использованию, если ваше приложение принимает трафик, и у вас Kubernetes >= 1.18.
Также предпочитайте увеличение failureTreshold
вместо использования initialDelaySeconds
. Это позволит контейнеру становиться доступным настолько быстро, насколько это возможно.
Проверка внешних зависимостей
Часто можно встретить совет проверять внешние зависимости вроде баз данных в readinessProbe
. И хотя такой подход имеет право на существование, предпочтительно разделять проверку внешних зависимостей и проверку на то, не стоит ли остановить идущий на Pod трафик, когда приложение в нём полностью утилизировано.
С помощью initContainers
можно проверять внешние зависимости до того, как начнут запускаться startupProbe/readinessProbe
основных контейнеров. В readinessProbe
, соответственно, проверки внешних зависимостей уже не понадобится. Подобные initContainers
не требуют изменений в коде приложения, не требуют собирать контейнеры приложения с дополнительными утилитами для проверок внешних зависимостей, а также в целом довольно просты в реализации:
initContainers:
- name: wait-postgres
image: postgres:12.1-alpine
command:
- sh
- -ec
- |
until (pg_isready -h example.org -p 5432 -U postgres); do
sleep 1
done
resources:
requests:
cpu: 50m
memory: 50Mi
limits:
cpu: 50m
memory: 50Mi
- name: wait-redis
image: redis:6.0.10-alpine3.13
command:
- sh
- -ec
- |
until (redis-cli -u redis://redis:6379/0 ping); do
sleep 1
done
resources:
requests:
cpu: 50m
memory: 50Mi
limits:
cpu: 50m
memory: 50Mi