Федерация двух кластеров через таблицу: сеть, пул ёмкости и WASM-рантайм в ячейке

Привет, Хабр! Наши sheet-ербьюторы не спят и продолжают развивать экосистему даже утром в субботу, когда сон для усталых взрослых людей. Теперь к делу!
Sheeternetes держит кластер контейнеров, у которого control plane — электронная таблица. Этот пост — про то, как заставить два таких кластера работать как один: on-prem-кластер поверх локального Excel-файла и облачный поверх Google-таблицы — по сети, с общей ёмкостью, живой миграцией и рантаймом, которому не нужен Docker. Всё воспроизводимо; код — один небольшой репозиторий.
Впервые тут? Sheeternetes — оркестратор контейнеров, у которого control plane живёт внутри электронной таблицы: Deployments, Nodes, Pods — это вкладки, планировщик читает и пишет ячейки, а на нодах крутятся настоящие Docker-контейнеры. Проект входит в Sheet-Native Computing Foundation (SNCF) — работающую пародию на CNCF, где весь стек живёт в таблицах. sncfoundation.github.io · github.com/sncfoundation
Проблема — не discovery, а reachability
Федерацию двух кластеров обычно формулируют как задачу discovery: научить кластер A знать про сервисы кластера B. Это простая часть. Сложная — reachability. On-prem-кластер сидит за NAT, снаружи к нему не постучаться; у serverless-кластера на Google Sheets входящего эндпоинта нет вообще. Можно сколько угодно публиковать каталог сервисов, но если под в A не может открыть сокет к поду в B — у тебя не федеративный кластер, а два кластера с общей адресной книгой.
Обычный ответ — туннель (WireGuard, overlay), но туннелю нужна хотя бы одна сторона, доступная на входящие, или relay, до которого дозваниваются обе. У нас за NAT обе стороны. Зато обе видят одну обычную вещь по простому исходящему HTTPS — Google-таблицу. Значит, транспортом становится сама таблица.
Sheetwire: таблица как провод
sheetwire.py — userspace-релей на TCP, транспорт которого — общая таблица. У него две роли:
serveкрутится рядом с сервисом. Следит за таблицей на запросы соединения, дозванивается до локального target и стримит ответ обратно в ячейки.exposeкрутится там, откуда хочешь дотянуться до сервиса. Открывает локальный слушающий порт; всё, что в него отправляешь, нарезается в таблицу, адресованное пиру.
# сторона B — хостит сервис
sheetwire.py serve --wire <id-общей-таблицы> --service web --target 127.0.0.1:8080
# сторона A — хочет дотянуться
sheetwire.py expose --wire <id-общей-таблицы> --service web --listen 127.0.0.1:9080
# на стороне A:
curl localhost:9080 # доходит до сервиса стороны B через ячейки
Транспорт — одна append-only вкладка Wire, по строке на кадр:
conn | kind (open | data | close) | dir (a2b | b2a) | service | payload (base64)
Каждое принятое TCP-соединение получает случайный conn-id, так что много соединений мультиплексируются в одной вкладке. Жизненный цикл минимальный: кадр open называет целевой сервис; кадры data несут base64-байты в одну сторону (a2b) или в другую (b2a); кадр close завершает поток. Payload крупнее безопасного размера ячейки шардится на несколько data-кадров по порядку. Сборка — просто конкатенация по порядку строк.
Обе стороны крутят один цикл: поллят новые строки, адресованные мне (двигая курсор, чтобы каждая строка читалась один раз), применяют их к локальным сокетам, затем сбрасывают свои исходящие кадры. Поскольку всё — только исходящие записи и чтения из таблицы, релей проходит любой NAT или фаервол: входящих портов нет ни с одной стороны.

Читаешь строки — и получаешь весь обмен стенограммой: запрос GET / HTTP/1.1 в одной ячейке, ответ HTTP/1.1 200 OK в другой, тело, закрытие. HTTP round-trip, сериализованный строками таблицы.
Инженерные заметки (места, где кусало)
Несколько вещей, которые стоит записать, — это неочевидная цена такого дизайна.
Гонка на создании при старте. На первом запуске обе роли стартовали одновременно и обе попытались создать вкладку Wire; одна выиграла, вторая получила 400 «вкладка с таким именем уже существует» и упала. Фикс — трактовать проигрыш гонки создания как успех:
try:
ss.batchUpdate(..., {"addSheet": {"properties": {"title": "Wire"}}}).execute()
except HttpError as e:
if "already exists" not in str(e): raise # другая сторона выиграла создание
macOS не маршрутизирует до IP контейнера. Первый сквозной тест с контейнерным бэкендом отказывался соединяться при чистых логах — худший вид отказа. Sheetwire ни при чём: на macOS IP контейнеров в bridge-сети Docker не маршрутизируются с хоста. Публикуй порт (-p) или запускай релей в той же Docker-сети; на Linux проблемы нет. Полезно знать заранее, чтобы не потерять на этом час.
Квота записи заставляет батчить. Google Sheets позволяет примерно 60 запросов на запись в минуту на пользователя. Кадр на чанк вылетает за это мгновенно. Поэтому каждая сторона накапливает исходящие кадры и сбрасывает их раз в тик (по умолчанию ~1.1 с) одним values.append — одна запись независимо от числа кадров — и читает в том же ритме. Следствие честное: Sheetwire — низкоскоростной канал сервис-к-сервису (межкластерные вызовы, control-plane-переписка), а не путь для объёмных данных. Задержка — пара тиков на round-trip.
Проверка на двух хостах
Всё выше сначала крутилось на одной машине — это доказывает логику, но не посылку: релей, который ты запускал только рядом с самим собой, ничего не пересёк. Итак: ноутбук с одной стороны, отдельная Linux-машина с другой, общая таблица как провод.
Подъём второго хоста вскрыл своё трение, всё бытовое и всё стоящее упоминания для тех, кто будет воспроизводить: машина не хотела git clone под дефолтной политикой одного агента (лечится клоном в обычном шелле — репо публичный, авторизация не нужна); pip отказался ставить в externally-managed окружение (PEP 668 — venv или --break-system-packages, хотя нужные две либы уже стояли); и между машинами не было ssh, так что OAuth-креды пришлось переносить руками.
С бэкендом на Linux-хосте и serve, направленным на него, — с ноутбука:
$ curl localhost:9090
<h1>hello from HOST B — through a spreadsheet</h1>
Этот HTML отдал процесс на другом хосте. Он пересёк интернет один раз, через общую таблицу, без входящих портов ни на одной машине. Весь обмен (open → GET → 200 OK → тело → close) виден строками во вкладке Wire.

