Все, кто работает с Kubernetes, хотят, чтобы жизненный цикл подов был предсказуемым. Пугает сама мысль, что какая-то неведомая сила может «прибить» ваши поды, особенно если используются stateful-нагрузки или критически важна доступность сервисов в целом.

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

В этой статье мы углубимся во внутреннее устройство Kubernetes и рассмотрим все пути, которые могут привести к завершению работы подов. А ещё разберёмся, почему фраза «Рестарты kubelet'а не затрагивают запущенные нагрузки» не всегда соответствует действительности. Кроме того, в конце вас ждёт удобная шпаргалка.

Eviction API

Инициированное API вытеснение — это способ удаления подов с соблюдением заданного PodDisruptionBudget. По сути, это и есть его ключевое отличие от прямого удаления пода. Логика Eviction API заключается в атомарном декременте счётчика status.disruptionsAllowed в статусе PDB. Тем самым гарантируется, что одновременные запросы на вытеснение не снизят доступность ниже разрешённого уровня.

Пожалуй, самое любопытное в Eviction API и PDB — это то, что они практически не используются базовыми компонентами Kubernetes, поэтому в оставшейся части статьи мы их упоминать не будем. Контроллёры рабочих нагрузок, такие как ReplicaSet, Deployment и StatefulSet, удаляют поды напрямую, полностью игнорируя PDB в процессе выката. Именно поэтому в API Deployment и StatefulSet есть отдельный параметр maxUnavailable, который не зависит от одноимённой настройки в PDB. Как владелец приложения, вы должны следить, чтобы это значение не опускалось ниже разрешённого в PDB, иначе во время выкатов доступность упадёт ниже установленного порога.

Больше всего в Eviction API мне не нравится то, что для строгого контроля над жизненным циклом подов с помощью admission-вебхуков приходится обрабатывать два разных события (удаление пода и вытеснение пода) через два отдельных вебхука.

Вытеснение подов из-за нехватки ресурсов на узле

Kubelet отвечает за работоспособность узла и вытесняет с него поды, если у того заканчивается память, место на диске или количество inode'ов. По большей части это полезная фича — исчерпание ресурсов в конечном итоге ударило бы по одному из подов. Проблема в том, что заранее неизвестно, по какому именно. Поэтому разумнее действовать превентивно и в первую очередь вытеснять поды с низким приоритетом.

Фича хорошо задокументирована. Но если вам важен строгий контроль над жизненным циклом подов или у вас работают собственные механизмы восстановления узлов, то ещё один игрок (kubelet), скорее всего, будет лишним на этом поле. Он только ус��ожнит анализ жизненного цикла подов.

Когда kubelet вытесняет поды этим способом, их PDB не соблюдаются, так как не используется Eviction API. Кроме того, жёсткие пороги вытеснения заставляют kubelet немедленно терминировать поды. Мягкие пороги, в свою очередь, игнорируют период корректного завершения работы (graceful termination period) пода, ограничивая его собственным, заранее настроенным значением.

К счастью, жёсткие и мягкие вытеснения можно отключить в конфигурации kubelet'а.

Вытеснение из-за taint'ов

Когда на узел с подом навешивается taint NoExecute, kubelet'у не остается ничего иного, как вытеснить под с этого узла. Давайте разберёмся, как это работает.

Каждые 10 секунд kubelet сообщает API-серверу Kubernetes, что он в порядке, обновляя ресурс Lease. За этим ресурсом следит встроенный контроллер node-lifecycle, который является частью процесса kube-controller-manager. Если в течение 50 секунд от kubelet'а не поступает «пульса» (это время можно изменить), контроллер меняет статус узла с Ready на Unknown.

Когда узел переходит в состояние Unknown, контроллер node-lifecycle помечает его taint’ом Unreachable («недоступен») с эффектом NoExecute. По умолчанию Kubernetes разрешает каждому поду (даёт toleration) «терпеть» этот taint в течение 5 минут. (Это стандартное поведение можно изменить, откл��чив соответствующий admission controller в apiserver или прописав свой toleration в PodSpec.)

Сам узел может даже «не знать», что контроллер node-lifecycle пометил его taint'ом. Обычно этот taint выставляют как раз потому, что kubelet перестал отвечать или узел потерял сетевую связность. Поэтому вытеснение подов в этом сценарии запускает не kubelet на узле, а внешний компонент control plane: taint eviction controller. Он отслеживает taint’ы с эффектом NoExecute и, когда истекает время toleration (или toleration отсутствует), удаляет поды с помеченного узла. 

Ранее этот контроллер был частью контроллера node-lifecycle и не мог быть отключён. Благодаря коллегам из Apple, начиная с Kubernetes 1.29, он вынесен в отдельный контроллер и может быть полностью деактивирован. Это очень полезно, если требуется более тонкий контроль над вытеснениями, вызванными taint'ами с эффектом NoExecute.

Такой способ вытеснения работает в обход Eviction API и удаляет поды напрямую. При этом kubelet, останавливая под, всё же соблюдает период корректного завершения работы (graceful termination). Также это будет отражено в событиях пода под названием TaintManagerEviction. Если же kubelet недоступен, под зависнет в статусе Terminating в API (или его принудительно удалят, но тогда есть риск, что он так и останется работать на отвалившемся узле).

Проверка допуска на стороне kubelet'а

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

Чтобы в этом разобраться, нужно вернуться немного назад и начать с kube-scheduler'а (планировщика). Когда планировщик ищет подходящий узел для пода (и это назначение постоянное), он прогоняет его через фильтры. Например, проверяет, хватает ли на узле ресурсов, подходит ли он под селекторы узлов или правила совместного существования (affinity), не заняты ли нужные порты хоста другим подом и так далее.

