Примечание переводчика: это не первый наш перевод материалов Learnk8s и её управляющего директора Daniele Polencic. В благодарность за интересную статью мы оставляем здесь ссылку на страницу компании. Learnk8s специализируется на обучении Kubernetes и предлагает очные и онлайн-курсы под руководством опытных инструкторов, оказывает консультационные услуги и проводит публичные воркшопы. Также на их сайте вы найдёте интересные исследования. На этом передаём слово автору. 

TL;DR: из этой статьи вы узнаете, как предотвратить разрыв соединений при запуске или остановке пода. А ещё мы поговорим о том, как корректно завершать длительные (long-running) задачи и соединения.

Скачать инфографику в PDF

В Kubernetes создание и удаление подов — одна из самых распространённых задач. Поды создаются при выполнении скользящего обновления, масштабировании Deployment’ов, для каждого нового релиза, для каждой задачи (Job), CronJob и т. д. Кроме того, поды удаляются и воссоздаются после вытеснений — например, когда вы помечаете узел как не подлежащий планированию (kubectl cordon $NODENAME).

Если природа этих подов настолько эфемерна, то что случится, если сигнал о завершении работы поступит в под, когда тот будет обрабатывать запрос? Будет ли запрос выполнен до отключения? А как насчёт последующих запросов? Будут ли они перенаправлены куда-либо ещё?

Что происходит, когда вы создаёте под в Kubernetes

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

apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  containers:
    - name: web
      image: nginx
      ports:
        - name: web
          containerPort: 80

Применить YAML-манифест в кластере можно с помощью следующей команды:

$ kubectl apply -f pod.yaml

Когда вы нажмёте на Enter, kubectl отправит манифест пода в Kubernetes API. Именно здесь и начинается наше путешествие. API получает и проверяет манифест пода, которое затем сохраняется в базе данных — etcd. Под также добавляется в очередь планировщика.

Планировщик:

  1. Изучает манифест.

  2. Собирает подробности о рабочей нагрузке (запросы на ресурсы CPU и памяти).

Решает, какой узел лучше всего подходит для его запуска (с помощью процесса, известного как Filters and Predicates).

Примечание переводчика о фильтрах и предикатах 

Фильтры (Filters) в Kubernetes используются для сокращения списка узлов, подходящих для запуска конкретного пода. Это первый этап в процессе планирования. Здесь проверяются различные атрибуты узлов и подов, чтобы оценить их совместимость.

Несколько примеров:

  • Node Affinity/Anti-Affinity — проверка, удовлетворяет ли узел требования affinity или anti-affinity, указанные в спецификации пода. 

  • Taints and Tolerations проверяет, могут ли поды размещаться на узлах, которые имеют назначенные taints. Под должен иметь соответствующую toleration, чтобы быть запланированным на таком узле. 

  • Node Resources — оценка, достаточно ли на узле ресурсов (CPU, памяти и других) для запуска конкретного пода. 

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

Например:

  • PodFitsHostPorts — проверка, соответствуют ли порты, которые требуют поды, доступным портам на узле. 

  • PodFitsResources — оценка, имеется ли на узле достаточно ресурсов (CPU, памяти и других) для каждого пода. 

  • NoDiskConflict — проверка, не вызывает ли диск, требуемый подом, конфликтов на узле. 

В конце процесса:

  • Под помечается как запланированный в etcd.

  • Под приписывается к конкретному узлу.

  • Состояние пода хранится в etcd.

Резюме блока в картинках
После ввода команды kubectl apply -f YAML пода отправляется в Kubernetes API
После ввода команды kubectl apply -f YAML пода отправляется в Kubernetes API
API сохраняет информацию о поде в базу данных etcd
API сохраняет информацию о поде в базу данных etcd
Планировщик выделяет лучший узел для пода, и его статус меняется на Pending. Под существует только в etcd
Планировщик выделяет лучший узел для пода, и его статус меняется на Pending. Под существует только в etcd

Предыдущие действия производились в управляющем слое (control plane), а состояние сохранялось в etcd. Так кто же создаёт поды на узлах?

kubelet создаёт поды и следит за ними 

Работа kubelet — опрашивать управляющий слой на предмет обновлений. Представьте, что он неустанно спрашивает у control plane: «Я присматриваю за рабочим узлом 1; есть ли для меня новый под?» Когда под появляется, kubelet создаёт его. Ну, вроде того.

kubelet не создаёт под самостоятельно. Вместо этого он делегирует работу трём другим компонентам:

  1. Container Runtime Interface (CRI) создаёт контейнеры для пода.

  2. Container Network Interface (CNI) подключает контейнеры к сети кластера и назначает им IP-адреса.

  3. Container Storage Interface (CSI) монтирует тома в контейнерах.

