Разработчик поставил цель наглядно показать, как именно работают пробы (probes) в Kubernetes: как они делают приложение более устойчивым и как помогают избежать типичных ошибок — вроде циклов перезапуска, на восстановление после которых уходят часы, или потери запросов во время выката новых версий.

Каждая интерактивная демонстрация в материале построена на webernetes — частичном порте Kubernetes на TypeScript. Проект содержит более 100 000 строк перенесённого Go-кода Kubernetes и позволяет запускать симулированный кластер прямо в браузере. Поведение демо-стендов проверено на реальном k3s, и в процессе этой проверки удалось обнаружить настоящий баг в Kubernetes — о нём подробнее ниже.

Под без проб

Рассмотрим под с одним контейнером. Вот его манифест, pod-a.yaml:

apiVersion: "v1"
kind: "Pod"
metadata:
  name: "pod-a"
spec:
  containers:
    - name: "app"
      image: "my-app:latest"

Образ my-app:latest несколько секунд инициализируется, прежде чем начать слушать порт 8080. При перезапуске контейнера сигналом, вызывающим краш, Kubernetes поднимает его заново.

После первого краша контейнер перезапускается сразу же. После второго Kubernetes применяет к нему CrashLoopBackOff, прежде чем запустить снова. По умолчанию задержка составляет 10 секунд, удваиваясь с каждым крашем вплоть до максимума в 5 минут.

В обоих случаях Kubernetes считает контейнер Ready, как только тот запускается, хотя на деле это не так — контейнер ещё выполняет работу по инициализации и не слушает порт 8080.

Добавим pod-b, который отправляет запрос к pod-a каждые 2 секунды. На протяжении статьи pod-b можно считать любым источником клиентского трафика: ingress-контроллером, балансировщиком нагрузки, межсервисными запросами и так далее.

Если перезапустить pod-a в момент, когда запрос уже в пути, этот запрос завершится ошибкой.

С момента перезапуска контейнера и до завершения его инициализации запросы будут падать, хотя контейнер при этом считается Ready. Это не тот результат, который нужен — Kubernetes должен знать, когда pod-a действительно готов принимать трафик.

Для этого в Kubernetes есть пробы. Пробы — это периодические проверки, отправляемые в контейнеры для определения их состояния. Их три вида:

  • Startup-пробы определяют, запустилось ли приложение внутри контейнера.
  • Readiness-пробы определяют, готово ли приложение принимать трафик.
  • Liveness-пробы определяют, нужно ли перезапустить приложение.

Судя по всему, startup-пробы лучше всего подходят для решения проблемы, показанной в демонстрациях выше — с них и стоит начать.

Startup-пробы

Добавим startup-пробу в pod-a.yaml:

apiVersion: "v1"
kind: "Pod"
metadata:
  name: "pod-a"
spec:
  containers:
    - name: "app"
      image: "my-app:latest"
      startupProbe:
        httpGet:
          path: "/startup"
          port: 8080
        periodSeconds: 1
        failureThreshold: 5

Это проба типа httpGet, которая отправляет запрос GET /startup на порт 8080 пода. Коды состояния 200–399 засчитываются как успех. Это происходит каждые periodSeconds секунд, и допускается failureThreshold подряд неудачных попыток, прежде чем Kubernetes убьёт контейнер. Это даёт контейнеру примерно 5 секунд на завершение работы по инициализации.

Kubernetes также поддерживает пробы tcpSocket, exec и grpc. Они устанавливают TCP-соединение, выполняют команду внутри контейнера или вызывают протокол проверки здоровья gRPC для определения состояния контейнера. Подробнее о них можно прочитать в документации Kubernetes.

Пробы отправляет процесс, называемый kubelet. У каждого узла кластера есть свой kubelet, и именно он отвечает за то, чтобы на узле работали нужные поды и чтобы они проходили проверки.

При перезапуске pod-a теперь он отображается как NotReady. Kubernetes теперь знает, что pod-a ещё не инициализировался. Под становится Ready только после успешного прохождения первой startup-пробы.

NotReady — это состояние по умолчанию для подов с контейнерами, у которых есть startup-проба. Однако даже когда под не готов, pod-b всё равно отправляет запросы к pod-a, и эти запросы всё ещё завершаются ошибкой во время периода инициализации контейнера. Это происходит потому, что pod-b настроен на отправку запросов напрямую на IP-адрес pod-a, что обходит механизм готовности.