Но мало кто знает, что kubelet повторно прогоняет эти фильтры, когда принимает под на узел. И для этого есть веские причины. Например, пользователь может попытаться напрямую запустить на узле свой под (явно прописав ему spec.nodeName) в обход планировщика.

Кроме того, kubelet проводит эти проверки не только при старте пода, но и после собственного перезапуска. Проще говоря, под, который до этого спокойно работал, может быть «убит», если после рестарта или падения kubelet'а он вдруг перестанет проходить проверки.

Вы можете легко убедиться в этом сами:

  1. Выберите узел и навесьте на него лейбл (например, role=worker), затем создайте под с таким же nodeSelector.

  2. Убедитесь, что под запланирован на этот узел и находится в состоянии Running. 

  3. Теперь удалите лейбл с узла; под по-прежнему будет в состоянии Running.

  4. Перезапустите kubelet (под перейдёт в состояние Error):

kubectl get pods
NAME READY STATUS ...
alpine-sleep 0/1 Error ...

Это не совсем «вытеснение» — ведь запись о поде в API остается, просто kubelet его больше не запускает (и корректно завершает его работу). В этом случае вы увидите, что под перешёл в статус Failed, а в сообщении будет что-то вроде этого (раньше оно было куда менее понятным):

Pod was rejected: Predicate NodeAffinity failed: node(s) didn't match Pod's node affinity/selector

Это финальное состояние — под «сломался» окончательно. Его уже не запустить, даже если проверки снова начнут проходить или kubelet перезапустится. С API-сервера его тоже никто не удалит, пока таких «упавших» подов не накопится 12,5 тыс. штук (только тогда их почистит сборщик мусора podgc (сложно сказать, почему kubelet в этой ситуации просто не удаляет под, как при обычном вытеснении. Если кто-то в курсе — дайте знать).

Эти проверки в kubelet'е довольно произвольные и не всегда совпадают с фильтрами планировщика. Например, селекторы узлов, правила affinity или лейбл ОС узла — это строгие условия. А вот если закордонить узел через taint NoSchedule и перезапустить kubelet, под не упадёт. Непонятно, почему так.

Так что настоятельно рекомендую мониторить количество таких сбойных подов и настроить на это алерты.

Это довольно «грязный» способ вытеснения подов, которые до этого прекрасно себя чувствовали на узле. По сути, сиюминутное состояние лейблов на узле (а вы ведь надеетесь, что их никто не поменяет) в комбинации с рестартом kubelet'а (из-за обновления сертификата или сбоя) может внезапно убить ваши поды, наплевав на PodDisruptionBudget и корректное завершение работы.

Проблема тут в том, что после рестарта (как и многие контроллеры) kubelet стартует с пустым кешем и думает, что все поды, которые он видит, новые. В коде это чётко прописано: kubelet не хранит и не восстанавливает состояние запущенных подов. Возможно, в будущем это поправят. Фраза «Рестарт kubelet'а ни на что не влияет» — это миф [один][два], который я и сам бездумно повторял много раз. Были и другие баги в этом admission-механизме, когда даже успешно отработавшие поды (со статусом Succeeded) после проверки помечались как Failed.

Большинство контроллеров (вроде ReplicaSet, StatefulSet, CloneSet) с такой ситуацией способны справиться. Тем же, кто пишет собственный контроллер для управления рабочими нагрузками, скажу следующее: обработка терминальных фаз пода, таких как Failed, — хорошая идея.

Вытеснение низкоприоритетных подов (Preemption)

Помимо механизма очистки узлов (node drain), менеджера taint'ов и kubelet'а, завершать работу подов может планировщик Kubernetes. Если для пода не получа��тся подобрать подходящий узел, kube-scheduler пытается найти подходящий узел, на котором запущен под с более низким приоритетом. Вытеснив его, он сможет освободить место для более важного пода из очереди планирования. 

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

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

Удаление узла

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

В случае облачного провайдера обработкой удаления Node занимается cloud-controller-manager. В случае Cluster API (CAPI) эту задачу выполняет его machine controller. Но если вы пишете свои инструменты для управления кластером (как мы), то вам самим придётся обрабатывать удаление узлов после выведения машин из кластера. Это также означает, что такое вытеснение может произойти, если из-за бага ресурсы Node начнут случайно удаляться.

После удаления ресурса Node «осиротевшие» поды, которые к нему были привязаны, подчищаются встроенным контроллером Kubernetes — pod-garbage-collector. Это удаление происходит принудительно (аналогично kubectl delete pod --force с периодом gracePeriodSeconds=0) и поэтому полностью игнорирует периоды корректного завершения работы.

Заключение

Ниже приведено краткое резюме рассмотренных выше сценариев вытеснения.

Метод

Исполнитель

Соблюдается ли PDB?

Корректное завершение работы

Eviction API

Дрейнеры узлов, kubectl drain

Да

Да

Прямое удаление пода

Контроллеры рабочих нагрузок во время выкатов (например, ReplicaSet, StatefulSet) или kubectl delete

Нет

Да

Давление на узел (мягкое)

kubelet

Нет

Да

Давление на узел (жёсткое)

kubelet

Нет

Нет

taint NoExecute

kube-controller-manager (taint-менеджер)

Нет

Да

Проверка допуска на стороне kubelet'а

kubelet

Нет

Нет

Вытеснение на основе приоритета

Планировщик

Вest effort (если возможно)

Да

Удаление узла

kube-controller-manager (контроллер podgc)

Нет

Нет

P. S.

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