В большинстве случаев Container Runtime Interface делает примерно то же, что и команда ниже:

$ docker run -d <my-container-image>

Container Networking Interface чуточку интереснее, поскольку отвечает за:

  • генерирование валидного IP-адреса для пода;

  • подключение контейнера к остальной сети.

Как вы понимаете, существует несколько способов подключить контейнер к сети и назначить ему валидный IP-адрес (IPv4 или IPv6, несколько IP-адресов).

Чтобы больше узнать о сетевых пространствах имён Linux и CNI, ознакомьтесь с этой статьёй о трассировке сетевого трафика в Kubernetes.

Когда Container Network Interface завершает свою работу, под подключается к остальной сети и получает корректный IP-адрес. Есть только одна проблема. kubelet знает об IP-адресе (потому что он, собственно, и запустил CNI), а вот управляющий слой о нём не в курсе. Никто не сообщил control plane, что под получил IP-адрес и готов принимать трафик. Так что управляющий слой продолжает считать, что под всё ещё создается.

Задача kubelet'а — собрать всю информацию о поде, такую как IP-адрес, и сообщить её управляющему слою. etcd подскажет, где запущен под и каков его IP-адрес.

Резюме блока в картинках
kubelet опрашивает управляющий слой на предмет наличия обновлений
kubelet опрашивает управляющий слой на предмет наличия обновлений
Как только новый под закрепляется за узлом, kubelet получает информацию о нём
Как только новый под закрепляется за узлом, kubelet получает информацию о нём
kubelet не создаёт под самостоятельно. Он использует интерфейсы Container Runtime Interface, Container Network Interface и Container Storage Interface
kubelet не создаёт под самостоятельно. Он использует интерфейсы Container Runtime Interface, Container Network Interface и Container Storage Interface
Как только все три интерфейса сделают свою работу, под получит статус Running и ему будет присвоен IP-адрес
Как только все три интерфейса сделают свою работу, под получит статус Running и ему будет присвоен IP-адрес
Kubelet сообщает IP-адрес управляющему слою
Kubelet сообщает IP-адрес управляющему слою

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

Поды, сервисы и эндпоинты

При создании сервиса обычно есть два момента, на которые следует обратить внимание:

  • Селектор для поиска подов, которые будут получать трафик.

  • TargetPort — порт, через который поды будут получать трафик.

Типичное YAML-описание сервиса выглядит так:

apiVersion: v1
kind: Service
metadata:
  name: my-service
spec:
  ports:
  - port: 80

   targetPort: 3000

 selector:

   name: app

Когда вы применяете конфиг сервиса с помощью kubectl apply, Kubernetes находит все поды с тем же лейблом, что прописан в селекторе name: app, и собирает их IP-адреса — но только если они прошли проверку на готовность (то есть Readiness-пробу). Затем для каждого IP-адреса Kubernetes объединяет IP-адрес и порт.

Предположим, что IP-адрес равен 10.0.0.3, а targetPort — 3000. Тогда Kubernetes объединит их и получит то, что называется эндпоинтом или конечной точкой.

IP address + port = endpoint
---------------------------------
10.0.0.3 + 3000 = 10.0.0.3:3000

Эндпоинты хранятся в etcd в объекте под названием Endpoint.

В общем: 

  • эндпоинт — это пара IP-адрес + порт (10.0.0.3:3000);

  • Endpoint — это объект с набором эндпоинтов.

Endpoint — реальный объект. Kubernetes автоматически создаёт его для каждого сервиса. Убедитесь в этом сами:

$ kubectl get services,endpoints

NAME                   TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)
service/my-service-1   ClusterIP   10.105.17.65   <none>        80/TCP
service/my-service-2   ClusterIP   10.96.0.1      <none>        443/TCP

NAME                     ENDPOINTS
endpoints/my-service-1   172.17.0.6:80,172.17.0.7:80
endpoints/my-service-2   192.168.99.100:443


$

Endpoint собирает все IP-адреса и порты из подов. Но это не разовое событие. Он обновляется всякий раз, когда:

  • под создаётся;

  • под удаляется;

  • меняется лейбл пода.

То есть всякий раз, когда под создаётся и kubelet отправляет его IP-адрес в управляющий слой, Kubernetes обновляет объект Endpoint, чтобы отразить это изменение:

$ kubectl get services,endpoints

NAME                   TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)
service/my-service-1   ClusterIP   10.105.17.65   <none>        80/TCP
service/my-service-2   ClusterIP   10.96.0.1      <none>        443/TCP

