Привет, Хабр! Была одна вещь, которая давно (примерно 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-планировщик, на всех девяти его юнит-тестах.

Что тут происходит?
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, заполненная самой таблицей:

Прочитайте эту раскладку — это не рандом, это планировщик думает:
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, которая схлопывается так, что даже не предупреждает. Если решите повторить — вот обо что споткнетесь:

Константная лямбда схлопывает массив.
MAP(arr, LAMBDA(x, 0))возвращает один0, а не вектор — если лямбда игнорирует свою переменную, Sheets тихо отдаёт скаляр. Заставьте лямбду её использовать:MAP(arr, LAMBDA(v, v*0)).Массив-минус-массив тоже схлопывается. Вычитание двух
FILTER/MAP-векторов (ct - zc), оба по два элемента, дало мне одно значение. Фикс, после которого всё стало надёжно: не делать арифметику массив-на-массив вообще — считать каждый элемент скалярно внутриMAP(keys, LAMBDA(q, XLOOKUP(q, …))).Запись через 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)) — это как вообще делается любая последовательная аккумуляция в ячейках: бегущее размещение, бегущий баланс, что угодно бегущее, — без скрипта. Не ожидал, что контейнерный планировщик научит меня этому, но вот.
Не шедульте продовые поды формулой таблицы. Но прочитайте формулу — это на удивление ясный способ увидеть, что планировщик на самом деле решает.
Потрогайте живую таблицу, потыкайте числа, посмотрите, как меняется раскладка: демо публичное, на чтение.
Присоединяйтесь
Sheeternetes: github.com/sncfoundation/sheeternetes
Slack sheetncf, Telegram t.me/stncf, LinkedIn company/sheetncf
It reconciles.