Технически у Kubernetes нет условия NotReady — есть условие Ready, которое может принимать значения True, False или Unknown. Обозначение NotReady используется просто как более короткая форма записи Ready=False.

Чтобы устранить эти сбойные запросы, нужно перейти к более production-подобной конфигурации: несколько копий pod-a с балансировкой запросов между ними. Создадим ReplicaSet, настроенный на запуск 2 реплик pod-a, и Service для балансировки между ними.

apiVersion: "apps/v1"
kind: "ReplicaSet"
metadata:
  name: "replica-set-a"
spec:
  # Run 2 copies of the pod defined under `template`.
  replicas: 2
  selector:
    matchLabels:
      # Consider pods with this label to be part of this replica set.
      app: "pod-a"
  template:
    metadata:
      labels:
        app: "pod-a"
    spec:
      # The same pod spec from before.
      containers:
        - name: "app"
          image: "my-app:latest"
          startupProbe:
            httpGet:
              path: "/startup"
              port: 8080
            periodSeconds: 1
            failureThreshold: 5
apiVersion: "v1"
kind: "Service"
metadata:
  name: "service-a"
spec:
  selector:
    # Load-balance between pods that have this label.
    app: "pod-a"
  ports:
    # Send requests to this port on the pods.
    - port: 80
      targetPort: 8080

pod-b теперь будет отправлять запросы на DNS-имя, которое Kubernetes создаёт для Service — в данном случае service-a.default.svc.cluster.local — вместо обращения напрямую к отдельному поду. Kubernetes использует условие Ready пода, чтобы включать или исключать его из балансировки нагрузки Service.

Если краш вызывается только у верхнего контейнера, запросы всегда направляются в нижний. Когда контейнер получает статус NotReady, это делает весь под неготовым, и он не будет получать трафик ни от одного из Service, в которые он входит.

Несмотря на это, запросы всё же могут завершаться ошибкой, если они находятся в процессе выполнения в момент перезапуска верхнего контейнера. Это происходит потому, что перезапуск обрывает контейнер резко — у него нет шанса завершить запросы, находящиеся в процессе выполнения.

Правильнее в этой ситуации удалить под и положиться на ReplicaSet, чтобы тот поднял новый. Это лучше по двум причинам:

  1. Kubernetes по умолчанию даёт подам 30-секундный льготный период на завершение работы. При удалении поды считаются terminating, и Kubernetes исключает их из всех Service, в которые они входят. Они больше не будут получать новых запросов.
  2. ReplicaSet не учитывает terminating-поды как активные реплики, поэтому замена создаётся сразу же, как только удалённый под начинает завершаться.

Вместе плавное завершение и startup-проба удерживают запросы подальше от контейнеров, которые запускаются или останавливаются. При удалении пода ни один запрос от pod-b не завершается ошибкой — всегда есть готовый под для обслуживания нового запроса, что делает безопасным удаление подов без прерывания пользовательского трафика.

При удалении пода kubelet сначала отправляет контейнеру сигнал SIGTERM, а спустя terminationGracePeriodSeconds отправляет SIGKILL, если контейнер всё ещё работает. Подробности — в документации Kubernetes о жизненном цикле пода. В демонстрациях контейнеры настроены на завершение через 1 секунду после получения SIGTERM — этого достаточно, чтобы завершить запросы, находящиеся в процессе выполнения. При настройке на своих подах важно оставлять достаточно времени для завершения самых долгих запросов.

Как неправильно настроить startup-пробу

Ранее упоминалось, что контейнеру даётся примерно 5 секунд на завершение инициализации за счёт установки failureThreshold в 5 при periodSeconds, равном 1. Эти значения стоит подбирать внимательно для собственных контейнеров — слишком малое время может привести к циклу перезапусков.

Если снизить failureThreshold до 1 или 2, после нескольких перезапусков pod-a попадает в CrashLoopBackOff. Startup-проба никогда не даёт контейнеру достаточно времени на запуск, поэтому такое демо будет циклически падать, пока failureThreshold не вернут обратно к значению 3 или выше. При настройке своих контейнеров стоит выбирать значения, учитывающие худший сценарий времени запуска.

Readiness-пробы