Растяжка кластера: пул ёмкости пира
Когда связность есть, следующий примитив — общая ёмкость: дать одному кластеру ставить работу на другой. bridge.py stretch смотрит на ноды обоих кластеров как на один пул: заполняет локальный, а остаток реплик заказывает у пира.
python3 bridge.py stretch web --replicas 10 --cpu 300 \
--local http://localhost:8801 --local-token secret \
--peer http://localhost:8802 --peer-token secret
# web x10 @ 300m | local free 1000m -> 3, ordered from peer -> 7
Сплит простой и учитывает ёмкость: читаем свободный CPU каждого кластера (по нодам cpu_total − cpu_used среди Ready и schedulable), вмещаем локально столько реплик, сколько позволяет локальный свободный CPU, и остаток применяем к пиру тем же деплойментом по имени. Десять реплик, три влезли локально, семь зашедулены на нодах пира. Снаружи это один деплоймент — один web, десять подов — растянутый через Excel-файл и Google-таблицу, а Sheetwire сшивает Service через оба субстрата.
Три плоскости складываются в одну федерацию, и транспорт под каждой — одна и та же общая таблица:

Живая миграция между субстратами
Перенос нагрузки между кластерами вживую — самый старый кусок здесь, он старше сетевой части. bridge migrate работает по make-before-break: копирует деплоймент на target и ждёт, пока тот отрапортует Ready, прежде чем сливать его с source. Если target так и не поднялся, source остаётся нетронутым — ноль простоя, ничего не потеряно. С --rollback-window N она продолжает следить за target N секунд после переключения и, если число реплик деградирует, автоматически восстанавливает деплоймент на source и снимает с target.
python3 bridge.py migrate web --from local --to peer --rollback-window 30 \
--local http://localhost:8801 --local-token secret \
--peer http://localhost:8802 --peer-token secret
Sheet-native рантайм: WASM в ячейке
Docker на ноде — только исполнитель; сам образ уже живёт в ячейках через SICF (наш on-sheet формат образов). Естественный следующий шаг — рантайм, которому не нужно ничего, кроме байтов, и это WASM/WASI. WebAssembly-модуль достаточно мал, чтобы целиком уместиться в одной ячейке (base64 + sha256), а WASI-рантайм запускает его без docker-демона и без реестра.
wasmlet.py вытягивает модуль из ячейки, проверяет дайджест и запускает через wasmtime:
wasmlet.py --store <id-таблицы> --name hello:v1
# pulled hello:v1 from a spreadsheet cell (158 bytes), sha256 OK
# hello from a spreadsheet cell
158-байтовый модуль, одна ячейка, запущен с проверкой дайджеста против сохранённого значения — ни демона, ни pull, ни внешних зависимостей. Это рантайм-двойник SICF: образ в таблице, а это исполняет прямо из неё. Для нагрузок, которым такое реально подходит — статические бинарники, вещи масштаба scratch/alpine, WASM-модули — это по-настоящему более лёгкий путь, чем OCI/Docker, который остаётся для тяжёлых образов.
Честные пределы
Пропускная способность. Квота записи Sheets ограничивает Sheetwire низкой полосой. Это для вызовов сервис-к-сервису, не для объёмной передачи.
Консистентность. Федерация eventually-consistent; консенсуса между двумя субстратами нет.
sync— union-реконсайл,stretch— одноразовый сплит; ни один не координирует глобальную картину.Границы валидации. Сеть проверена между двумя физическими хостами.
stretchиmigrateгонялись против настоящих живых кластеров (реальный Excel-apiserver и реальный Google-Sheets-apiserver), но на одной машине; полный multi-host прод — следующая проверка.
Не запускайте на этом прод. Это пародийный фонд с настоящим кодом под капотом, и это тот самый настоящий код.
Воспроизвести
Всё в одном репозитории, и README проводит по нему от начала до конца:
Репозиторий: github.com/sncfoundation/sheeternetes-onprem
Документация: разделы README — Cross-substrate networking (Sheetwire), Stretching a cluster, A sheet-native runtime
Код:
sheetwire.py(релей),bridge.py(status/stretch/migrate/sync),wasmlet.py(WASM-рантайм)Стандарт образов, в который это встраивается: SICF — Sheet Container Initiative
Комьюнити — Slack sheetncf, Telegram t.me/stncf, LinkedIn company/sheetncf
Требования: Python 3 с google-api-python-client + google-auth, Google OAuth authorized-user JSON и wasmtime в PATH для рантайм-части. Единственное сетевое требование — исходящий HTTPS к Google с обеих сторон, без входящих портов.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.