Привет, Хабр! Наши 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 или фаервол: входящих портов нет ни с одной стороны.

Вкладка Wire: целый TCP-разговор в ячейках
Вкладка Wire: целый TCP‑разговор в ячейках

Читаешь строки — и получаешь весь обмен стенограммой: запрос 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 проводит по нему от начала до конца:

Требования: Python 3 с google-api-python-client + google-auth, Google OAuth authorized‑user JSON и wasmtime в PATH для рантайм‑части. Единственное сетевое требование — исходящий HTTPS к Google с обеих сторон, без входящих портов.

It reconciles. Like sheet.