Как стать автором
Обновить
Флант
DevOps-as-a-Service, Kubernetes, обслуживание 24×7

Лучшие практики для деплоя высокодоступных приложений в Kubernetes. Часть 1

Время на прочтение 13 мин
Количество просмотров 26K

Развернуть в 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
  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.

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 лучше либо совсем не использовать, либо делать очень простую liveness-пробу, вроде проверки возможности 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.

Читайте также в нашем блоге:

Теги:
Хабы:
+66
Комментарии 19
Комментарии Комментарии 19

Публикации

Информация

Сайт
flant.ru
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Тимур Тукаев