Лучшие практики для деплоя высокодоступных приложений в Kubernetes. Часть 1
Развернуть в Kubernetes приложение в минимально рабочей конфигурации нетрудно. Но когда вы захотите обеспечить своему приложению максимальную доступность и надежность в работе, вы неизбежно столкнётесь с немалым количеством подводных камней. В этот статье мы попытались систематизировать и ёмко описать самые важные правила для развертывания высокодоступных приложений в Kubernetes.
Функциональность, которая не доступна в Kubernetes «из коробки», здесь почти не будет затрагиваться. Также мы не будем привязываться к конкретным CD-решениям и опустим вопросы шаблонизации/генерации Kubernetes-манифестов. Рассмотрены только общие правила, касающиеся того, как Kubernetes-манифесты могут выглядеть в конечном итоге при деплое в кластер.
1. Количество реплик
Вряд ли получится говорить о какой-либо доступности, если приложение не работает по меньшей мере в двух репликах. Почему при запуске приложения в одной реплике возникают проблемы? Многие сущности в Kubernetes (Node, Pod, ReplicaSet и др.) эфемерны, т. е. при определенных условиях они могут быть автоматически удалены/пересозданы. Соответственно, кластер Kubernetes и запущенные в нём приложения должны быть к этому готовы.
К примеру, при автомасштабировании узлов вниз, какие-то узлы вместе с запущенными на них Pod’ами будут удалены. Если в это время на удаляемом узле работает ваше приложение в одном экземпляре, то неизбежна полная — хотя обычно и непродолжительная — недоступность приложения. В целом, при работе в одной реплике любое нештатное завершение работы приложения будет означать простой. Таким образом, приложение должно быть запущено по меньшей мере в двух репликах.
При этом, если реплика экстренно завершает работу, то чем больше рабочих реплик было изначально, тем меньше просядет вычислительная способность всего приложения. К примеру, если у приложения всего две реплики и одна из них перестала работать из-за сетевых проблем на узле, то приложение теперь сможет выдержать только половину первоначальной нагрузки (одна реплика доступна, одна — недоступна). Конечно, через некоторое время новая реплика приложения будет поднята на новом узле, и работоспособность полностью восстановится. Но до тех пор увеличение нагрузки на единственную рабочую реплику может приводить к перебоям в работе приложения, поэтому количество реплик должно быть с запасом.
Рекомендации актуальны, если не используется HorizontalPodAutoscaler. Лучший вариант для приложений, у которых будет больше нескольких реплик, — настроить HorizontalPodAutoscaler и забыть про указание количества реплик вручную. О HorizontalPodAutoscaler мы поговорим в следующей статье.
2. Стратегия обновления
Стратегия обновления у Deployment’а по умолчанию такая, что почти до конца обновления только 75% Pod’ов старого+нового ReplicaSet’а будут в состоянии Ready
. Таким образом, при обновлении приложения его вычислительная способность может падать до 75%, что может приводить к частичному отказу. Отвечает за это поведение параметр strategy.rollingUpdate.maxUnavailable
. Поэтому убедитесь, что приложение не теряет в работоспособности при отказе 25% Pod’ов, либо увеличьте maxUnavailable
. Округление maxUnavailable
происходит вверх.
Также у стратегии обновления по умолчанию (RollingUpdate
) есть нюанс: приложение некоторое время будет работать не только в несколько реплик, но и в двух разных версиях — разворачивающейся сейчас и развернутой до этого. Поэтому, если приложение не может даже непродолжительное время работать в нескольких репликах и нескольких разных версиях, то используйте strategy.type: Recreate
. При Recreate
новые реплики будут подниматься только после того, как удалятся старые. Очевидно, здесь у приложения будет небольшой простой.
Альтернативные стратегии деплоя (blue-green, canary и др.) часто могут быть гораздо лучшей альтернативой RollingUpdate, но здесь мы не будем их рассматривать, так как их реализация зависит от того, какое ПО вы используете для деплоя. Это выходит за рамки текущей статьи. (См. также статью »Стратегии деплоя в Kubernetes: rolling, recreate, blue/green, canary, dark (A/B-тестирование)» в нашем блоге.)
3. Равномерное распределение реплик по узлам
Очень важно разносить Pod’ы приложения по разным узлам, если приложение работает в нескольких репликах. Для этого рекомендуйте планировщику не запускать несколько Pod’ов одного Deployment’а на одном и том же узле:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- podAffinityTerm:
labelSelector:
matchLabels:
app: testapp
topologyKey: kubernetes.io/hostname
Предпочитайте preferredDuringScheduling
вместо requiredDuringScheduling
, который может привести к невозможности запустить новые Pod’ы, если доступных узлов окажется меньше, чем новым Pod’ам требуется. Тем не менее, requiredDuringScheduling
может быть полезен, когда количество узлов и реплик приложения точно известно и необходимо быть уверенным, что два Pod’а не смогут оказаться на одном и том же узле.
4. Приоритет
priorityClassName влияет на то, какие Pod’ы будут schedule’иться в первую очередь, а также на то, какие Pod’ы могут быть «вытеснены» (evicted) планировщиком, если места для новых Pod’ов на узлах не осталось.
Потребуется создать несколько ресурсов типа PriorityClass и ассоциировать их с Pod’ами через priorityClassName
. Набор PriorityClass
'ов может выглядеть примерно так:
Cluster. Priority > 10000. Критичные для функционирования кластера компоненты, такие как kube-apiserver.
Daemonsets. Priority: 10000. Обычно мы хотим, чтобы Pod’ы DaemonSet’ов не вытеснялись с узлов обычными приложениями.
Production-high. Priority: 9000. Stateful-приложения.
Production-medium. Priority: 8000. Stateless-приложения.
Production-low. Priority: 7000. Менее критичные приложения.
Default. Priority: 0. Приложения для окружений не категории production.
Это предохранит нас от внезапных evict’ов важных компонентов и позволит более важным приложениям вытеснять менее важные при недостатке узлов.
5. Остановка процессов в контейнерах
При остановке контейнера всем процессам в нём отправляется сигнал, указанный в 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
.
6. Резервирование ресурсов
Планировщик на основании resources.requests
Pod’а принимает решение о том, на каком узле этот Pod запустить. К примеру, Pod не будет schedule’иться на узел, на котором свободных (т. е. non-requested) ресурсов недостаточно, чтобы удовлетворить запросам (requests) нового Pod’а. А resources.limits
позволяют ограничить потребление ресурсов Pod’ами, которые начинают расходовать ощутимо больше, чем ими было запрошено через requests. Лучше устанавливать лимиты равные запросам, так как если указать лимиты сильно выше, чем запросы, то это может лишить другие Pod’ы узла выделенных для них ресурсов. Это может приводить к выводу из строя других приложений на узле или даже самого узла. Также схема ресурсов Pod’а присваивает ему определенный QoS class: например, он влияет на порядок, в котором Pod’ы будут вытесняться (evicted) с узлов.
Поэтому необходимо выставлять и запросы, и лимиты и для CPU, и для памяти. Единственное, что можно/нужно опустить, так это CPU-лимит, если версия ядра Linux ниже 5.4 (для EL7/CentOS7 версия ядра должна быть ниже 3.10.0–1062.8.1.el7).
(Подробнее о том, что такое requests и limits, какие бывают QoS-классы в Kubernetes, мы рассказывали в этой статье.)
Также некоторые приложения имеют свойство бесконтрольно расти в потреблении оперативной памяти: к примеру, Redis, использующийся для кэширования, или же приложение, которое «течёт» просто само по себе. Чтобы ограничить их влияние на остальные приложения на том же узле, им можно и нужно устанавливать лимит на количество потребляемой памяти. Проблема только в том, что, при достижении этого лимита приложение будет получать сигнал KILL. Приложения не могут ловить/обрабатывать этот сигнал и, вероятно, не смогут корректно завершаться. Поэтому очень желательно использовать специфичные для приложения механизмы контроля за потреблением памяти в дополнение к лимитам Kubernetes, и не доводить эффективное потребление памяти приложением до limits.memory
Pod’а.
Конфигурация для Redis, которая поможет с этим:
maxmemory 500mb # если данные начнут занимать 500 Мб...
maxmemory-policy allkeys-lru # ...Redis удалит редко используемые ключи
А для Sidekiq это может быть Sidekiq worker killer:
require 'sidekiq/worker_killer'
Sidekiq.configure_server do |config|
config.server_middleware do |chain|
# Корректно завершить Sidekiq при достижении им потребления в 500 Мб
chain.add Sidekiq::WorkerKiller, max_rss: 500
end
end
Понятное дело, что во всех этих случаях limits.memory
должен быть выше, чем пороги срабатывания вышеуказанных механизмов.
В следующей статье мы также рассмотрим использование VerticalPodAutoscaler для автоматического выставления ресурсов.
7. Пробы
В Kubernetes пробы (healthcheck’и) используются для того, чтобы определить, можно ли переключить на приложение трафик (readiness) и не нужно ли приложение перезапустить (liveness). Они играют большую роль при обновлении Deployment’ов и при запуске новых Pod’ов в целом.
Сразу общая рекомендация для всех проб: выставляйте высокий timeoutSeconds. Значение по умолчанию в одну секунду — слишком низкое. Особенно критично для readinessProbe и livenessProbe. Слишком низкий timeoutSeconds
будет приводить к тому, что при увеличении времени ответов у приложений в Pod’ах (что обычно происходит для всех Pod’ов сразу благодаря балансированию нагрузки с помощью Service) либо перестанет приходить трафик почти во все Pod’ы (readiness), либо, что ещё хуже, начнутся каскадные перезапуски контейнеров (liveness).
7.1 Liveness probe
На практике вам не так часто нужна liveness probe (дословно: «проверка на жизнеспособность»), насколько вы думаете. Её предназначение — перезапустить контейнер с приложением, когда livenessProbe перестаёт отрабатывать, например, если приложение намертво зависло. На практике подобные deadlock«и скорее исключение, чем правило. Если же приложение работает, но не полностью (например, приложение не может само восстановить соединение с БД, если оно оборвалось), то это нужно исправлять в самом приложении, а не накручивать «костыли» с livenessProbe.
И хотя как временное решение можно добавить в livenessProbe проверку на подобные состояния, по умолчанию livenessProbe лучше вообще не использовать. Как альтернативу её полному отсутствию можно рассмотреть простейшую livenessProbe вроде проверки на то, открыт ли TCP-порт (обязательно выставьте большой таймаут). В таком случае это поможет приложению перезапуститься при возникновении очевидного deadlock’а, но при этом приложение не подвергнется риску войти в цикл перезапусков, когда перезапуск не может помочь.
И риски, которые плохая livenessProbe несёт, весьма серьезные. Самые частые случаи: когда livenessProbe перестаёт отрабатывать по таймауту из-за повышенной нагрузки на приложение, а также когда livenessProbe перестаёт работать, т. к. проверяет (прямо или косвенно) состояние внешних зависимостей, которые сейчас отказали. В последнем случае последует перезагрузка всех контейнеров, которая при лучшем раскладе ни к чему не приведет, а при худшем — приведет к полной (и, возможно, длительной) недоступности приложения. Полная длительная недоступность приложения может происходить, если при большом количестве реплик контейнеры большинства Pod’ов начнут перезагружаться в течение короткого промежутка времени. При этом какие-то контейнеры, скорее всего, поднимутся быстрее других, и на это ограниченное количество контейнеров теперь придется вся нагрузка, которая приведет к таймаутам у livenessProbe и заставит контейнеры снова перезапускаться.
Также, если все-таки используете livenessProbe, убедитесь, что она не перестает отвечать, если у вашего приложения есть лимит на количество установленных соединений и этот лимит достигнут. Чтобы этого избежать, обычно требуется зарезервировать под livenessProbe отдельный тред/процесс самого приложения. Например, запускайте приложение с 11 тредами, каждый из которых может обрабатывать одного клиента, но не пускайте извне в приложение более 10 клиентов, таким образом гарантируя для livenessProbe отдельный незанятый тред.
И, конечно, не стоит добавлять в livenessProbe проверки внешних зависимостей.
(Подробнее о проблемах с liveness probe и рекомендациях по предотвращению таких проблем рассказывалось в этой статье.)
7.2 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’ы уже после старта приложения.
7.3 Startup probe
startupProbe (дословно: «проверка на запуск») реализует первоначальную проверку готовности приложения в контейнере для того, чтобы пометить текущий Pod как готовый к приёму трафика, или же для того, чтобы продолжить обновление/перезапуск Deployment’а. В отличие от readinessProbe, startupProbe прекращает работать после запуска контейнера. Проверять внешние зависимости в startupProbe не лучшая идея, потому что если startupProbe не отработает, то контейнер будет перезапущен, что может приводить к переходу Pod’а в состояние CrashLoopBackOff
. При этом состоянии между попытками перезапустить неподнимающийся контейнер будет делаться задержка до пяти минут. Это может означать простой в том случае, когда приложение уже может подняться, но контейнер всё ещё выжидает CrashLoopBackOff
перед тем, как снова попробовать запуститься.
Обязательна к использованию, если ваше приложение принимает трафик и у вас Kubernetes >= 1.18.
Также предпочитайте увеличение failureTreshold
вместо использования initialDelaySeconds
. Это позволит контейнеру становиться доступным настолько быстро, насколько это возможно.
8. Проверка внешних зависимостей
Часто можно встретить совет проверять внешние зависимости вроде баз данных в 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
Полный пример
Резюмируя, привёдем полный пример того, как уже с учётом всех вышеописанных рекомендаций может выглядеть Deployment stateless-приложения при его боевом развертывании.
Требования: Kubernetes >= 1.18, на узлах Ubuntu/Debian с версией ядра >= 5.4.
apiVersion: apps/v1
kind: Deployment
metadata:
name: testapp
spec:
replicas: 10
selector:
matchLabels:
app: testapp
template:
metadata:
labels:
app: testapp
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- podAffinityTerm:
labelSelector:
matchLabels:
app: testapp
topologyKey: kubernetes.io/hostname
priorityClassName: production-medium
terminationGracePeriodSeconds: 40
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
containers:
- name: backend
image: my-app-image:1.11.1
command:
- run
- app
- --trigger-graceful-shutdown-if-memory-usage-is-higher-than
- 450Mi
- --timeout-seconds-for-graceful-shutdown
- 35s
startupProbe:
httpGet:
path: /simple-startup-check-no-external-dependencies
port: 80
timeoutSeconds: 7
failureThreshold: 12
lifecycle:
preStop:
exec:
["sh", "-ec", "#command to shutdown gracefully if needed"]
resources:
requests:
cpu: 200m
memory: 500Mi
limits:
cpu: 200m
memory: 500Mi
В следующий раз
Осталось ещё немало важных вещей, о которых обязательно надо рассказать, таких как PodDisruptionBudget
, HorizontalPodAutoscaler
и VerticalPodAutoscaler
, чем мы непременно займемся во второй части этой статьи. А пока предлагаем вам поделиться своими лучшими практиками по деплою, либо исправить/дополнить уже описанные.
P.S.
Читайте также в нашем блоге: