Есть проверенный закон вселенной: если у чего-то есть экран и хоть немного памяти, рано или поздно на этом запустят DOOM. Запускали на осциллографах, на тесте на беременность, на кофемашине и на тракторе. Мы в Sheet-Native Computing Foundation тоже решили побыть в этом уже достаточно запылившемся тренде и запустить DOOM в контейнере, упакованном в ячейки электронной таблицы. То есть образ контейнера с игрой физически лежит в ячейках, оттуда собирается обратно в Docker-образ, проверяется по sha256 и запускается.

В первой статье я рассказывал про Sheeternetes — оркестратор контейнеров, у которого весь control plane (Deployments, Nodes, Pods, Events) живёт внутри Google Таблицы или Excel, а на другом конце крутятся настоящие Docker-контейнеры. Там же мельком был упомянут наш формат образов — SICF (Sheet-Native Image Container Format). Эта статья как раз про него: как он устроен, как всё это воспроизвести у себя, как оно работает на bare metal (то есть в эксельке, а не гугл-таблице) без интернета и какие неожиданно интересные находки вылезли, когда мы начали пихать всё это дело в ячейки Google Sheets.

Спойлер: DOOM — настоящий (Chocolate Doom плюс свободный Freedoom, всё в Linux-контейнере, играется в браузере с клавиатуры).

DOOM, запущенный из образа, который лежит внутри таблицы
DOOM, запущенный из образа, который лежит внутри таблицы

Дисклеймер

Не запускайте это в проде. И — да, id Software владеет DOOM; мы упаковываем только сам движок chocolate-doom и свободный IWAD freedoom (оба живут в Debian main). Никакого пиратства, только дичь и sheet-постинг.

Что тут происходит?

Sheeternetes — оркестратор контейнеров, у которого весь control plane живёт внутри электронной таблицы: Deployments, Nodes, Pods, Events — это вкладки Google Sheets (или Excel), планировщик читает и пишет ячейки, а на другом конце крутятся настоящие Docker-контейнеры. Таблица не симулирует кластер — таблица и есть кластер. Под капотом реальная инженерия: bin-packing и failover, live-миграция, HMAC-подписи, свой формат образов (SICF), в котором образ хранится прямо в ячейках. Репозиторий: https://github.com/sncfoundation/sheeternetes.

Sheet-Native Computing Foundation (SNCF) — вендор-нейтральный дом для «sheet-native computing», пародия на CNCF, которая при этом работает: 30+ проектов (оркестрация, сеть, хранилище, наблюдаемость, CI/CD), собственный стандарт контейнеров (SCRI/SICF), верифицируемая сертификация инженеров и живые демо на настоящих таблицах. Всё — open source, комьюнити международное и англоязычное.

Зачем вообще образу жить в таблице

Обычный контейнер работает так: в манифесте написано image: nginx:alpine, kubelet идёт в реестр (Docker Hub, ghcr.io), тянет слои, разворачивает, запускает. Sheeternetes так тоже умеет — это режим по умолчанию, полная совместимость с K8s-экосистемой. Байты образа приходят из реестра, таблица держит только желаемое состояние.

Но есть второй режим — еретический. Что, если реестра нет вообще, а образ хранится прямо в таблице? Тогда таблица становится не только control plane, но и registry. Полностью air-gapped: одна "книга" — и кластер, и хранилище образов. Именно это описывает наш стандарт SICF (Sheet-Native Image Container Format), и именно на нём работает SheetDoom.

Идея простая:

  • Слой образа — это .tar-архив. Берём его байты, кодируем в base64, режем на куски и складываем в ячейки вкладки Layers, адресуя по sha256-дайджесту.

  • Манифест образа (имя, конфиг, список слоёв) — строка на вкладке Images.

  • Puller собирает слои обратно из ячеек, склеивает, проверяет дайджест, разворачивает и docker load-ит.