NAME                     ENDPOINTS
endpoints/my-service-1   172.17.0.6:80,172.17.0.7:80,172.17.0.8:80
endpoints/my-service-2   192.168.99.100:443
$

Эндпоинт доставлен в управляющий слой, и объект Endpoint обновлён.

Резюме блока в картинках
На этом рисунке в кластере развёрнут один под. Он является частью сервиса. Сведения о поде и сервисе хранятся в etcd
На этом рисунке в кластере развёрнут один под. Он является частью сервиса. Сведения о поде и сервисе хранятся в etcd
Что происходит после развёртывания нового пода?
Что происходит после развёртывания нового пода?
Kubernetes должен следить за подом и его IP-адресом. Сервис будет направлять трафик на новый эндпоинт, поэтому IP-адрес и порт должны передаваться всем заинтересованным сторонам
Kubernetes должен следить за подом и его IP-адресом. Сервис будет направлять трафик на новый эндпоинт, поэтому IP-адрес и порт должны передаваться всем заинтересованным сторонам
Что произойдёт, если развернуть ещё один под?
Что произойдёт, если развернуть ещё один под?
То же самое. В базе данных для нового пода будет создан соответствующий «ряд» с информацией об эндпоинте
То же самое. В базе данных для нового пода будет создан соответствующий «ряд» с информацией об эндпоинте
Но что произойдёт после удаления пода?
Но что произойдёт после удаления пода?
Сервис сразу удалит эндпоинт, после чего под (в конечном итоге) будет удалён и из базы данных
Сервис сразу удалит эндпоинт, после чего под (в конечном итоге) будет удалён и из базы данных
Kubernetes реагирует на все изменения в кластере
Kubernetes реагирует на все изменения в кластере

Готовы начать использовать под? Но это ещё не всё.

Эндпоинты — «валюта» Kubernetes

Их используют различные компоненты K8s. Kube-proxy с помощью эндпоинтов настраивает правила iptables на узлах. Каждый раз, когда объект Endpoint меняется, kube-proxy получает новый список IP-адресов и портов и пишет новые правила iptables.

Резюме блока в картинках
Рассмотрим кластер из трёх узлов с двумя подами и без сервисов. Состояние подов хранится в etcd
Рассмотрим кластер из трёх узлов с двумя подами и без сервисов. Состояние подов хранится в etcdЧто произойдёт, если создать сервис?
Kubernetes создаёт объект Endpoint и собирает все эндпоинты (пары IP-адрес:порт) с подов
Kubernetes создаёт объект Endpoint и собирает все эндпоинты (пары IP-адрес:порт) с подов
Kubernetes создаёт объект Endpoint и собирает все эндпоинты (пары IP-адрес:порт) с подов
Kubernetes создаёт объект Endpoint и собирает все эндпоинты (пары IP-адрес:порт) с подов
Демон kube-proxy подписан на изменения Endpoints
Демон kube-proxy подписан на изменения Endpoints
После добавления, изменения или удаления объекта Endpoint kube-proxy извлекает новый список эндпоинтов
После добавления, изменения или удаления объекта Endpoint kube-proxy извлекает новый список эндпоинтов
На основе этих эндпоинтов kube-proxy создаёт правила iptables на каждом узле кластера
На основе этих эндпоинтов kube-proxy создаёт правила iptables на каждом узле кластера

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

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-ingress
spec:
  rules:
  - http:
      paths:
      - backend:
          service:

           name: my-service
            port:
              number: 80

       path: /
        pathType: Prefix

Но на самом деле трафик не направляется в сервис. Вместо этого контроллер Ingress подписывается на уведомления об изменении эндпоинтов этого сервиса.

Ingress направляет трафик непосредственно к подам, минуя сервис. То есть всякий раз, когда происходит изменение объекта Endpoint, Ingress получает новый список IP-адресов и портов и переконфигурирует контроллер, подключая новые поды.

