Привет, Хабр! Была одна вещь, которая давно (примерно 3 дня) не давала мне покоя в Sheeternetes. Весь смысл проекта — «таблица и есть кластер»: Deployments, Nodes, Pods живут во вкладках, таблица — источник истины. Вот только в первых версиях мы покривили душой и шли против истины. Планировщик — та самая часть, что решает, какой под на какую ноду поедет, — был Python-функцией, которая читала таблицу снаружи. То есть таблица хранила состояние, а думал Python.

Это жульничество, и оно меня грызло (на самом деле нет, это клод так придумал). Поэтому я решил убрать последний внешний мозг: переписать планировщик как формулу таблицы. Без Python, без Apps Script, без bash. Одна =LET(…) на под, которая читает вкладку Nodes и решает, куда его поставить, — пользуясь только тем, что встроено в Google Sheets.

И оно работает. Воспроизводит bin-packing, capacity, spread, sticky placement, cordon, affinity и taints — и выдаёт ровно ту же раскладку, что и настоящий Python-планировщик, на всех девяти его юнит-тестах.

Kube-scheduler теперь формула таблицы
Kube-scheduler теперь формула таблицы
Что тут происходит?

Sheeternetes — оркестратор контейнеров, у которого весь control plane живёт внутри электронной таблицы: Deployments, Nodes, Pods, Events — это вкладки, планировщик читает и пишет ячейки, а на другом конце крутятся настоящие Docker-контейнеры. Проект входит в Sheet-Native Computing Foundation (SNCF) — работающую пародию на CNCF: 30+ проектов, стандарт контейнеров, сертификация, живые демо.

Правила игры

Планировщик Sheeternetes — чистая функция schedule(deployments, nodes, existing). Она идёт по деплойментам по порядку, разворачивает реплики и для каждого пода выбирает ноду:

  • sticky — если текущая нода пода жива, подходит и есть место, оставить его там;

  • иначе best-fit — подходящая нода с максимумом свободного CPU, в которую под влезает;

  • подходящая значит: fresh, schedulable (не cordoned), совпала affinity (node_selector ⊆ лейблы ноды), под толерирует NoSchedule-тейнты ноды, и хватает и CPU, и памяти;

  • никуда не влезло → под Unschedulable.

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

Задача, стало быть: воспроизвести stateful, зависящий от порядка bin-packer только ячейками.

Раскладка в таблице

Две вкладки. Nodes — ёмкость кластера, по строке на ноду:

name

cpu_total

mem_total

fresh

schedulable

labels

taints

node-a

4000

8192

TRUE

TRUE

disk=ssd

node-b

2000

4096

TRUE

TRUE

disk=hdd

node-gpu

8000

16384

TRUE

TRUE

disk=ssd

gpu=true:NoSchedule

Pods — по строке на реплику, в том порядке, в каком их обработал бы планировщик. Последняя колонка, chosen, пустая — её и заполняет формула.

Ключ к жадной аккумуляции — растущий диапазон. Когда формуле в строке r нужно знать, «сколько CPU уже занято на ноде X», она суммирует строки выше себя:

SUMIFS($C$2:INDEX($C:$C, ROW()-1), $G$2:INDEX($G:$G, ROW()-1), нода)

$C$2:INDEX($C:$C, ROW()-1) — тот самый растущий диапазон: строки со 2-й по «одну надо мной». Поскольку строка r смотрит только на строки < r, циклической ссылки нет; таблица просто считает сверху вниз, и каждый под видит уже размещённые. Одна эта идея превращает электронную таблицу в последовательный bin-packer.

Формула

Для каждого пода — оценить каждую ноду скалярно, потом выбрать лучшую:

=LET(
  nm, FILTER(Nodes!$A$2:$A, Nodes!$A$2:$A<>""),   /* имена, ёмкости, флаги … */
  creq, INDEX($C:$C, ROW()), mreq, INDEX($D:$D, ROW()),
  sel, INDEX($E:$E, ROW()),  tol, INDEX($F:$F, ROW()), prev, INDEX($H:$H, ROW()),
  score, MAP(nm, LAMBDA(q,
     IF( AND( fresh(q), schedulable(q), not_excluded(q),
              affinity_ok(q), tolerates(q),
              free_cpu(q) >= creq, free_mem(q) >= mreq ),
         free_cpu(q), -1 ))),
  best, IF(MAX(score) < 0, "", XLOOKUP(MAX(score), score, nm)),
  IF(sticky_ok(prev), prev, best)
)

