Перед вами перевод статьи drmorr в блоге «Applied Computing Research Labs». Автор наглядно демонстрирует, как автоскейлер Karpenter консолидирует узлы кластера, и объясняет, в чём причины его на первый взгляд странного поведения.

Эта история началась во вторник в 9 утра с сообщения от коллеги: «Привет, drmorr! Karpenter в кластере творит что-то странное, посмотри, плиз?»

«Да не вопрос», — ответил я, даже не успев глотнуть утреннего кофе. «Займёт минут 15, и вернусь к своим делам», — подумал я. Через пять часов до меня, наконец, дошло, в чём дело. Ещё через два часа я смог внятно объяснить это коллеге. А где-то через неделю разобрался в теме настолько, что сел за этот пост.

Спойлер: ошибок в Karpenter'е нет, всё работает логически стройно, но (по крайней мере, для меня) результат всё равно получается достаточно неожиданным для того, чтобы посвятить этому отдельный пост.

Всё началось с графика

Сразу оговорюсь: я убрал из статьи все потенциально узнаваемые детали, чтобы защитить… честно говоря, сам не знаю кого. Вряд ли тут есть что-то секретное, но всё же. Проблема началась вот с такого графика:

Рисунок 1. Гипотетический график неиспользуемых ресурсов CPU во времени
Рисунок 1. Гипотетический график неиспользуемых ресурсов CPU во времени

График показывает неиспользуемые CPU в кластере, сгруппированные по узлам. Иными словами, каждая линия представляет отдельный узел, а значение в любой точке — это число свободных (доступных для запуска подов) CPU на этом узле. Используем накопительный график, чтобы наглядно видеть и распределение неиспользуемых CPU, и их суммарный объём в кластере.

Читать такой график непросто. Но если всмотреться в него повнимательнее, то можно заметить две вещи: 

  • объем свободных ресурсов CPU в кластере держится на стабильном уровне: почти всегда доступно около 5 ядер; 

  • несмотря на это, узлы в кластере постоянно ротируются. 

Раз за разом на протяжении 12 с лишним часов повторяется одна и та же картина. Зачем Karpenter это делает? И виноват ли он вообще?

Проверив логи Karpenter’а, я подтвердил, что он удалял узлы из-за консолидации. Но ясности это не добавило: Karpenter должен консолидировать узлы с целью плотнее «упаковать» кластер. Проще говоря, после каждой консолидации объём простаивающих ресурсов должен сокращаться. На деле же всё иначе: консолидация проходит, а незадействованная ёмкость остаётся на прежнем уровне.

Чтобы разобраться в этой чертовщине, надо понять две вещи: что именно запущено в кластере и как работает механизм консолидации узлов в Karpenter. Давайте начнём с консолидации, а то в интернетах про её работу ходит куча вранья… Ладно, скажем мягче — слегка неточных утверждений.

Кто и что консолидирует?

Типичный рекламный питч для Karpenter'а звучит так: «Поставьте наш чудо-автоскейлер и забудьте о проблемах!»

Окей, перегнул с сарказмом. Попытка номер два. Обычно Karpenter продают под соусом: «Наш волшебный автоскейлер поможет вам сильно сэкономить на кластере!» Опять язвительно, но хотя бы ближе к реальности. Встаёт вопрос: за счёт чего Karpenter экономит деньги? Ответ — консолидация узлов.

На самом верхнем уровне у Karpenter'а есть два контура управления: контур масштабирования и контур консолидации (я подробно разбирал их производительность ещё во времена SimKube 1.0). Контур масштабирования работает быстро: он моментально реагирует на любые Pending-поды и поднимает новые узлы, чтобы те запланировались как можно быстрее. Контур консолидации работает медленнее; он постоянно подбирает «оптимальную» конфигурацию подов и узлов, где под «оптимальной» в данном случае понимается «самая дешёвая». На практике это означает непрерывные попытки сжать кластер (то есть перепаковать его — bin-packing), перемещая поды с малоиспользуемых узлов, чтобы затем эти узлы удалить. В теории это снижает расходы, ведь вы платите AWS за меньшее количество ресурсов.

Давайте разберём процесс консолидации чуточку подробнее. Если почитать документацию Karpenter’а, можно удивиться тому, как мало здесь «крутилок» для настройки. Для каждого пула узлов задаются всего два параметра: consolidationPolicy и consolidateAfter. Первый управляет тем, как именно Karpenter проводит консолидацию, — можно задать значения WhenEmpty или WhenEmptyOrUnderutilized. Второй определяет задержку перед запуском консолидации.