Резюме блока в картинках
На этой картинке показан контроллер Ingress с развёртыванием, состоящим из двух реплик и сервиса
На этой картинке показан контроллер Ingress с развёртыванием, состоящим из двух реплик и сервиса
Чтобы направить внешний трафик на поды через Ingress, необходимо создать манифест Ingress (файл YAML)
Чтобы направить внешний трафик на поды через Ingress, необходимо создать манифест Ingress (файл YAML)
Как только вы введёте kubectl apply -f ingress.yaml, Ingress-контроллер извлечёт файл из control plane
Как только вы введёте kubectl apply -f ingress.yaml, Ingress-контроллер извлечёт файл из control plane
Поле serviceName в YAML-файле Ingress’а описывает, какой сервис использовать
Поле serviceName в YAML-файле Ingress’а описывает, какой сервис использовать
Контроллер Ingress извлекает список эндпоинтов из сервиса, чтобы направлять трафик непосредственно к эндпоинтам (подам)
Контроллер Ingress извлекает список эндпоинтов из сервиса, чтобы направлять трафик непосредственно к эндпоинтам (подам)
Что происходит, когда создаётся новый под?
Что происходит, когда создаётся новый под?
Вы уже знаете, как Kubernetes создаёт под и распространяет информацию об эндпоинте
Вы уже знаете, как Kubernetes создаёт под и распространяет информацию об эндпоинте
Контроллер Ingress подписывается на изменения эндпоинтов. Поскольку они изменились, он извлекает новый список эндпоинтов
Контроллер Ingress подписывается на изменения эндпоинтов. Поскольку они изменились, он извлекает новый список эндпоинтов
Контроллер Ingress направляет трафик на новый под
Контроллер Ingress направляет трафик на новый под

Есть и другие компоненты Kubernetes, которые подписываются на изменения эндпоинтов. Например, CoreDNS, отвечающий за DNS в кластере. Если вы используете сервисы типа Headless, CoreDNS придётся подписываться на изменения эндпоинтов и перенастраиваться каждый раз, когда эндпоинт добавляется или удаляется. Те же самые эндпоинты используются сервисными сетями вроде Istio или Linkerd, облачными провайдерами для создания сервисов типа LoadBalancer и бесчисленными операторами.

Надо помнить, что на изменения эндпоинтов подписано несколько компонентов, и они могут получать уведомления об их обновлениях в разное время.

На этом всё, больше после создания пода ничего не происходит.

Вот краткое резюме того, что происходит при создании пода:

  1. Под хранится в etcd.

  2. Планировщик приписывает его к узлу. Узел записывается в etcd.

  3. kubelet получает уведомление о новом поде, запланированном на узел.

  4. kubelet делегирует создание контейнера интерфейсу Container Runtime Interface (CRI).

  5. kubelet делегирует присоединение контейнера к сетевому интерфейсу Container Network Interface (CNI).

  6. kubelet делегирует монтирование томов в контейнере интерфейсу Container Storage Interface (CSI).

  7. Container Network Interface присваивает поду IP-адрес.

  8. kubelet сообщает IP-адрес управляющему слою.

  9. IP-адрес записывается в etcd.

Если под принадлежит к какому-либо сервису:

  1. kubelet дожидается окончания успешной Readiness-пробы.

  2. Все связанные эндпоинты (объекты Endpoint) получают уведомление об изменении.

  3. Новый эндпоинт (пара IP-адрес + порт) добавляется в список эндпоинтов.

  4. Kube-proxy получает уведомление об изменении эндпоинтов и обновляет правила iptables на всех узлах.

  5. Контроллер Ingress получает уведомление об изменении эндпоинтов и направляет трафик на новые IP-адреса.

  6. CoreDNS получает уведомление об изменении эндпоинтов. Если у сервиса (Service) тип Headless, обновляется запись DNS.

  7. Облачный провайдер получает уведомление об изменении эндпоинтов. Если у сервиса тип LoadBalancer, новые эндпоинты добавляются в пул балансировщиков нагрузки.

  8. Об изменении эндпоинтов узнаёт service mesh (если он установлен в кластере).

  9. Любой другой оператор, подписанный на изменения эндпоинтов, также получает уведомление.

Согласитесь, длинный список для, казалось бы, обычной задачи — создания пода.

Итак, наш под запущен и работает. Пора поговорить о том, что произойдёт при его удалении.

Что происходит, когда вы удаляете под

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

Во-первых, нужно удалить эндпоинт из объекта Endpoint. Readiness-проба пока ещё показывает, что под в норме, но эндпоинт сразу же удаляется из управляющего слоя. Это, в свою очередь, запускает череду уведомлений kube-proxy, Ingress-контроллера, DNS, service mesh и т. д. Эти компоненты обновляют своё внутреннее состояние и прекращают направлять трафик на IP-адрес пода. Поскольку компоненты могут быть заняты чем-то другим, никто не знает, сколько времени потребуется для удаления IP-адреса из их внутреннего состояния. Для одних это занимает менее секунды, для других — больше.

