Привет, Хабр!

Меня зовут Андрей Бирюков. Я — независимый эксперт в области ИТ и ИБ, преподаю в учебных центрах и пишу статьи и книги.

Сегодня мы будем говорить про Kubernetes, как бы не «оригинально» это ни звучало. Большинство инцидентов уровня P1 в микросервисной инфраструктуре начинаются одинаково: DevOps видит, как поды с веб‑фронтендом внезапно уходят в OOMKilled или Evicted, хотя мониторинг CPU показывает утилизацию всего 65%. Через 40 секунд поды пересоздаются, но их холодный старт (подтягивание образов, инициализация кэшей, отработка пробов) занимает порядка 2 минут. В итоге, пока «важные» поды борются за выживание, вся система ложится из‑за таймаутов пробов готовности.

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

Как планировщик считает «цену» пода

Давайте попробуем разобраться, как возникает эта проблема. Планировщик принимает решение не на основе текущей нагрузки, а на основе состояния очереди и оценки QoS (Quality of Service). В Kubernetes существует три класса QoS:

  1. Guaranteed — когда requests строго равны limits по CPU и RAM.

  2. Burstable — когда requests меньше limits (или задан только limits).

  3. BestEffort — когда не задано ни того, ни другого.

Соответственно, здесь кроется ошибка номер один: выставлять для критичных stateful‑приложений (базы данных, очереди) requests с большим запасом, чтобы «зарезервировать» ресурсы, а для stateless‑воркеров ставить низкие requests, чтобы сэкономить.

На этом шаге вступает в силу алгоритм оценки узла (Node Score). На этапе PreFilter планировщик смотрит не на свободную память, а на величину (Allocatable - TotalRequests). То есть, если высокоприоритетный под с большим requests не может поместиться ни на один узел, планировщик запускает механизм Preemption (вытеснение). И тут начинается самое интересное: вытесняются не наименее важные поды, а те, у которых ниже приоритет и выше использование ресурсов относительно своих requests.

Представьте ситуацию: ваш критичный StatefulSet с PostgreSQL (приоритет 100) запросил 8 ГБ RAM. На узле остается 2 ГБ. Планировщик находит под‑воркер (приоритет 50) с requests: 1ГБ, который в реальности потребляет 6 ГБ из‑за утечки памяти. Планировщик считает «жертву» эффективной: выселив один под с requests 1ГБ, он освободит 6 ГБ физической памяти, так как kubelet учитывает именно usage, а не request. Выселение происходит, воркер умирает, PostgreSQL встает. Через 5 секунд воркер пересоздается на соседнем узле, но его кэш инвалидирован. Начинается каскад и все становится совсем плохо.

Эффект «Лавины регистрации»

Допустим, для решения этой проблемы, вы настроили PodDisruptionBudget (PDB), чтобы защитить критичные поды от вытеснения. Но в реальности это не особо помогает. Дело в том, что PDB защищает только от добровольных прерываний (drain узлов, апгрейды). Preemption — это вынужденное прерывание, и PDB на него не действует. Планировщик проигнорирует PDB, если альтернативы нет.

Самая тяжелая техническая ошибка кроется во взаимодействии планировщика с Horizontal Pod Autoscaler (HPA) и Cluster Autoscaler (CA). Когда высокоприоритетный под не может встать из‑за нехватки CPU, CA добавляет новый узел в кластер. Новый узел поднимается 3–4 минуты. За это время HPA, видя рост нагрузки на старых узлах, начинает плодить новые реплики низкоприоритетных воркеров. Они занимают освободившееся место быстрее, чем поднимается узел.

В итоге CA видит, что под с высоким приоритетом все еще висит в очереди Pending, и добавляет еще один узел. Кластер распухает на 30% за 10 минут. А когда новые узлы наконец появляются, высокоприоритетный под встает, но вместе с ним туда же, по правилу least-requested, планировщик закидывает и низкоприоритетные поды. Происходит эффект симбиоза: высокий приоритет тянет за собой мусор, так как планировщик старается максимально заполнить узел, чтобы не тратить ресурсы зря.

Приоритет очереди и время ожидания

Теперь давайте поговорим об очередях и о том, как они влияют на работу всего кластера. Планировщик использует две очереди: activeQ (поды, готовые к назначению) и unschedulableQ (поды, которые не удалось разместить). Критическая ошибка — игнорирование параметра podMaxInUnschedulableQDuration.

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

Если ваш высокоприоритетный под не встает из‑за нехватки GPU или огромных PVC‑томов, он блокирует всю очередь. Планировщик не будет параллельно обрабатывать другие поды, если они имеют более низкий приоритет, так как алгоритм Schedule сначала пытается удовлетворить самого «жирного» клиента. Э

то приводит к тому, что простой под на 100 mCore висит в очереди 4 минуты, хотя на узле есть 2 свободных ядра, но они фрагментированы (заняты мелкими подами). Планировщик слеп к фрагментации, если не включен NodeResourcesFit с политикой MostAllocated — но эту политику включают редко, так как она убивает автоскейлинг.

