В этом разделе мы покажем, как управлять жизненным циклом приложения.

Остановка процессов в контейнерах

При остановке контейнера всем процессам в нём отправляется сигнал, указанный в STOPSIGNAL (обычно это TERM). Но не все приложения умеют правильно реагировать на него и делать graceful shutdown, который бы корректно отработал и для приложения, запущенного в Kubernetes.

Например, чтобы сделать корректную остановку nginx, нам понадобится preStop-хук вроде этого:

lifecycle:
  preStop:
    exec:
      command:
      - /bin/sh
      - -ec
      - |
        sleep 3
        nginx -s quit
  1. sleep 3 здесь для страховки от race conditions, связанных с удалением endpoint;

  2. 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