Объяснение в картинках
При удалении пода с помощью kubectl delete pod команда сначала попадает в API Kubernetes
При удалении пода с помощью kubectl delete pod команда сначала попадает в API Kubernetes
Далее команда перехватывается определённым контроллером в control plane — контроллером конечных точек
Далее команда перехватывается определённым контроллером в control plane — контроллером конечных точек
Контроллер конечных точек даёт API команду на удаление IP-адреса и порта из объекта Endpoint
Контроллер конечных точек даёт API команду на удаление IP-адреса и порта из объекта Endpoint
Кто следит за изменениями эндпоинтов? Kube-proxy, контроллер Ingress, CoreDNS и т. д. получают уведомления об изменениях
Кто следит за изменениями эндпоинтов? Kube-proxy, контроллер Ingress, CoreDNS и т. д. получают уведомления об изменениях
Некоторым компонентам, таким как kube-proxy, может потребоваться дополнительное время для применения изменений
Некоторым компонентам, таким как kube-proxy, может потребоваться дополнительное время для применения изменений

В то же время статус пода в etcd меняется на Terminating. kubelet получает уведомление об изменении и «просит»:

  • Container Storage Interface — отмонтировать все тома от контейнера;

  • Container Network Interface — отключить контейнер от сети и освободить его IP-адрес;

  • Container Runtime Interface — удалить контейнер.

Объяснение в картинках
При удалении пода с помощью kubectl delete pod команда сначала обращается к API Kubernetes
При удалении пода с помощью kubectl delete pod команда сначала обращается к API Kubernetes
kubelet, опрашивая управляющий слой, узнаёт, что под был удалён
kubelet, опрашивая управляющий слой, узнаёт, что под был удалён
kubelet делегирует удаление пода интерфейсам Container Runtime Interface, Container Network Interface и Container Storage Interface
kubelet делегирует удаление пода интерфейсам Container Runtime Interface, Container Network Interface и Container Storage Interface

Как мы уже выяснили, при удалении пода Kubernetes выполняет те же шаги, что и при его создании, только в обратном порядке. Однако есть тонкое, но существенное отличие. При завершении работы пода удаление эндпоинта и уведомление kubelet'а происходят одновременно. И это может привести к целому ряду неприятных последствий. Что, если под перестанет существовать до того, как будет удалён его эндпоинт?

Объяснение в картинках
Эндпоинт и под удаляются одновременно
Эндпоинт и под удаляются одновременно
Поэтому может оказаться так, что эндпоинт будет удалён до того, как kube-proxy обновит правила iptables
Поэтому может оказаться так, что эндпоинт будет удалён до того, как kube-proxy обновит правила iptables
Или вам повезёт, и под будет удалён после того, как информация об удалении эндпоинта распространится по кластеру
Или вам повезёт, и под будет удалён после того, как информация об удалении эндпоинта распространится по кластеру

Как корректно завершать работу подов

Если под завершит свою работу до того, как его эндпоинт будет удалён из kube-proxy или Ingress-контроллера, может случиться простой. И, если вдуматься, в этом есть смысл. 

Kubernetes по-прежнему отправляет трафик на IP-адрес, но пода там больше нет. Контроллер Ingress, kube-proxy, CoreDNS и т. д. не успели удалить IP-адрес из своего внутреннего состояния. В идеале Kubernetes должен дождаться, пока все компоненты кластера получат обновлённый список эндпоинтов, прежде чем удалять под. Только K8s так не работает.

Он предлагает действенные примитивы для распределения эндпоинтов (то есть объект Endpoint и более сложные абстракции вроде EndpointSlices). Однако Kubernetes не следит за тем, чтобы компоненты, подписавшиеся на изменения эндпоинтов, были в курсе актуального состояния кластера. Что же можно сделать, чтобы избежать проблем с преждевременным удалением пода? 