Примечание: в последней версии Karpenter’а (1.14 на момент написания) появилась третья политика — Balanced (выбирается с помощью параметра consolidationPolicy). Я ничего не знаю о том, как она работает, кроме того, что, по-видимому, это менее агрессивная форма консолидации, которая сокращает ротацию узлов.

Как правило, Karpenter'оводы выбирают значение WhenEmptyOrUnderutilized, и это разумно: при WhenEmpty придётся ждать, пока поды сами завершат свою работу. Перепаковка кластера запустится только после этого. В результате получим кучу полупустых узлов в кластере. Так что consolidateAfter — единственный разумный выбор. Он помогает снизить избыточную ротацию узлов: если поставить слишком маленькое значение, Karpenter будет удалять узлы мгновенно (и это не преувеличение — по умолчанию время ожидания 0 сек.). Но это неудобно, когда в кластер вот-вот должна прийти новая нагрузка, которая могла бы использовать этот узел. Придётся поднимать новый узел с нуля, а его подготовка наверняка займёт больше времени (и обойдётся дороже), чем если бы вы просто подержали «прогретый» узел в кластере чуть подольше. Если у вас такое происходит постоянно, просто увеличьте значение consolidateAfter, чтобы Karpenter не спешил удалять узлы.

Документацию Karpenter’а я прочитал уже раз сто. И каждый раз ловил себя на мысли: «Странно, что нельзя настроить порог утилизации для запуска консолидации!» Ну то есть мы всегда говорим, что Karpenter консолидирует «недозагруженные» узлы. Логично же, что должен быть какой-то порог, за которым узел считается нормально загруженным. Меня всегда удивляло, почему этот порог нельзя настроить. Но, как оказалось, на это есть весомая причина: никакого порога попросту не существует.

Karpenter оценивает узел как кандидата на консолидацию независимо от того, насколько он заполнен. Неважно, работает там один под или целая сотня: как только время consolidateAfter для узла вышло, тот становится кандидатом на консолидацию. С точки зрения Karpenter’а имеет значение только один вопрос: поместятся ли все эти поды где-либо ещё? Если ответ «да» — бай-бай, тебе пора на выход…

Отсюда следует вывод, что поведение Karpenter'а вообще не зависит от состояния конкретного узла, но полностью определяется состоянием всего кластера. Будет ли консолидирован узел с сотней подов в полностью загруженном кластере? Нет. Может ли быть консолидирован тот же узел с сотней подов в кластере, где много свободных узлов? Да. Иными словами, Karpenter использует глобальное состояние (структуру всего кластера) для принятия локального решения (что делать с конкретным узлом). Как правило, в распределённых системах мы стремимся к прямо противоположному подходу.

То видно, то не видно

Теперь, когда мы (ну или я, как минимум) лучше понимаем логику консолидации Karpenter'а, давайте вернёмся к запущенным в кластере нагрузкам, а конкретно — к графику, которым я поделился в начале статьи. Видите что-либо странное? Вот и я не видел, пока не посмотрел на его инверсию. Вместо того чтобы смотреть на свободные ресурсы в кластере, давайте взглянем на занятую (запланированную) ёмкость CPU:

Рисунок 2. Гипотетический график запрошенных ресурсов CPU во времени; по сути, это инвертированный вариант рисунка 1
Рисунок 2. Гипотетический график запрошенных ресурсов CPU во времени; по сути, это инвертированный вариант рисунка 1

График тоже непростой, но разобраться в нём уже легче. Видно, что утилизация ресурсов в кластере держится примерно на одном уровне, хотя узлы при этом постоянно пересоздаются. Но постойте, что это за всплески? Откуда они? Оказывается, в этом кластере несколько долгоживущих подов работают на том же пуле узлов, что и короткоживущая задача (Job), которая: 

а) запускается через регулярные промежутки времени; 

б) потребляет относительно большой объём вычислительных ресурсов. 

Другой ключевой момент: для некоторых долгоживущих подов в кластере настроен период корректного завершения работы (termination grace period). То есть после того, как Karpenter запустит консолидацию для узла, тот будет продолжать работать ещё некоторое время, прежде чем поды будут с него перенесены.

Теперь у нас есть все детали пазла. Чтобы не ломать глаза о графики, давайте просто нарисуем наши узлы с их содержимым. Представьте сценарий: есть три узла (A, B и C) со следующим распределением рабочих нагрузок:

Рисунок 3. Кластер Kubernetes из трёх узлов с 6 подами
Рисунок 3. Кластер Kubernetes из трёх узлов с 6 подами

Пока что тяжёлый короткоживущий под отсутствует, а все остальные поды могут поместиться на двух узлах, поэтому Karpenter решает провести консолидацию узла A:

Рисунок 4. Кластер Kubernetes из трех узлов с 6 подами после того, как Karpenter принял решение о консолидации узла A
Рисунок 4. Кластер Kubernetes из трех узлов с 6 подами после того, как Karpenter принял решение о консолидации узла A

Большинство подов на узле A были вытеснены (evicted) и перепланированы на узел B; один из подов на узле A всё ещё висит в статусе Terminating, дожидаясь окончания периода корректного завершения работы. И тут появляется тяжёлый короткоживущий под (у нас в примере он занимает целый узел, однако легко смоделировать ситуацию, когда это не так). Ему негде запуститься (узел А не подходит, поскольку находится в процессе удаления), поэтому Karpenter запускает для него новый узел:

Рисунок 5. Кластер Kubernetes из четырех узлов с 6 небольшими подами и одним крупным подом на новом узле D
Рисунок 5. Кластер Kubernetes из четырех узлов с 6 небольшими подами и одним крупным подом на новом узле D

Вскоре после этого короткоживущий под завершает работу, и в кластере снова остаются три активных узла. Узлы B и D не подлежат консолидации, так как на обоих недавно происходили операции масштабирования (Karpenter сбрасывает таймер consolidateAfter всякий раз, когда под планируется на узел или удаляется с него. Вроде бы логичное поведение, хотя раньше я думал, что удаление подов с узла не должно сбрасывать таймер); а вот узел C уже некоторое время работает без изменений. Его Karpenter может консолидировать! Что он и делает:

Рисунок 6. Кластер Kubernetes из трех узлов с 6 подами; узел C помечен на удаление
Рисунок 6. Кластер Kubernetes из трех узлов с 6 подами; узел C помечен на удаление

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

Так как же это починить?

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

Знаю, вы думаете, что у меня есть готовое решение, но на самом деле его нет. Рассуждения Karpenter'а в духе: «Так, у меня два узла — загруженный и почти пустой. Мне запрещено трогать пустой узел, поэтому я перепланирую все поды с загруженного» — выглядят абсурдными. Но как это исправить? Вот несколько вариантов:

  • Возможно, в Karpenter стоит внедрить некий порог консолидации для узлов. Например, если узел А заполнен более чем на 75 % (или на нём запущено более 42 подов), Karpenter не будет его трогать. Это предотвратит консолидацию узлов с большим количеством подов, но также может помешать кластеру масштабироваться вниз при необходимости.

  • Karpenter мог бы помечать узлы, на которых недавно планировались поды, как недоступные для размещения других подов во время симуляции планирования (такое поведение противоположно тому, что имеем сейчас: при малом значении таймера консолидации для узла Karpenter не будет пытаться перемещать поды с него. Хорошо бы, чтобы Karpenter также не пытался перемещать поды на него). Но это тоже существенно повлияет на эффективность консолидации, да и логика работы будет сильно отличаться от того, что делает kube-scheduler (примечание автора: кстати, это уже сделали в Karpenter 1.14).

  • Может быть, Karpenter'у нужен некий «глобальный порог ротации узлов»? Нечто подобное у него уже есть — бюджеты прерываний пула узлов (node pool disruption budgets), однако они учитывают только те узлы, которые удаляются прямо сейчас, и не смотрят на историю ротации.

  • Или пусть Karpenter анализирует историю утилизации ресурсов, когда принимает решения о консолидации? Что-то вроде «Так, этот короткий, но прожорливый под появляется с завидной регулярностью, надо бы придержать для него место». Было бы клёво! Увы, предиктивное автомасштабирование реализовать крайне сложно, и сценариев, в которых оно может дать сбой, гораздо больше, чем у чисто реактивного, реагирующего автоскейлера.

Наверняка есть и другие варианты, но какой из них правильный? Может, внедрить сразу несколько? Но вдруг они начнут конфликтовать с остальными правилами масштабирования? Как мы об этом узнаем? В большинстве случаев, сталкиваясь с подобными проблемами автоскейлинга, инженеры принимают решения на основе максимально «научных» критериев вроде интуиции и принципа «пофиг, живём только раз».

Но, чёрт возьми, неужели нет способа получше?

Как обычно, спасибо, что дочитали до конца :)

P. S.

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

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