MAP(nm, LAMBDA(q, …)) оценивает каждую ноду; неподходящие получают -1; XLOOKUP(MAX(score), score, nm) возвращает победителя — а поскольку XLOOKUP отдаёт первое совпадение, ничьи разрешаются в пользу первой ноды по порядку, ровно как питоновский max(). Затем sticky-проверка перебивает выбор предыдущей нодой, если та ещё валидна.

Вот оно на реальном кластере — одна золотая =LET(…) в строке формул, и колонка chosen, заполненная самой таблицей:

Вкладка Pods: колонка chosen считается целиком формулой
Вкладка Pods: колонка chosen считается целиком формулой

Прочитайте эту раскладку — это не рандом, это планировщик думает:

  • web (4×300m) пакуется на node-a;

  • db требует disk=ssd, то есть может только на node-a или node-gpu — но node-gpu под тейнтом, а db его не толерирует, так что оба db едут на node-a;

  • ml толерирует gpu=true, и потому это единственное, что садится на node-gpu;

  • batch (1200m каждый) не может на тейнтованную GPU-ноду; первый идёт на node-b (в тот момент там больше свободного CPU), второй обратно на node-a, а третий — не влезает никуда и краснеет, Unschedulable.

Это affinity, taints, bin-packing, spread и capacity — всё решено формулой.

А оно правда совпадает с Python-планировщиком?

Вот это меня и интересовало по-настоящему. У Python-schedule() есть набор юнит-тестов — spread, bin-packing по ёмкости, память как ограничение, cordon-оставляет-существующие, affinity-пины, taint отталкивает, toleration пропускает, слишком-большой-под-unschedulable. Так что я собрал harness, который прогоняет каждый тест-кейс через оба планировщика и сравнивает раскладку.

Один планировщик, два субстрата — одинаковый результат
Один планировщик, два субстрата — одинаковый результат

Девять из девяти. Тот же под на той же ноде, в каждом случае. Плюс более богатое демо на несколько деплойментов выше — его я тоже сверил с Python: идентично, вплоть до того, какая именно реплика batch остаётся без ноды. Формула — не приближение планировщика; на этих кейсах она и есть планировщик.

Три грабли, которые дали сдачи

Чтобы дойти до этого, пришлось потерять полдня на массивную семантику Google Sheets, которая схлопывается так, что даже не предупреждает. Если решите повторить — вот обо что споткнетесь:

Три грабли Google Sheets, которые дали сдачи
Три грабли Google Sheets, которые дали сдачи
  1. Константная лямбда схлопывает массив. MAP(arr, LAMBDA(x, 0)) возвращает один 0, а не вектор — если лямбда игнорирует свою переменную, Sheets тихо отдаёт скаляр. Заставьте лямбду её использовать: MAP(arr, LAMBDA(v, v*0)).

  2. Массив-минус-массив тоже схлопывается. Вычитание двух FILTER/MAP-векторов (ct - zc), оба по два элемента, дало мне одно значение. Фикс, после которого всё стало надёжно: не делать арифметику массив-на-массив вообще — считать каждый элемент скалярно внутри MAP(keys, LAMBDA(q, XLOOKUP(q, …))).

  3. Запись через API не адаптирует относительные ссылки. Я писал одну и ту же строку формулы в каждую строку через Sheets API — без fill-down, — так что $C2 в строке 3 всё ещё указывал на строку 2. Половина сценария разместилась по данным не того пода. Используйте INDEX($C:$C, ROW()) для «этой строки» и растущий $C$2:INDEX($C:$C, ROW()-1) для «строк выше».

Ни одна из них не кидает ошибку. Они просто выдают неверный ответ с полной уверенностью — а это худший вид. Бенчмарк, который только подтверждает твои убеждения, — не бенчмарк; и формула, которая молча возвращает скаляр, — не баг-репорт: её находишь, только сравнивая с тем, чему доверяешь.

Зачем вообще это всё

Затем, что это закрывает дыру в шутке. «Таблица и есть кластер» звучало чуть нечестно, пока планировщик крутился в Python. Теперь таблица не просто хранит желаемое состояние — она вычисляет размещение. Меняешь число replicas — и соседние ячейки заново выводят, какому поду на какой ноде быть, вживую, без единого процесса где-либо. Более sheet-native планирование сложно придумать.

А под абсурдом прячется по-настоящему полезный урок про жадные алгоритмы в таблицах: приём с растущим диапазоном (A$2:INDEX(A:A, ROW()-1)) — это как вообще делается любая последовательная аккумуляция в ячейках: бегущее размещение, бегущий баланс, что угодно бегущее, — без скрипта. Не ожидал, что контейнерный планировщик научит меня этому, но вот.

Не шедульте продовые поды формулой таблицы. Но прочитайте формулу — это на удивление ясный способ увидеть, что планировщик на самом деле решает.

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

Присоединяйтесь

It reconciles.