Ответ очевиден — нужно подождать. Перед самым удалением под получает сигнал SIGTERM. Приложение может перехватить его и начать завершать работу. Маловероятно, что эндпоинт будет сразу удалён изо всех компонентов Kubernetes, поэтому можно:

  1. Чуть подождать перед выходом.

  2. Обрабатывать входящий трафик, несмотря на SIGTERM.

  3. Закрыть существующие долгоживущие соединения (возможно, соединение с базой данных или WebSocket'ы).

  4. Остановить процесс.

Давайте рассмотрим несколько примеров:

Пример на Go
package main

import (
  "fmt"
  "os"
  "os/signal"
  "syscall"
)

func main() {

  sigs := make(chan os.Signal, 1)
  done := make(chan bool, 1)
  //registers the channel
  signal.Notify(sigs, syscall.SIGTERM)

  go func() {
    sig := <-sigs
    fmt.Println("Caught SIGTERM, shutting down")
    // Finish any outstanding requests, then...
    done <- true
  }()

  fmt.Println("Starting application")
  // Main logic goes here
  <-done
  fmt.Println("exiting")
}

Пример на Python
import signal, time, os

def shutdown(signum, frame):
  print('Caught SIGTERM, shutting down')
  # Finish any outstanding requests, then...
  exit(0)

if __name__ == '__main__':
  # Register handler
  signal.signal(signal.SIGTERM, shutdown)
  # Main logic goes here

Пример на Node.js
const express = require('express');

const app = express();

app.listen(3000, () => console.log('Server is up using port 3000'));

process.on('SIGTERM', async () => {
  await wait(15 * 1000)
  app.close() // terminating the server
  db.close() // closing any other connection
  process.exit(0)
});

Пример на Java
public class App {

  public static void main(String[] args) {

    var shutdownListener = new Thread() {
      public void run() {
        // Main logic goes here
      }
    };
    Runtime.getRuntime().addShutdownHook(shutdownListener);
  }
}

Пример на С#
internal class LifetimeEventsHostedService: IHostedService {
  private readonly IHostApplicationLifetime _appLifetime;

  public LifetimeEventsHostedService(
    ILogger < LifetimeEventsHostedService > logger,
    IHostApplicationLifetime appLifetime,
    TelemetryClient telemtryClient) {
    _appLifetime = appLifetime;
  }

  public Task StartAsync(CancellationToken cancellationToken) {
    _appLifetime.ApplicationStarted.Register(OnStarted);
    _appLifetime.ApplicationStopping.Register(OnStopping);
    _appLifetime.ApplicationStopped.Register(OnStopped);

    return Task.CompletedTask;
  }

  public Task StopAsync(CancellationToken cancellationToken) {
    return Task.CompletedTask;
  }

  private void OnStarted() {}

  private void OnStopping() {
    // Main logic here
  }

  private void OnStopped() {}
}

Как долго нужно ждать?

По умолчанию Kubernetes отправляет сигнал SIGTERM и ждёт 30 секунд, прежде чем принудительно завершить процесс. Первые 15 секунд можно продолжать работать как ни в чём не бывало. Этого времени должно хватить, чтобы информация об удалении эндпоинта дошла до kube-proxy, Ingress-контроллера, CoreDNS и т. п. Следовательно, всё меньше и меньше трафика будет поступать в под.

Через 15 секунд трафик иссякнет, и можно будет безопасно закрыть соединение с базой данных (или любое другое постоянное соединение) и завершить процесс. Если какой-либо компонент кластера не успел обновить список эндпоинтов за 15 секунд, время ожидания следует увеличить (скажем, до 20 или 25 секунд). Только стоит помнить, что Kubernetes принудительно завершит процесс через 30 секунд (если вы не меняли параметр terminationGracePeriodSeconds в манифесте пода).

Что, если нельзя изменить код, чтобы он ждал SIGTERM? Можно вызвать скрипт, который будет ждать определённое время, а затем позволит приложению завершить работу.

Корректное завершение работы с preStop-хуком

Перед тем как послать SIGTERM, Kubernetes запускает хук preStop. Его можно настроить на задержку в 15 секунд.

Давайте рассмотрим пример:

apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  containers:
    - name: web
      image: nginx
      ports:
        - name: web
          containerPort: 80

     lifecycle:
        preStop:
          exec:
            command: ["sleep", "15"]

Хук preStop — один из хуков жизненного цикла пода. Важно отметить, что preStop-хук и ожидание средствами самого приложения — два разных подхода. Хук preStop запускается перед отправкой сигнала SIGTERM в приложение. То есть во время работы хука приложение не имеет понятия, что оно вот-вот завершит работу.

Но это ещё не всё. Задержка в preStop-хуке входит в 30 секунд ожидания, прописанные в terminationGracePeriodSeconds. Предположим, что хук ждёт 25 секунд. Тогда сразу после его завершения приложение получит сигнал SIGTERM, и у него останется всего 5 секунд на завершение своей работы. Когда они истекут, kubelet пошлёт SIGKILL.

Что произойдёт, если хук preStop будет ждать дольше 30 секунд? kubelet пошлёт сигнал SIGKILL и принудительно завершит работу контейнера — сигнала SIGTERM не будет. Чтобы увеличить время ожидания, поменяйте значение terminationGracePeriodSeconds.

Резюме блока в картинках
Вы уже знаете, что при удалении пода kubelet получает соответствующее уведомление
Вы уже знаете, что при удалении пода kubelet получает соответствующее уведомление
Если для пода настроен хук preStop, он вызывается первым
Если для пода настроен хук preStop, он вызывается первым
После завершения работы хука preStop kubelet посылает контейнеру сигнал SIGTERM. С этого момента контейнер должен закрыть все долгоживущие соединения и подготовиться к отключению
После завершения работы хука preStop kubelet посылает контейнеру сигнал SIGTERM. С этого момента контейнер должен закрыть все долгоживущие соединения и подготовиться к отключению
По умолчанию процессу даётся 30 секунд на завершение работы, включая период, в течение которого выполняется хук preStop. Если за это время процесс не успеет завершить работу, kubelet убивает его с помощью сигнала SIGKILL
По умолчанию процессу даётся 30 секунд на завершение работы, включая период, в течение которого выполняется хук preStop. Если за это время процесс не успеет завершить работу, kubelet убивает его с помощью сигнала SIGKILL
kubelet уведомляет управляющий слой об успешном удалении пода
kubelet уведомляет управляющий слой об успешном удалении пода

Так какую же задержку поставить? 15, 60, 120 секунд? Однозначного ответа тут нет.

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

Давайте рассмотрим пример, иллюстрирующий этот сценарий.

Длительное завершение работы и автомасштабирование кластера

Kubernetes постоянно удаляет и запускает различные поды. Например, он делает это, когда выкатывается новая версия приложения или когда вы меняете образ в Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  replicas: 3
  selector:
    matchLabels:
      name: app
  template:
    metadata:
      labels:
        name: app
    spec:
      containers:
      - name: app

       # image: nginx:1.18 OLD
        image: nginx:1.19

       ports:
          - containerPort: 3000
      lifecycle:
        preStop:
          exec:
            command: ["sleep", "15"]

Предположим, у вас три реплики приложения. После того как вы примените изменённый YAML-манифест Deployment, Kubernetes:

  1. Создаст под с новым образом контейнера.

  2. «Убьёт» старый под.

  3. Дождётся готовности нового пода (то есть успешной Readiness-пробы).

Эти шаги он будет повторять до тех пор, пока все поды не обновятся. При этом он каждый раз будет дожидаться готовности нового пода.

Будет ли K8s ждать удаления старого пода, прежде чем перейти к следующему? Нет. Предположим, что у вас 10 подов и каждому достаточно 2 секунд, чтобы стать Ready, а завершение работы занимает 20 секунд. В этом случае произойдёт следующее:

  1. K8s создаст первый новый под и завершит работу старого.

  2. Под станет Ready через 2 секунды, после чего Kubernetes запустит ещё один под.

  3. В то же время старый под будет продолжать завершать свою работу.

Через 20 секунд все новые поды будут готовы, а все старые окажутся в состоянии Terminating (у первого как раз закончится период ожидания). Другими словами, в кластере на короткое время будет вдвое больше подов, чем необходимо.

Чем длительнее период завершения работы и короче время старта, тем больше будет подов, работающих параллельно. Одни из них — в статусе Ready, другие — в Terminating. Страшно ли это? Нет, если не обрывать соединения.

Но что, если повторить эксперимент с более долгим периодом ожидания (120 секунд) и бóльшим числом реплик (40 штук)?

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  replicas: 40
  selector:
    matchLabels:
      name: app
  template:
    metadata:
      labels:
        name: app
    spec:
      containers:
      - name: app
        # image: nginx:1.18 OLD
        image: nginx:1.19
        ports:
          - containerPort: 3000

     lifecycle:
        preStop:
          exec:
            command: ["sleep", "120"]
            terminationGracePeriodSeconds: 180

В этом случае Kubernetes завершит развёртывание, но на протяжении примерно 120 секунд у вас будет 80 работающих реплик: 40 новых и 40 старых. Такое удвоение реплик может привести к срабатыванию автоскейлера, который создаст новые узлы в кластере. Затем, когда 40 реплик завершат свою работу, узлы придётся удалять. Если период ожидания для корректного завершения работы короче (меньше 30 секунд), старые поды будут удаляться параллельно с созданием новых. То есть число одновременно работающих подов будет меньше.

Но есть и другая причина стремиться к более короткому времени ожидания, и она касается эндпоинтов. Если приложение экспортирует метрики через /metrics, то данные вряд ли будут собираться во время корректного завершения работы. Почему?

Инструменты вроде Prometheus полагаются на объекты Endpoint при сборе метрик с подов в кластере. Однако при удалении пода сразу удаляется и его эндпоинт. Хотя это и занимает некоторое время, в конечном итоге информация о том, что эндпоинт удалён, доходит и до Prometheus! Другими словами, период ожидания следует рассматривать как возможность корректно завершить работу пода как можно скорее, а не пытаться продлить срок его существования для завершения текущей задачи.

Корректное завершение работы для долгоживущих соединений и задач

Если приложение работает с долгоживущими соединениями вроде WebSocket, закрыть их в течение 30 секунд — не вариант. Такое соединение, скорее всего, имеет смысл держать открытым как можно дольше — в идеале до тех пор, пока клиент не отключится. Аналогично, если вы, например, перекодируете большое видео, вас вряд ли устроит, если скользящее обновление удалит под и кучу часов работы вместе с ним.

Но как избежать принудительного «убийства» пода? Можно увеличить terminationGracePeriodSeconds до трёх часов в надежде, что к тому моменту работа будет выполнена, а соединение разорвано. Однако тут есть свои тонкости:

  • Вы не сможете собирать метрики с помощью Prometheus (так как эндпоинт будет удалён).

  • Отладка станет ещё сложнее, поскольку Running- и Terminating-поды могут быть разных версий.

  • kubelet не будет проверять Liveness-пробу (если процесс зависнет, никто об этом не узнает).

Вместо увеличения периода ожидания стоит подумать о создании нового Deployment для каждого релиза. При его создании существующий Deployment остаётся нетронутым. Долговременные задачи продолжат обрабатывать видео в обычном режиме, а долгоживущие соединения останутся активными. Потом старый Deployment можно будет удалить вручную.

Чтобы поды удалялись автоматически, можно настроить автоскейлер на масштабирование Deployment до нуля реплик после завершения задач. Пример такого автоскейлера — KEDA, событийно-ориентированный автомасштабировщик Kubernetes. Этот метод иногда называют rainbow-развёртыванием, и он полезен в тех случаях, когда простое увеличение terminationGracePeriodSeconds не даст желаемого результата. 

Создание нового Deployment для каждого релиза — менее очевидный, но более подходящий выбор.

Резюме

  • При удалении подов из кластера не забывайте о том, что на их IP-адреса может по-прежнему поступать трафик.

  • Вместо того чтобы завершать работу пода мгновенно, стоит задуматься о том, как сделать процесс более плавным, реализовав соответствующую функциональность в приложении или воспользовавшись preStop-хуком.

  • Под следует удалять только после того, как обновлённая информация об эндпоинтах распространится по всему кластеру и старый эндпоинт удалится из kube-proxy, Ingress-контроллеров, CoreDNS и т. д.

  • Если поды выполняют затяжные задачи (например, перекодируют видео или обновляют какие-либо данные в реальном времени через WebSockets), подумайте об использовании rainbow-развёртываний. Суть этого метода в том, что под каждый релиз создаётся новый Deployment, а старый удаляется, когда все задачи завершены, а подключения закрыты (сделать это можно вручную).

  • Также можно масштабировать старый Deployment до нуля реплик, тем самым автоматизировать процесс.

P. S. 

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

Комментарии (5)


  1. slava_k
    25.10.2024 10:24

    Спасибо за статью!


  1. jenyabykov
    25.10.2024 10:24

    Начиная с Kubernetes 1.30 больше не нужно вызывать команду `sleep N` в контейнере и хранить бинариник sleep для preStop hook: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/

    lifecycle:
      preStop:
        sleep:
          seconds: 15

    Кстати, использовать лучше не command: ["sleep", "15"] , а что-то вида `/bin/sh -c /bin/sleep 15` т.к. не во всех образах корректно задана переменная $PATH

    Дополнительно: Если используете HPA, уберите секцию replicas из ваших Deployment: https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/#migrating-deployments-and-statefulsets-to-horizontal-autoscaling (как альтернативное решение: выставьте replicas в максимальный лимит реплик HPA)


    1. kubelet Автор
      25.10.2024 10:24

      Спасибо за дополнение!


  1. olku
    25.10.2024 10:24

    K8s создаст первый новый под и завершит работу старого.

    Уточнение - пошлет сигнал SIGTERM.

    Про вебсокеты интересно. В общем случае мы не знаем сколько времени клиент их может использовать. Если это потоковый обмен с другой системой, то ждать окончания бессмысленно. Самое простое, не давать клиенту гарантий, пусть переподключается сам. Но если дать гарантию хочется, мы же облако, как реализуется неразрываемый вебсокет?


  1. gozoro
    25.10.2024 10:24

    Пару недель назад тоже наткнулся на эту статью на Learnk8s. Очень удивился, что в таком комбайне нужно делать sleep 15 с надеждой, что через это время запросы перестанут идти на завершающийся Под.

    Я правильно понимаю, что другого решения не было. Какое-нибудь отслеживание iptables перед отправкой SIGTERM приводило бы к более глубокому пересечению завершающихся и стартующих процессов и это бы съедало бы больше ресурсов. И разработчики k8s решили отдать это решение пользователям.