Инверсия через политики «Упреждающего вытеснения»

Разобравшись с проблематикой, давайте теперь поговорим о том, что, собственно, можно сделать.

Единственный рабочий технический подход — перестать воспринимать priority как защиту. Вместо этого необходимо использовать связку:

  • Принудительное использование requests равным limits для высоконагруженных подов. Это переводит их в класс Guaranteed. Такой под никогда не будет вытеснен из‑за нехватки памяти на узле (его память зарезервирована жестко). Но за это вы платите простаивающими 20% ресурсов, которые нельзя отдать бурстовым задачам.

  • Внедрение PriorityThreshold в descheduler. Вместо того чтобы позволять планировщику вытеснять поды при дефиците, вы запускаете descheduler в режиме RemovePodsViolatingNodeAffinity с жестким порогом. Он заранее, при утилизации узла > 85%, начинает мирно эвакуировать BestEffort‑поды, используя terminationGracePeriodSeconds. Это дает им время отдать незавершенные транзакции (drain), в отличие от хардкорного SIGKILL при использовании preemption.

  • Самая нетривиальная техника — манипуляция schedulerName. Для высокоприоритетных, но «тяжелых» подов (например, Spark‑драйверов) создают отдельный экземпляр планировщика с кастомным профилем, где отключен Preemption. Эти поды просто встают в очередь и ждут, пока освободится место естественным путем, не трогая очередь основного планировщика, где крутятся веб‑сервисы.

Это разрывает цикл «приоритетный под будит CA → CA добавляет ноду → нода заполняется мусором».

Cgroups v2 и давление памяти (PSI)

На уровне ядра Linux данная ошибка проявляется еще жестче. Когда планировщик kubelet принимает решение о вытеснении, он смотрит на memory.available — это не вся свободная память, а значение, вычисленное с учетом inactive_file (кэша страниц). Если высокоприоритетный под использует memory‑mapped файлы (например, Java занимает огромный heap), ядро начинает вытеснять страницы кэша других подов в swap (если он включен) или просто убивает процесс с самым высоким oom_score_adj.

В Cgroups v2 появился механизм PSI (Pressure Stall Information). Ошибка в том, что стандартный kubelet до версии 1.26 не использовал PSI для принятия решений о вытеснении, а полагался на жесткие пороги evictionHard. Если вы не настроили evictionSoft с eviction-max-pod-grace-period, kubelet ждет до последнего, а потом убивает поды с самым высоким приоритетом первыми, потому что они потребляют больше всего памяти относительно requests.

Это противоречит логике, но это факт: при OOM на узле ядро смотрит на oom_score_adj пода. Для Guaranteed этот коэффициент равен -998, для BestEffort — 1000. Казалось бы, Guaranteed защищен. Но если у вас два Guaranteed пода, и один потребляет на 10% больше своего request (что невозможно, если requests==limits, но возможно из‑за overcommit ядра), то ядро убьет тот, у которого выше использование в абсолютных цифрах.

Как избежать инверсии через Taint и Toleration

Самый надежный промышленный паттерн — это не возиться с приоритетами, а использовать горячие и холодные ноды. Вы выполняете taint для группы узлов специальным ключом, например, critical=heavy:NoSchedule. Высокоприоритетные поды получают toleration, а все остальные — нет. Они живут в изолированном пуле, где requests жестко пересчитаны так, что сумма всех requests никогда не превышает 80% allocatable.

В этом пуле вы отключаете Preemption через конфиг планировщика (disablePreemption: true). Теперь, если высокоприоритетному поду не хватает места, он просто висит в Pending, НО он не вытесняет низкоприоритетные поды из других пулов, потому что они не имеют toleration и не могут быть сюда переброшены. И главное — этот подход убивает эффект шторма реинкарнаций, потому что eviction в этом пуле происходит только по драйну ноды, а не по алгоритму вытеснения.

Подведем итог

Контроль над приоритетами в Kubernetes — это контроль над источниками стресса планировщика. Помните: чем выше приоритет пода, тем более жестким должен быть его requests, и тем более узким должен быть круг узлов, где он имеет право жить. Если ваш «критичный» под может лечь на любой узел кластера — он критичен только по названию, но не по архитектуре.

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

Устройство Kubernetes и его ключевые абстракции подробно разобраны в отдельном гайде.


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

  • 21 июля в 20:00 — «Выбор между Serverless и Kubernetes для AI-ворклоадов: как определить оптимальную платформу под задачу». Записаться

  • 12 августа в 20:00 — «Паттерны отказоустойчивости и масштабируемости микросервисной архитектуры». Записаться

Занятия проводят преподаватели-практики. Во время эфира можно задать вопросы по теме урока и получить ответы от эксперта.

Больше бесплатных уроков июля смотрите в дайджесте.

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