Привет, Хабр! Наши 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 с обеих сторон, без входящих портов.
It reconciles. Like sheet.