После успешного прохождения startup-пробы readiness-пробы отслеживают состояние контейнера всё оставшееся время его жизни. Провал readiness-пробы помечает контейнер как NotReady и исключает его из получения запросов для любого Service, в который он входит.

pod-a.yaml теперь настроен только с readiness-пробой:

apiVersion: "v1"
kind: "Pod"
metadata:
  name: "pod-a"
spec:
  containers:
    - name: "app"
      image: "my-app:latest"
      readinessProbe:
        httpGet:
          path: "/ready"
          port: 8080
        periodSeconds: 3
        failureThreshold: 1
        successThreshold: 1

Проба отправляется на эндпоинт /ready каждые 3 секунды. После единственного сбоя контейнер получает условие NotReady.

Пробы за пределами расписания. Readiness-пробы отправляются гораздо чаще, чем раз в 3 секунды. Документация Kubernetes объясняет это так:
Пока контейнер не находится в состоянии Ready, readiness-проба может выполняться не только по расписанию periodSeconds — это делается, чтобы под быстрее стал готовым.
Формулировка довольно расплывчатая. В ходе работы над webernetes удалось выяснить, что запускать такие внеплановые пробы может множество событий. Если не углубляться в детали — практически любое обновление пода, пока контейнер находится в состоянии NotReady, способно вызвать внеплановую пробу: обновление аннотаций, статуса и тому подобное. Многие вещи могут обновлять поды незаметно для разработчика. Занятный факт, но полагаться на такое поведение на практике не стоит.

В примере выше failureThreshold и successThreshold установлены в 1, но одиночный кратковременный сбой не должен исключать поды из обслуживания Service. Если установить пороги в 2, потребуется уже 2 сбоя, прежде чем контейнер станет NotReady.

При переключении из готового состояния в неготовое можно заметить срабатывание внеплановой пробы — по тем же причинам, что и раньше: под становится NotReady, и его статус только что обновился.

По умолчанию successThreshold равен 1, а failureThreshold — 3. Это в целом хорошие значения по умолчанию, менять которые без веской причины не рекомендуется.

Зачем нужны startup-пробы, если есть readiness-пробы?

В демонстрациях выше использовалась только readiness-проба. Проверка начинается сразу и не проходит успешно, пока контейнер не завершит инициализацию — это ровно та же задача, которую выполняла startup-проба. Зачем тогда нужны оба типа проб?

Есть несколько веских причин:

  1. Startup-пробы откладывают начало readiness- и liveness-проверок до завершения инициализации.
  2. Они позволяют задать отдельные periodSeconds и failureThreshold для запуска, так что медленную инициализацию можно проверять чаще, чем стабильную работу.
  3. Повторные сбои startup-пробы убивают контейнер и применяют политику перезапуска. Сбои readiness-пробы этого не делают. Перезапуск может помочь застрявшему контейнеру стать готовым.

Несколько типов проб можно использовать одновременно. Например, startup-пробу можно отправлять каждую секунду для быстрого обнаружения инициализации, а readiness-пробу замедлить до раза в 5 секунд, чтобы снизить постоянную нагрузку от проверок на контейнер и kubelet.

apiVersion: "v1"
kind: "Pod"
metadata:
  name: "pod-a"
spec:
  containers:
    - name: "app"
      image: "my-app:latest"
      startupProbe:
        httpGet:
          path: "/startup"
          port: 8080
        periodSeconds: 1
        failureThreshold: 5
      readinessProbe:
        httpGet:
          path: "/ready"
          port: 8080
        periodSeconds: 5

Readiness-пробы не начинаются, пока не пройдёт успешно startup-проба. Эта гарантия позволяет проверять специфичные для запуска вещи в эндпоинте /startup — например, убедиться, что загружены начальные конфигурации или прогреты кэши. На практике startup-пробы используются реже readiness-проб, но полезно знать, что они есть в качестве опции при необходимости.

Если возникает желание, чтобы readiness-пробы могли перезапускать контейнеры — для этого есть отдельный механизм.

Liveness-пробы

Последний тип проб — liveness-проба. Она работает так же, как readiness-проба, но вместо того чтобы пометить контейнер как NotReady по достижении failureThreshold, liveness-проба убивает контейнер. Kubernetes затем применяет политику restartPolicy пода, которая по умолчанию равна "Always" — это значит, что убитый контейнер будет перезапущен.

apiVersion: "v1"
kind: "Pod"
metadata:
  name: "pod-a"