Content-addressable storage, только вместо блоб-стораджа — Google Sheets. Тот же принцип, что в OCI, тот же sha256, просто носитель максимально неуместный.

SICF: OCI-образ ездит в таблицу и обратно без потерь
SICF: OCI-образ ездит в таблицу и обратно без потерь

Собираем настоящий DOOM и прячем его в таблицу

Теперь самое интересное — по шагам, всё воспроизводимо. Понадобится Docker, python3 с openpyxl, и две наши репы рядом: sheetdoom (Dockerfile игры) и sci (там лежит sheetbuild — референсный инструмент SICF).

Сам образ — честный Linux-DOOM. Вот весь Dockerfile:

FROM debian:stable-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      chocolate-doom freedoom \
      xvfb x11vnc fluxbox novnc websockify tini ca-certificates \
 && rm -rf /var/lib/apt/lists/*
COPY start.sh /usr/local/bin/start.sh
RUN chmod +x /usr/local/bin/start.sh
EXPOSE 8080
ENTRYPOINT ["/usr/bin/tini","--"]
CMD ["/usr/local/bin/start.sh"]

start.sh поднимает виртуальный дисплей (Xvfb), оконный менеджер (fluxbox), сам движок и мост VNC → web (noVNC), чтобы играть в браузере:

export DISPLAY=:0
Xvfb :0 -screen 0 1024x768x24 &
fluxbox &
/usr/games/chocolate-doom -iwad /usr/share/games/doom/freedoom1.wad -window -geometry 1024x768 &
x11vnc -display :0 -forever -shared -nopw -quiet &
exec websockify --web /usr/share/novnc 8080 localhost:5900

(Небольшая грабля, на которой я потерял полчаса: chocolate-doom в Debian ставится в /usr/games, которого нет в PATH у неинтерактивной оболочки контейнера. Отсюда чёрный экран. Лечится полным путём к бинарю — что и видно выше).

А дальше — фокус. Собираем образ, пакуем его целиком в cluster.xlsx, удаляем локально, поднимаем обратно из таблицы и запускаем. Это весь demo.sh:

# 1. собрать настоящий DOOM и сохранить в tar
docker build -t doom:shareware .
docker save  doom:shareware -o /tmp/doom.tar

# 2. упаковать ВЕСЬ образ в cluster.xlsx (слои -> base64 в ячейках, адресация по sha256)
python3 ../sci/tools/sheetbuild.py import /tmp/doom.tar --name doom:shareware --store cluster.xlsx
python3 ../sci/tools/sheetbuild.py ls --store cluster.xlsx

# 3. удалить локальный образ — теперь он существует ТОЛЬКО в таблице
docker rmi doom:shareware

# 4. пересобрать образ ИЗ ТАБЛИЦЫ (каждый слой проверяется по sha256)
python3 ../sci/tools/sheetbuild.py export doom:shareware --store cluster.xlsx --out /tmp/back.tar
docker load -i /tmp/back.tar

# 5. запустить DOOM, материализованный из таблицы
docker run --rm -p 8080:8080 doom:shareware
# открыть http://localhost:8080/vnc.html -> Connect -> rip and tear

Ключевой момент — шаг 3. После docker rmi образа локально нет. Он существует только как base64 в ячейках cluster.xlsx. Шаг 4 достаёт его обратно, и docker load не ругается, потому что байты бит-в-бит те же — это гарантирует проверка дайджеста внутри sheetbuild export. Если бы хоть одна ячейка побилась, экспорт упал бы с ошибкой sha256, а не отдал бы битый образ. Round-trip import → export честный именно потому, что оба конца адресуют контент одним и тем же sha256:.

И вот вы играете в DOOM, который только что собрался из электронной таблицы.

Тот же трюк, но внутри кластера

Запустить руками через docker run — это доказательство концепции. Но настоящая цель — чтобы образ из таблицы шедулил и запускал сам кластер. В Sheeternetes это выглядит так: в манифесте вместо обычного имени образа пишем sicf:-схему.

{
  "deployments": [
    { "name": "doom", "image": "sicf:doom:shareware", "replicas": 1, "cpu_req": 200, "mem_req": 128 }
  ]
}

Поднимаем on-prem кластер (это отдельная редакция sheeternetes-onprem, которая работает поверх .xlsx вообще без интернета) и применяем манифест:

# терминал 1:  apiserver поверх cluster.xlsx
WORKBOOK=cluster.xlsx TOKEN=secret python3 apiserver.py
# терминал 2:  kubelet — этот хост становится нодой
./kubelet.sh
# терминал 3:
./skctl apply doom.json      # image: sicf:doom:shareware
./skctl get pods             # doom-1  Running

Что произошло под капотом: планировщик положил под на ноду, kubelet увидел префикс sicf: и вместо похода в Docker Hub полез в ту же таблицу — вытащил слои из ячеек, проверил каждый sha256, сделал docker load и запустил контейнер. Ни одного внешнего запроса. Хранилище образов, реестр, планировщик и желаемое состояние — всё в одной книге.

Резолвинг sicf:-образа — это буквально несколько строк в kubelet:

case "$image" in
  sicf:*) image="$(python3 "$here/sicf.py" "$WEBAPP_URL" "$TOKEN" "$image")" \
            || { echo "[kubelet] FAILED to resolve $image"; continue; } ;;
esac

Дальше — обычный docker run с полученным локальным тегом. Исполнение всегда на ноде; таблица хранит образ и шедулит под, но сама ничего не исполняет.

Кластер как вкладки таблицы; тот же контракт, что и в облаке
Кластер как вкладки таблицы; тот же контракт, что и в облаке

Находки: что происходит, когда всерьёз пихаешь шиттейнеры в Google Sheets

Вот тут началось самое поучительное. Одно дело — Excel-файл на диске (там openpyxl пишет ячейки, и всё предсказуемо). Другое дело — живая Google Таблица, у которой свой характер. Я перенёс SICF-хранилище в Google Sheets и по пути собрал разные грабли, каждая из которых оказалась маленьким уроком про распределённые системы.

1. Google Sheets молча портит длинные строки в одной ячейке

Первая версия писала весь base64-слой в одну ячейку. Формально можно: документация обещает 50 000 символов на ячейку. На практике где-то после ~2000 символов Google Sheets начинает молча коверкать содержимое длинной строки — на чтении обратно приходят не те байты. Не ошибка, не обрезка с предупреждением, а именно тихая порча. Для CAS это смертельно: склеиваешь слой, считаешь sha256 — не сходится, export падает.

Лечение — шардирование. Режем base64 на куски по ~500 символов, каждый в свою ячейку, и склеиваем на чтении. После этого дайджест сходится, слой собирается, контейнер поднимается.

Почему слои режутся на ячейки по ~500 символов
Почему слои режутся на ячейки по ~500 символов

Вот как это выглядит на реальном примере. Я импортировал в Google-таблицу крошечный образ hello-world (слой ~2.6 КБ). Его единственный слой лёг в вкладку Layers девятью кусками по 500 символов:

digest

ordinal

media_type

data

sha256:58dee6a4…cfb6d6

0

application/vnd.oci.image.layer.v1.tar

H4sIAAAAAAAA… (500 симв.)

sha256:58dee6a4…cfb6d6

1

(500 симв.)

sha256:58dee6a4…cfb6d6

8

(хвост)

Puller читает куски по ordinal, склеивает, считает sha256 от собранного .tar, сверяет с digest из строки — и только потом отдаёт байты рантайму. Тот же слой, что был у Docker, ровно те же байты.

2. Настоящий предел ячейки — не 50 000, а тысяча

После порчи я перестал доверять документированным лимитам и стал мерить сам. Практический безопасный размер ячейки для двоичного base64 в Google Sheets — около 1000 символов, а не 50 000. Мы держим консервативные ~500 с запасом. sheetbuild даже выносит это в переменную окружения SICF_MAX_CELL (по умолчанию 30 000 — безопасно для .xlsx, где лимит Excel 32 767), но для Google-бэкенда режем куда мельче.

3. Реальный потолок размера образа — это не ячейки, а квоты

В книге Google Sheets 10 млн ячеек. Кажется, много. Но при шардировании по 500 символов мегабайт образа — это ~2000 ячеек только под данные. Настоящий образ DOOM (Debian + X + noVNC + движок) — пара сотен мегабайт, а это уже тысячи и тысячи ячеек, и файл начинает ощутимо тяжелеть. Плюс у Google Sheets API есть квоты на запись (порядка 60 запросов в минуту на пользователя), так что заливка большого образа — это ещё и упражнение в батчинге и терпении.

Вывод: нативный SICF-режим в Google Sheets годится лишь для маленьких образов. Статические Go/Rust-бинарники, scratch/alpine, и особенно WASM-модули (ложатся идеально). Полноценный DOOM отлично живёт в .xlsx на диске (там нет сетевых квот), но в облачной таблице он — гость тяжёлый. Сколько именно образов влезет в Excel, пока он не взмолится, мы меряем всерьёз в sci#5 — это отдельное нагрузочное тестирование, и оно того стоит.

4. Бонус: конкурентное редактирование внезапно работает

Приятный сюрприз. Google Sheets под капотом использует operational transformation для совместного редактирования, и это неожиданно хорошо ложится на наш кейс. Кластерную таблицу могут одновременно открыть на чтение десятки человек, и живой running-state (поды приезжают-уезжают, ноды меняют статус) обновляется у всех сразу. Мы наблюдали ~100 одновременных «зрителей» без деградации. Получился бесплатный многопользовательский дашборд — тот случай, когда еретический субстрат внезапно даёт фичу, которую в нормальной системе пришлось бы строить руками.

Про размер

Полный Linux-DOOM в base64 — это тысячи ячеек. Под лимит 10 млн ячеек влезает, но файл тяжёлый, и Excel/Sheets это чувствуют. Для более бодрого демо минимальная сборка (фреймбуфер или ASCII-DOOM) ужимается в разы, а сам WAD — всего несколько мегабайт. Но мы специально собрали тяжёлый, настоящий вариант: хотели доказать, что в ячейках живёт не какая-то урезанная подделка, а полноценный образ с целым Linux-окружением. Живёт. Собирается. Запускается.

Что это доказывает (кроме того, что мне пора лечиться)

За абсурдом здесь прячется совершенно нормальная инженерия, и на ней удобно объяснять серьёзные вещи:

  • Content-addressable storage. Почему у слоёв образа есть дайджесты и почему это не украшение: единственная битая ячейка ловится на sha256 до того, как испортит рантайм.

  • Registry — это просто хранилище блобов с адресацией по контенту. Мы заменили блоб-сторадж на ячейки и получили рабочий реестр. Docker Hub делает то же самое, только блобы у него в S3, а не в Google Sheets.

  • Air-gapped дистрибуция. Одна книга = кластер + реестр. Ни одного внешнего запроса, и это ровно то, что нужно в закрытых контурах (у нормальных людей — Harbor в изоляции, у нас — .xlsx).

  • Пределы носителя решают всё. Документированный лимит и практический лимит — разные числа. Меряй, а не верь на слово.

Ничего из этого не нужно в проде. Но как способ по-настоящему понять, что такое OCI и CRI изнутри, — на удивление рабочий. И да, теперь у меня есть DOOM, который reconciles.

Ждите новых новостей и статей из мира Sheet-Native!

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

Всё это — open source и живое. Комьюнити международное и англоязычное, подключайтесь откуда угодно.

It reconciles. Rip and tear.