Graceful shutdown in Kubernetes
TL;DR: В этой статье вы узнаете, как предотвратить разрыв соединений при запуске или завершении работы пода. Вы также узнаете, как изящно завершать долго выполняющиеся задачи и соединения.
В Kubernetes создание и удаление подов - одна из самых распространенных задач.
Поды создаются при выполнении обновления (rolling update), масштабировании развертываний, для каждого нового релиза, для каждого задания (job), задания выполняющегося по расписанию (cron job) и т. д.
Однако поды также удаляются и создаются заново после выселения (eviction) - например, когда вы помечаете узел как не подлежащий планированию.
Если природа этих подов настолько эфемерна, что произойдет, если во время ответа на запрос, но ему говорят завершится?
Будет ли запрос завершен до выключения?
А как насчет последующих запросов? Перенаправляются ли они куда-то еще?
Оглавление
Что происходит при создании пода в Kubernetes
Kubelet создает поды и следит за их состоянием
Что происходит при удалении пода
Как плавно завершить работу подов
Плавное завершение с preStop хуком
Длительное время плавного отключения и автомасштабирование кластера
Плавное завершение работы для долгоживущих соединений и длительно выполняющихся задач
Что происходит при создании пода в Kubernetes
Прежде чем говорить о том, что происходит, когда Pod удаляется, необходимо обсудить, что происходит, когда Pod создается.
Предположим, что вы хотите создать следующий Pod в своем кластере:
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
Когда вы введете команду, kubectl
отправит определение пода в Kubernetes API.
С этого момента начинается путешествие.
API получает и проверяет определение Pod'а, который впоследствии сохраняется в базе данных - etcd.
Pod также добавляется в очередь планировщика.
- Проверяет определение.
- Собирает подробные сведения о рабочей нагрузке, такие как запросы процессора и памяти (requests), а затем
- Решает, какой узел лучше всего подходит для ее выполнения (с помощью процесса, называемого фильтрами и предикатами).
- Pod помечается как запланированный (Scheduled) в etcd.
- Pod'у назначается узел.
- Состояние Pod'а сохраняется в etcd.
Но Pod по-прежнему не существует.
Предыдущие задачи выполнялись в плоскости управления (control plane), а состояние хранится в базе данных.
Так кто же создает Pod на ваших узлах?
Kubelet создает поды и следит за их состоянием
Задача kubelet'а - опрашивать плоскость управления на предмет обновлений.
Вы можете представить себе, как kubelet неустанно спрашивает у плоскости управления: "Я присматриваю за рабочим узлом 1; есть ли для меня новый под?".
Если Pod есть, kubelet создает его.
Kubelet не создает Pod самостоятельно. Вместо этого он делегирует эту работу трем другим компонентам:
- Container Runtime Interface (CRI) создает контейнеры для Pod.
- Сетевой интерфейс контейнеров (CNI) подключает контейнеры к сети кластера и назначает IP-адреса.
- Интерфейс контейнерного хранилища (CSI) монтирует тома в контейнерах.
В большинстве случаев интерфейс выполнения контейнеров (CRI) выполняет аналогичную работу:
docker run -d <my-container-image>
Сетевой интерфейс контейнеров (Container Networking Interface, CNI) немного интереснее, поскольку он отвечает за:
Как вы можете себе представить, существует несколько способов подключить контейнер к сети и назначить ему действительный IP-адрес (вы можете выбрать IPv4 или IPv6 или несколько IP-адресов).
Если вы хотите узнать больше о сетевых пространствах имен Linux и CNI, вам стоит ознакомиться с этой статьей
Когда сетевой интерфейс контейнера завершает свою работу, Pod подключается к остальной сети и ему назначается действительный IP-адрес.
Kubelet знает IP-адрес (потому что он вызвал сетевой интерфейс контейнера), но плоскость управления не знает.
Никто не сообщил плоскости управления, что поду назначен IP-адрес и он готов принимать трафик.
С точки зрения плоскости управления, Pod все еще создается.
Задача kubelet'а - собрать все данные о Pod, например IP-адрес, и сообщить их обратно в плоскость управления.
Проверка etcd покажет, где запущен Pod и его IP-адрес.
Если Pod не связан ни с одним из сервисов (Service), на этом путешествие заканчивается.
Pod создан и готов к использованию.
Если Pod является частью сервиса, необходимо выполнить еще несколько шагов.
Pods, Services и Endpoints
Когда вы создаете сервис (Service), обычно есть два момента, на которые следует обратить внимание:
- Селектор (
selector
), который используется для указания подов, которые будут получать трафик. targetPort
- порт, который поды используют для получения трафика.
Типичное определение YAML для сервиса выглядит следующим образом:
apiVersion: v1 kind: Service metadata: name: my-service spec: ports: - port: 80 targetPort: 3000 selector: name: app
Когда вы применяете манифест с помощью kubectl apply
, Kubernetes находит все поды, имеющие ту же метку (label), что и селектор (name: app
), и собирает их IP-адреса - но только если они прошли проверку на готовность (Readiness probe).
Затем для каждого IP-адреса он соединяет IP-адрес и порт.
Если IP-адрес равен 10.0.0.3
, а порт targetPort
- 3000
, Kubernetes объединяет эти два значения и называет их конечной точкой (endpoint).
IP address + port = endpoint --------------------------------- 10.0.0.3 + 3000 = 10.0.0.3:3000
Конечные точки хранятся в etcd в другом объекте под названием Endpoint.
- конечная точка (endpoint) - это пара IP-адрес + порт (10.0.0.3:3000).
- Endpoint (объект) - это набор конечных точек.
Объект Endpoint - это реальный объект в Kubernetes, и для каждого сервиса 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 endpoints/my-service-2 192.168.99.100:8443
Endpoint собирает все IP-адреса и порты из подов.
Объект Endpoint обновляется новым списком конечных точек, когда:
Таким образом, вы можете представить, что каждый раз, когда вы создаете Pod и после того, как kubelet публикует IP-адрес в плоскости управления, 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,172.17.0.8:80 endpoints/my-service-2 192.168.99.100:8443
Конечная точка хранится в плоскости управления, и объект Endpoint был обновлен.
Готовы ли вы начать использовать свой Pod?
Endpoints- валюта Kubernetes
Конечные точки используются несколькими компонентами Kubernetes.
Kube-proxy использует конечные точки для настройки правил iptables на узлах.
Таким образом, каждый раз, когда Endpoint (объект) меняется, kube-proxy получает новый список IP-адресов и портов и пишет новые правила iptables.
Контроллер Ingress (Ingress controller) использует тот же список конечных точек.
Контроллер 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-адресов и портов и перенастраивает контроллер, чтобы включить новые поды.
Есть и другие примеры компонентов Kubernetes, которые подписываются на изменения конечных точек.
Еще один пример - CoreDNS, компонент DNS в кластере.
Если вы используете сервисы типа Headless, CoreDNS придется подписываться на изменения конечных точек и перенастраивать себя каждый раз, когда конечная точка добавляется или удаляется.
Те же конечные точки используются Service Mesh, такими как Istio или Linkerd, облачными провайдерами для создания сервисов type:LoadBalancer
и бесчисленными операторами.
Вы должны помнить, что несколько компонентов подписываются на изменения конечных точек и могут получать уведомления об их обновлении в разное время.
Достаточно ли этого, или что-то еще происходит после создания Pod?
Вот краткий обзор того, что происходит при создании пода:
- Pod сохраняется в etcd.
- Планировщик назначает узел. Он записывает узел в etcd.
- Kubelet получает уведомление о новом и запланированном Pod'е.
- Kubelet делегирует создание контейнера интерфейсу выполнения контейнера (CRI).
- Kubelet делегирует присоединение контейнера сетевому интерфейсу контейнеров (CNI).
- Kubelet делегирует монтирование томов в контейнере интерфейсу Container Storage Interface (CSI).
- Сетевой интерфейс контейнера назначает IP-адрес.
- Kubelet сообщает IP-адрес плоскости управления.
- IP-адрес сохраняется в etcd.
А если ваш Pod принадлежит сервису:
- Kubelet ожидает прохождения проверки готовности (Readiness probe).
- Все соответствующие Endpoints (объекты) получают уведомление об изменении.
- Endpoints добавляют новую конечную точку (пару IP-адрес + порт) в свой список.
- Kube-proxy получает уведомление об изменении Endpoint. Kube-proxy обновляет правила iptables на каждом узле.
- Контроллер Ingress получает уведомление об изменении Endpoint. Контроллер направляет трафик на новые IP-адреса.
- CoreDNS получает уведомление об изменении Endpoint. Если сервис имеет тип Headless, запись DNS обновляется.
- Облачный провайдер уведомляется об изменении Endpoint. Если служба имеет
type: LoadBalancer
, новые конечные точки настраиваются как часть пула балансировщика нагрузки. - Любая service mesh, установленная в кластере, получает уведомление об изменении Endpoint.
- Любой другой оператор, подписанный на изменения Endpoint, также получает уведомление.
Такой длинный список для, на удивление, обычной задачи - создания пода.
Пришло время обсудить, что произойдет при его удалении.
Что происходит при удалении пода
Возможно, вы уже догадались, но при удалении пода необходимо выполнить те же действия, но в обратном порядке.
Сначала нужно удалить конечную точку из Endpoint (объекта).
На этот раз проба готовности (Readiness probe) остается активной, но конечная точка сразу же удаляется из плоскости управления.
Это, в свою очередь, запускает все события для kube-proxy, Ingress controller, DNS, service mesh и т. д.
Эти компоненты обновят свое внутреннее состояние и прекратят маршрутизацию трафика на данный IP-адрес.
Поскольку компоненты могут быть заняты чем-то другим, нет никакой гарантии, сколько времени потребуется для удаления IP-адреса из их внутреннего состояния.
Для одних это может занять меньше секунды, для других - больше.
В то же время статус пода в etcd меняется на Terminating.
Kubelet получает уведомление об изменении и делегирует:
- Размонтирование всех томов контейнера на интерфейс контейнерного хранилища (CSI).
- Отсоединение контейнера от сети и освобождение IP-адреса на Container Network Interface (CNI).
- Уничтожение контейнера на интерфейс выполнения контейнера (CRI).
Другими словами, Kubernetes выполняет точно такие же шаги для создания Pod, но в обратном порядке.
Однако есть тонкое, но существенное различие.
Когда вы завершаете Pod, удаление конечной точки и сигнал kubelet'у выдаются одновременно.
Когда вы создаете Pod в первый раз, Kubernetes ждет, пока kubelet сообщит IP-адрес, а затем запускает распространение конечной точки.
Однако при удалении пода события начинаются параллельно.
И это может привести к возникновению множества условий гонки.
Что, если Pod будет удален до того, как будет распространена конечная точка?
Как плавно завершить работу подов
Когда под завершается до того, как конечная точка удаляется из kube-proxy или контроллера Ingress, вы можете столкнуться с простоем.
И, если подумать, становится понятно.
Kubernetes все еще маршрутизирует трафик на IP-адрес, но Pod больше не существует.
Контроллер Ingress, kube-proxy, CoreDNS и т. д. не успели удалить IP-адрес из своего внутреннего состояния.
В идеале Kubernetes должен дождаться, пока все компоненты кластера получат обновленный список конечных точек, прежде чем удалять под.
Но Kubernetes так не работает.
Kubernetes предлагает надежные примитивы для распределения конечных точек (т. е. объект Endpoint и более сложные абстракции, такие как Endpoint Slices).
Однако Kubernetes не проверяет, что компоненты, которые подписываются на изменения конечных точек, находятся в курсе состояния кластера.
Что же можно сделать, чтобы избежать этих условий гонки и убедиться, что Pod будет удален после распространения конечной точки?
Когда Pod собирается быть удаленным, он получает сигнал SIGTERM
.
Ваше приложение может перехватить этот сигнал и начать завершение работы.
Поскольку маловероятно, что конечная точка будет немедленно удалена из всех компонентов Kubernetes, вы можете:
- Подождите еще немного перед выходом.
- Продолжайте обрабатывать входящий трафик, несмотря на SIGTERM.
- Наконец, закройте существующие долгоживущие соединения (возможно, соединение с базой данных или WebSockets).
- Завершите процесс.
Давайте посмотрим на несколько примеров:
По умолчанию Kubernetes отправляет сигнал SIGTERM и ждет 30 секунд перед принудительным завершением процесса.
Вы можете использовать первые 15 секунд для продолжения работы, как будто ничего не произошло.
Этого интервала должно быть достаточно для распространения информации об удалении конечной точки в kube-proxy, Ingress controller, CoreDNS и т. д.
Следовательно, все меньше и меньше трафика будет достигать вашего Pod, пока он не прекратится.
Через 15 секунд трафика не будет, и можно будет закрыть соединение с базой данных (или любые постоянные соединения) и завершить процесс.
Если какой-либо компонент в вашем кластере не избавился от конечных точек в течение 15 секунд, вам следует увеличить задержку (возможно, до 20 или 25 секунд).
Однако следует помнить, что Kubernetes принудительно завершит процесс через 30 секунд (если вы не измените параметр terminationGracePeriodSeconds
в манифесте Pod).
Что делать, если вы не можете изменить код, чтобы слушать SIGTERM и ждать дольше?
Можно вызвать скрипт, который будет ждать определенное время, а затем позволит приложению выйти.
Плавное завершение с preStop хуком
Перед вызовом SIGTERM
Kubernetes вызывает хук preStop
в Pod.
Вы можете установить 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
является одним из хуков Pod LifeCycle.
Важно заметить, что использование хука preStop и ожидание 15 секунд в приложении - это разные вещи.
Хук preStop вызывается перед отправкой SIGTERM
в приложение.
Таким образом, пока выполняется preStop, приложение не знает, что оно собирается завершиться.
Задержка в хуке preStop является частью 30-секундного бюджета terminationGracePeriodSeconds
.
Если preStop хук, ожидает 25 секунд, то как только завершит работу:
- Контейнер получает
SIGTERM
. - У него есть 5 секунд, чтобы завершить работу, или kubelet выдаст
SIGKILL
.
Что произойдет, если хук preStop будет ждать дольше, чем terminationGracePeriodSeconds
30 секунд?
Kubelet отправит сигнал SIGKILL
и принудительно завершит работу контейнера - сигнал SIGTERM
не будет отправлен.
Если вам нужна более длительная задержка, подумайте о том, чтобы скорректировать terminationGracePeriodSeconds
с учетом этого.
Итак, что вы посоветуете по поводу задержки preHook или приложения? 15, 60, 120 секунд?
Если вы обрабатываете плавное завершение работы в точечных экземплярах, ваш бюджет, скорее всего, будет ограничен 60 секундами или меньше.
Если вашему приложению необходимо сбросить журналы или метрики (flush logs or metrics) перед выключением, вам может понадобиться больший интервал.
В общем случае время ожидания не должно превышать 30 секунд, поскольку более длительные интервалы влияют на использование ресурсов в кластере.
Давайте рассмотрим пример, чтобы проиллюстрировать этот сценарий.
Длительное время плавного отключения и автомасштабирование кластера
Плавное отключение применяется к удаляемым подам.
Но что делать, если вы не удаляете поды?
Даже если вы этого не делаете, Kubernetes постоянно удаляет поды.
В частности, Kubernetes создает и удаляет поды каждый раз, когда вы развертываете новую версию приложения.
Когда вы изменяете образ в развертывании (Deployment), Kubernetes выкатывает изменения инкрементально.
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, Kubernetes:
- Создает Pod с новым образом контейнера.
- Уничтожает существующий Pod.
- Ждет, пока Pod будет готов (т. е. пройдет проверку готовности).
Повторяет описанные выше действия до тех пор, пока все поды не будут переведены на новую версию.
Kubernetes повторяет каждый цикл только после того, как новый Pod будет готов к приему трафика (другими словами, пройдет проверку готовности).
Ждет ли Kubernetes удаления пода перед переходом к следующему?
Если у вас 10 подов, и поду требуется 2 секунды на подготовку и 20 на выключение, вот что произойдет:
- Создается первый Pod, а предыдущий Pod завершается.
- Новому поду требуется 2 секунды, чтобы быть готовым. После этого Kubernetes создает новый.
- Тем временем завершаемый Pod остается завершенным в течение 20 секунд.
Через 20 секунд все новые поды работают (10 подов, готовых (Ready) через 2 секунды), а все десять предыдущих подов завершают работу (первый под вот-вот завершится).
На короткое время у вас вдвое больше подов (10 работающих (Running), 10 завершающихся (Terminating)).
Чем дольше мягкий интервал (graceful period) по сравнению с временем проверки готовности, тем больше подов будет одновременно запущено Running (и завершающихся 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 завершит развертывание, но у вас будет 80 реплик, работающих в течение 120 секунд: 40 работающих (Running) и 40 завершающихся (Terminating).
Поскольку это нетривиальное количество реплик, может сработать автоскалер кластера и создать новые узлы.
Эти же узлы должны быть удалены, а кластер уменьшен, когда будут удалены 40 завершающихся подов.
Если ваше плавное выключение происходит быстрее (т. е. менее 30 секунд), завершающиеся поды удаляются, пока создаются новые поды.
Другими словами, одновременно работает меньшее количество подов.
Но есть и другая причина, по которой следует стремиться к более коротким, плавным выключениям, и она касается конечных точек.
Если ваше приложение отображает конечную точку /metrics для сбора метрик, данные вряд ли будут собраны во время плавного отключения.
Такие инструменты, как Prometheus, полагаются на конечные точки, чтобы собирать метрики с подов в вашем кластере.
Однако, как только вы удаляете Pod, удаление конечной точки распространяется по всему кластеру - даже в Prometheus!
Другими словами, вы должны относиться к своему плавному отключению как к возможности завершить работу пода как можно скорее, а не пытаться продлить срок его жизни для выполнения текущей задачи.
Плавное завершение работы для долгоживущих соединений и длительно выполняющихся задач
Если ваше приложение обслуживает долгоживущие соединения, например WebSockets, вы не захотите завершать его в течение 30 секунд.
Вместо этого вы, возможно, захотите поддерживать соединение как можно дольше - в идеале, до тех пор, пока клиент не отключится.
Аналогично, если вы перекодируете большое видео, вы не хотите, чтобы обновление удаляло текущий под (и часы работы).
Как избежать задержки отключения пода?
Вы можете увеличить значение параметра terminationGracePeriodSeconds
до 3 часов в надежде, что к этому времени соединение будет разорвано или работа будет завершена.
Однако это имеет ряд недостатков:
- Вы не сможете собирать метрики, если полагаетесь на Prometheus (т. е. конечная точка удалена).
- Отладка становится более сложной, поскольку запущенный и завершающий поды могут быть разных версий.
- Kubelet не проверяет зонд liveness (например, если ваш процесс застрял, он не перезапускает его).
Вместо увеличения льготного периода следует подумать о создании нового развертывания для каждого релиза.
При создании нового Deployment существующий Deployment остается нетронутым.
Запущенные задания могут продолжать обрабатывать видео, как обычно, а ваши долговременные соединения останутся нетронутыми.
Как только они закончатся, вы можете удалить их вручную.
Если вы хотите удалять их автоматически, подумайте о настройке автоскалера, который будет масштабировать ваше развертывание до нулевых реплик, когда у них закончатся задания.
Примером такого автоскалера Pod является KEDA - событийно-ориентированный автоскалер Kubernetes.
Эту технику иногда называют Rainbow Deployments, и она полезна в тех случаях, когда вам нужно поддерживать предыдущие поды в рабочем состоянии дольше льготного периода.
Создание нового развертывания для каждого релиза - менее очевидный, но лучший выбор.
Резюме
Следует обращать внимание на удаление подов из кластера, поскольку их IP-адреса могут по-прежнему использоваться для маршрутизации трафика.
Вместо немедленного завершения работы пода следует немного подождать в приложении или настроить preStop
хук.
Под должен быть удален только после того, как все конечные точки в кластере распространятся и будут удалены из kube-proxy, Ingress контроллеров, CoreDNS и т. д.
Вам следует рассмотреть возможность использования радужных развертываний, если ваши Pod выполняют долгоживущие задачи, такие как транскодирование видео или обслуживание обновлений в реальном времени с помощью WebSockets.
В радужных развертываниях вы создаете новый Deployment для каждого релиза и удаляете предыдущий, когда соединение (или задачи) исчерпаны.
Вы можете вручную удалить старые развертывания после завершения длительной задачи.
Или можно автоматически масштабировать развертывание до нулевых реплик, чтобы автоматизировать этот процесс.