spec:
  containers:
    - name: "app"
      image: "my-app:latest"
      startupProbe:
        httpGet:
          path: "/startup"
          port: 8080
        periodSeconds: 1
        failureThreshold: 5
      livenessProbe:
        httpGet:
          path: "/live"
          port: 8080
        periodSeconds: 2
        failureThreshold: 1

Это помогает в случаях, когда контейнер не может восстановиться самостоятельно — например, если завис главный поток или умер критический фоновый поток. Если такие условия можно надёжно обнаружить, можно намеренно провалить liveness-пробу и положиться на Kubernetes для перезапуска контейнера.

При установке эндпоинта /live в состояние ошибки происходит странная последовательность событий:
  1. Liveness-проба проваливается.
  2. Kubelet перезапускает контейнер pod-a.
  3. Прежде чем успевает сработать startup-проба, срабатывает и проваливается liveness-проба, потому что контейнер всё ещё находится в состоянии запуска.
  4. Kubelet перезапускает контейнер снова, ещё до того, как тот успел пройти startup-пробу!
Это реальный баг Kubernetes, обнаруженный при подготовке материала. Liveness-пробы не должны срабатывать до успешного прохождения startup-пробы. Баг воспроизведён в k3s, minikube и kind на актуальной на момент написания версии Kubernetes v1.36.2. Похоже, он появился в v1.35.0. По нему заведён issue, и SIG Node приняла его, присвоив метку priority/important-soon.

Как неправильно настроить liveness-пробу

Было бы плохой идеей проверять в liveness-пробе состояние базы данных. Кратковременный сбой базы может привести к циклическому падению всех контейнеров, если он продлится достаточно долго.

При отключении базы данных liveness-пробы pod-a начинают проваливаться. После нескольких сбоев каждый контейнер уходит в CrashLoopBackOff. Чтобы подчеркнуть, насколько это плохо, задержка backoff масштабируется так же, как в реальном Kubernetes: сначала 10 секунд, с удвоением при каждом краше.

Проблема усугубляется, если клиенты повторяют запросы. Контейнеры pod-a способны обрабатывать только 3 запроса в секунду — при превышении этого лимита они перегружаются и падают. Если настроить pod-b на повторную отправку неудачных запросов в цикле, ситуация ухудшается ещё больше.

Повторные попытки создают так называемое стадное поведение (thundering herd), которое вызывает каскадный отказ. Не имеет значения, что база данных снова работает — любой контейнер, посмевший восстановиться, тут же получает лавину трафика, которая убивает его заново.

Пробы, к сожалению, здесь не помогут. Потребовался бы механизм, пропускающий лишь небольшой процент трафика, давая контейнерам время на восстановление, а затем постепенно наращивающий нагрузку до полного объёма. Если есть контроль над клиентами — например, это собственное мобильное приложение — можно добавить задержку с нарастанием (backoff) для повторных попыток, что замедлит рост трафика и облегчит восстановление.

Но лучшее, что можно сделать — это вообще избежать подобной ошибки. Liveness-пробу стоит проваливать только тогда, когда сбой локален для одного контейнера и перезапуск с высокой вероятностью его исправит. Не стоит проваливать пробу по условиям, которые одновременно истинны для всех контейнеров.

Пробы и Deployment

Последнее, на что стоит обратить внимание — как пробы влияют на Deployment. В Kubernetes большая часть spec пода неизменяема. Способ обновить неизменяемое поле — создать новый под и удалить старый. Deployment управляет этой заменой как «rollout» (выкатом).

Рассмотрим deployment-a.yaml в качестве примера:

apiVersion: "apps/v1"
kind: "Deployment"
metadata:
  name: "deployment-a"
spec:
  replicas: 3
  strategy:
    type: "RollingUpdate"
    rollingUpdate:
      maxUnavailable: "25%"
      maxSurge: "25%"
  selector:
    matchLabels:
      app: "pod-a"
  template:
    metadata:
      labels:
        app: "pod-a"
    spec:
      containers:
        - name: "app"
          image: "my-app:latest"
          ports:
            - name: "http"
              containerPort: 8080
          startupProbe:
            httpGet:
              path: "/startup"
              port: "http"
            periodSeconds: 1
            successThreshold: 1
            failureThreshold: 5

Раздел strategy контролирует, как выкатываются новые поды. Deployment сначала создаёт ReplicaSet, поднимающий заданное число replicas. Изменение template Deployment после создания создаёт новый, второй ReplicaSet, настроенный по новому шаблону. Затем Deployment масштабирует новый ReplicaSet вверх, одновременно уменьшая старый, согласно параметрам strategy.

Вот что означает каждый параметр strategy:

  1. type: "RollingUpdate" обновляет поды постепенно, а не все сразу. Для одновременного обновления всех подов используется type: "Recreate" — сначала старый ReplicaSet масштабируется до 0, затем новый до заданного числа replicas. Это вызывает простой, поэтому не используется по умолчанию.
  2. maxUnavailable: "25%" допускает floor(3 * 0.25) = 0 недоступных реплик, то есть все 3 должны оставаться доступными в течение выката.
  3. maxSurge: "25%" допускает ceil(3 * 0.25) = 1 дополнительный под сверх replicas во время выката — в данном случае разрешено существование 4 реплик.

Выкат обязан поддерживать 3 пода в состоянии Ready постоянно и может доходить до 4 реплик благодаря maxSurge. Поды в состоянии terminating не считаются доступными, поэтому иногда одновременно может существовать больше 4 реплик.

Выкат может создать только 1 дополнительный под и должен дождаться, пока он станет Ready, прежде чем убить старый под. Это значит, что пробы напрямую влияют на скорость выката. При указанной конфигурации завершение занимает около 11 секунд, и ни один запрос от pod-b не проваливается.

Если изменить periodSeconds с 1 на 5, выкат занимает уже около 19–20 секунд. Более длинные периоды startup-пробы замедляют выкат, потому что каждый новый под должен дождаться прохождения проверки.

А что произойдёт, если обновить Deployment вовсе без проб? В этом случае выкат вызывает небольшое количество сбоев запросов, потому что новые контейнеры ещё не завершили инициализацию. Выкат без проб происходит очень быстро, поскольку каждый контейнер считается готовым сразу после запуска — это и приводит к небольшому проценту неудачных запросов, пока контейнеры ещё не завершили запуск.

Советы по проектированию хороших эндпоинтов для проб

Startup

  1. Используйте их, когда запуск медленный или непредсказуемый по времени, либо есть инициализационная работа, которая может застрять и требует перезапуска.
  2. Проверяйте часто для быстрого обнаружения инициализации. При снижении periodSeconds увеличивайте failureThreshold, чтобы сохранить общее время ожидания запуска. Ориентируйтесь на худший сценарий времени старта плюс небольшой запас.
  3. Используйте отдельный эндпоинт /startup, если есть проверки, гарантирующие завершение инициализации. Если нет — вполне разумно использовать тот же эндпоинт, что и для liveness-проверки.

Readiness

  1. Делайте эту пробу дешёвой и консервативной. Проваливайте её только тогда, когда исключение пода из обслуживания вероятно улучшит общее состояние сервиса.
  2. Не стоит проваливать readiness на основе состояния общих зависимостей, таких как серверы баз данных и сторонние API. Включайте зависимость только тогда, когда контейнер действительно не может обслуживать полезный трафик без неё.
  3. Не стоит проваливать readiness в ответ на высокую загрузку CPU или памяти. Если сервис близок к полной загрузке, удаление реплики может вызвать каскадный отказ.

Liveness

  1. Проваливайте эту пробу только тогда, когда очень вероятно, что контейнер завис и перезапуск поможет. Если нет уверенности — возвращайте успех.
  2. Не проваливайте liveness на основе состояния общих зависимостей вроде серверов баз данных и сторонних API.
  3. Не проваливайте liveness в ответ на высокую загрузку CPU или памяти.

Общие рекомендации

  1. Держите пробы ограниченными по времени и дешёвыми. У startup-проб есть немного больше пространства для манёвра, чем у двух других, но они всё равно потребляют ресурсы кластера, которые могли бы использоваться для обслуживания пользовательского трафика.
  2. failureThreshold по умолчанию равен 3. Снижайте это значение только тогда, когда немедленное вмешательство стоит риска реакции на кратковременный сбой.

Заключение

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

У ngrok есть официальный Kubernetes-оператор. Он поддерживает как Ingress, так и Gateway API, а также позволяет декларативно создавать agent endpoints в кластере. Подробнее — в документации.