Первый продакшен Sheeternetes: запустили телеграм-бот, компьют и данные которого живут целиком в Google-таблице

Sheeternetes — оркестратор контейнеров, в котором роль etcd играет Google-таблица, а цикл управления написан на Apps Script. До сих пор он оставался шуткой и учебной моделью: таблица планировала и лечила контейнеры, но для работы самих контейнеров необходим был какой-то сторонний компьют, воркеры. Но вчера нам удалось впервые запустить полноценную нагрузку непостредственно в таблицах.

У меня есть бот KUBER KOMBAT, который проводил викторины в одном Kubernetes-чатике. Так-то он жил в хетцнере, но недавно хетцнер отключали за неуплату и появилась идея запустить его в каком-то бесплатном окружении под управлением одного из моих кубернетесов. Выбор пал на Sheeternetes, птому что остальные проекты требовали реальных нод или виртуалок. Конечно, можно было просто собрать прилодения на AppScript и пользоваться им, но мы пошли более сложным путем и собрали Sheeternetes-под, а все данные и состояние кластера также положили в ячейки гугл-таблицы (в том числе видео и картинки). В итоге код этого пода хранится в ячейках, исполняет его сама таблица, видеоролики лежат в ячейках в форме base64, а голоса участников записываются строками в отдельную вкладку.

Расскажу, почему для этого пришлось научить control plane самому исполнять поды, какие ограничения Google-таблиц мы обнаружули в процессе экспериментов, как устроены хранилище в ячейках и подсчёт голосов и какую ошибку словила таблица уже после позеленевших тестов.

Зачем вообще запихивать телеграм-бота в таблицу

Sheeternetes и его соседи по фонду SNCF начинались с шутки в чате, но за пару месяцев стали полигоном для исследования того, насколько пластичен Kubernetes. И таблицаы для такого исследования подходят отлично. В них нельзя использовать готовые библиотеки, а значит каждую концепцию Kubernetes приходится собирать заново из ячеек, формул и пары десятков функций Apps Script. Кроме того, всё, что собрано, видно невооруженным глазом: состояние кластера можно буквально открыть в браузере и прочитать.

До этой истории в Sheeternetes не крутилось ни одной нагрузки, от которой что-то бы зависело. Контейнеры с nginx и DOOM хорошо смотрятся на демонстрации, но никто не расстроится, если они упадут. А тут готовые коммитменты, SLA и всё такое. Если бот упадет посреди баттла, это будет заметно (хотя и никто не пострадает).

Чтобы протесрировать идею, пришлось собрать новый сезон баттла и в итоге прогнать его целиком несколько раз в своем закртом чате, где кроме этого бота есть только я сам (не гонять же эксперимент на готовых данных, это неспортивно). Мезханика простая: есть семь тикетов-опросов, по одному в день, есть мультики с подводкой к этим тикетам, есть ежедневное подведение итогов и правильные ответы, а также финальное подведение итогов сезона и финальный закрывающий мультик. Предыдущая версия бота была обычным процессом на Python в Kubernetes-кластере внутри тенанта Cozystack: этот процесс забирал обновления из Telegram, а состояние хранил в файле на диске.

Было и стало: бот в Kubernetes и тот же бот подом в таблицеБыло и стало: бот в Kubernetes и тот же бот подом в таблице

Что нужно боту

Прежде чем что-то переносить, я составил список того, что вообще требуется от платформы, чтобы на ней мог жить такой бот. Получилось одиннадцать пунктов, и все они знакомы любому, кто запускал сервис в Kubernetes.

Боту нужно состояние, которое переживает перезапуск: кто за что проголосовал, какие тикеты уже опубликованы, какие разборы выложены. Работать должен только один его экземпляр, потому что два одновременно запущенных бота опубликуют каждый тикет дважды и будут вести две расходящиеся копии таблицы участников. Токен бота должен храниться так, чтобы его не было видно ни в таблице, ни в логах. Настройки вроде идентификатора чата и режима работы должны меняться без правки кода самого бота. Восемь роликов общим объёмом почти десять мегабайт нужно где-то хранить и отдавать по запросу. Нужна возможность выполнить разовую команду, например «запускай сезон прямо сейчас», и поставить бота на паузу, не теряя состояния. Упавший бот должен перезапускаться, но не долбить Telegram в бесконечном цикле. Чтобы Telegram мог сам доставлять голоса, у бота должен быть адрес в интернете с HTTPS. Нужны логи, в том числе логи упавшего инстанса, и понятные ограничения на ресурсы и время работы.

В Sheeternetes полностью покрывались только два пункта: внешний адрес с HTTPS и настройки, причём настройки были подходящими только в on-prem-версии. Секреты существовали, но хранились в base64, то есть по сути открытым текстом, а токен передавался в строке запроса. Только один запущенный инстанс бота тоже был гарантирован лишь частично. При переезде пода на другую ноду (то есть таблицу) новый экземпляр запускался раньше, чем останавливался старый, и в течение примерно сорока секунд работали сразу оба. Если же агент на ноде умирал, а контейнер продолжал работать, кластер поднимал ещё одну копию. Для бота это означает дубликаты публикаций. Перезапуски были, но без нарастающей паузы между попытками, и случались ложные перезапуски. Томов в поде не было совсем: хранилище Sheetstor к подам не подключалось, а каталог хоста привязывал под к одной машине. Не было ни разовых задач, ни логов предыдущего контейнера.

В общем, как обычно, перед продос выяснилось, что Sheeternetes не зря содержит в своем названии слово шит, нифига он на деле не умел: для полноценного запуска пода нужен был компбьютер с Docker и Sheeternetes-агентом, а таблица оставалась только контрол плейном, пультом управления. Но это, конечно же, не наш метод.

Идея: пусть поды исполняет сам control plane

У Google-таблицы есть собственный вычислитель. Проект Apps Script, привязанный к таблице, уже работает в Sheeternetes в каяестве сервера API и цикла управления. Он умеет ходить в интернет, держать блокировку, хранить свойства, запускаться по таймеру и отвечать на HTTP-запросы как веб-приложение. Если научить его исполнять ещё и код пода, бот не будет зависеть ни от одной машины, кроме серверов Google.

Сложность в том, что Apps Script не умеет запускать контейнеры. Он исполняет JavaScript, поэтому под в таком кластере становится модулем на JavaScript, а не образом. Здесь пригодилась идея, которая давно существует в настоящем Kubernetes. Проект virtual-kubelet позволяет зарегистрировать в кластере ноду, за которой стоят не машины, а облачная serverless-платформа, и кластер скедулит на неё поды так же, как на обычные ноды. Мы сделали то же самое внутри таблицы: проект Apps Script регистрируется как нода apps-script, а планировщик и цикл управления ничего не знают о том, что поды на этой ноде устроены иначе.

Прежде чем проектировать всё это, нужно было убедиться, что Google вообще позволит так делать.

Эксперименты на настоящем Google

Все проверки ниже проходили на реальных серверах Google и реальном боте. Эмулятор Apps Script, на котором мы гоняем тесты, тут не помог бы: проверять нужно было именно поведение Google, а эмулятор повторяет только то, что мы о нём уже знаем.

Код из строки исполняется, асинхронный — нет

Код пода хранится в ячейках, значит, Apps Script должен исполнить его из строки. Я написал (ну, мой клод, конечно) маленький проект, который тестирует разные способы и возвращает результат в JSON. Обычный eval вычислил 1+2, конструктор Function собрал из строки функцию и помножил шесть на семь, а код из строки увидел встроенные сервисы Apps Script для сетевых запросов, блокировок и хранения свойств. Модуль пода в том виде, в каком мы собирались его хранить, загрузился и ответил.

Один способ Google запретил: собрать из строки асинхронную функцию нельзя, Apps Script отвечает, что вызывать .constructor у функции запрещено. Отсюда первое правило рантайма: обработчики пода должны быть синхронными. Для бота это не ограничение, потому что все запросы к Telegram в Apps Script и так синхронные.

Проба eval в Apps Script: строковый код работает, асинхронный запрещёнПроба eval в Apps Script: синхронный код работает, асинхронный запрещёнКод

function probe_() { const r = {}; const t = (k, f) => { try { r[k] = f(); } catch (e) { r[k] = 'ERR ' + e; } }; t('eval', () => eval('1+2')); t('newFunction', () => new Function('a', 'b', 'return a*b')(6, 7)); t('indirectEval', () => (0, eval)('typeof SpreadsheetApp')); t('fnSeesGlobals', () => new Function('return [typeof UrlFetchApp, typeof LockService, typeof PropertiesService].join(",")')()); t('asyncFn', () => typeof Object.getPrototypeOf(async function(){}).constructor('return 1')); t('podCode', () => { const src = 'exports.handle = (ev, env) => ({ got: ev.n * 2, secret: env.SECRET ? "set" : "unset" });'; const mod = { exports: {} }; new Function('exports', 'module', src)(mod.exports, mod); return mod.exports.handle({ n: 21 }, { SECRET: 'x' }); }); return r;}

Разрешения проекта

Apps Script сам определяет, какие разрешения нужны проекту. Он читает исходный код и, увидев, например, обращение к сервису сетевых запросов, просит владельца разрешить доступ к внешним адресам. Код пода лежит в ячейке, и в исходниках проекта его нет. Значит, сам он ни одного разрешения не запросит, и всё, что может понадобиться подам, нужно объявить в манифесте проекта заранее.

Мы убедились в этом на собственном опыте. Как только в проект добавился сетевой запрос, веб-приложение начало отвечать анонимным клиентам страницей «Access Denied», и приём сообщений от Telegram остановился. Новое разрешение отменило прежнее согласие владельца, и до повторного согласия Google просто закрыл приложение. Поэтому теперь все разрешения объявлены в манифесте сразу, а владелец кластера один раз подтверждает их при установке.

Рабочий домен против анонимных веб-приложений

Telegram доставляет обновления на адрес бота без всякой авторизации, поэтому веб-приложение должно принимать анонимные запросы. Первый кластер я поднял в рабочем аккаунте Google Workspace и получил ту же страницу «Access Denied»: администраторы домена могут запретить анонимный доступ к веб-приложениям, и в нашем домене он запрещён. Личный аккаунт Google такого запрета не имеет, поэтому кластер, в котором живёт бот, работает на личном аккаунте.

Телеграм не любит перенаправления

Самая важная находка касалась ответа на вебхук. Обычный способ вернуть текст из веб-приложения Apps Script, то есть сервис ContentService, отвечает не сразу. Результат он отдаёт не сам: сначала отвечает кодом 302 и отправляет клиента за ответом на другой адрес. Браузер и curl спокойно следуют за ним, а Telegram считает такой ответ ошибкой. Я отправил боту три сообщения, и через минуту Telegram сообщил, что вебхук ответил неправильно. В очереди стояли четыре обновления, а тестовое веб-приложение насчитало шесть доставок от Telegram на эти три сообщения.

Ответ через ContentService: 302, и Telegram копит очередьОтвет через ContentService: 302, и Telegram копит очередь

Решение нашлось в соседнем сервисе. HtmlService, предназначенный для HTML-страниц, отвечает статусом 200 сразу, без перенаправления. Я переключил обработчик на него, и очередь двинулась: каждое из пяти обновлений дошло до тестового приложения только один раз, а следом через тот же вебхук пришёл и голос в опросе.

Ответ через HtmlService: 200 и пустая очередьОтвет через HtmlService: 200 и пустая очередь

На снимке видно, что сообщение о последней ошибке в ответе Telegram осталось прежним. Telegram хранит его как историю, а о текущем состоянии говорит пустая очередь.

Позже это неудобство стало полезным инструментом. Рантайм отвечает статусом 200, когда обновление обработано, и сознательно отвечает перенаправлением, когда хочет, чтобы Telegram прислал обновление ещё раз: если обработчик упал или кластер занят. Ошибка платформы превратилась в способ попросить о повторной доставке.

Обратный путь

Последней проверкой стал исходящий запрос. Скрипт взял токен из свойств проекта, которые не видны в таблице, отправил в тестовый чат опрос через сервис сетевых запросов и получил от Telegram ответ 200 с номером сообщения. После этого стало ясно, что бот поместится в таблицу целиком: входящие голоса доставляются вебхуком, исходящие сообщения уходят сетевыми запросами, а все данные можно хранить в ячейках.

Как всё это устроено

Нода, которая живёт внутри таблицы

При установке кластер регистрирует проект Apps Script как обычную ноду с меткой и запретом runtime=script. Деплоймент с полем runtime: script автоматически получает селектор и допуск к этой метке. Поэтому планировщик ставит такие поды только на apps-script и никогда не поставит туда контейнер с nginx. Реплики, обновления, события и масштабирование работают так же, как для любого другого деплоймента.

Обязанности агента на этой ноде выполняют таймеры Apps Script: раз в минуту, если у подов есть работа на ближайшие полчаса, и раз в полчаса в остальное время. Таймер продлевает аренду ноды, при необходимости запускает цикл сверки, переводит поды в нужные фазы и вызывает у пода обработчики тех слотов, время которых подходит.

Деплоймент kombat во вкладке Deployments: образ — файл на томе, runtime — scriptДеплоймент kombat во вкладке Deployments: образ — файл на томе, runtime — script

Образ пода — это файл на томе. В строке деплоймента бота указано sheetstor://kombat-code/bot.js, то есть файл bot.js на томе с кодом. Там же перечислены расписание, настройки в JSON, имя секрета и подключённые тома. Модуль экспортирует обработчик событий и отдельные функции для разовых команд. Событий три вида: наступил слот расписания, пришёл вебхук или владелец вызвал функцию командой skctl run.

skctl видит ноду apps-script, под kombat, тома и секрет, но не его значениеskctl видит ноду apps-script, под kombat, тома и секрет, но не его значение

В кластере без Docker-нод у этой схемы нашлась одна проблема. Демонстрационные деплойменты nginx и redis, которые создаёт установка, получили поды, которым негде было запуститься. После удаления деплойментов эти поды перешли в Terminating и остались во вкладке навсегда: остановить их должен был агент ноды, а ноды у них не было. Теперь цикл сверки удаляет поды в Terminating, у которых нет ноды или если их нода пропала из вкладки нод, и пишет об этом в журнал. На боевом кластере четыре таких пода исчезли при первой же сверке после выкладки.

Вкладка Events: цикл сверки удаляет четыре пода, которые так и не получили нодуВкладка Events: цикл сверки удаляет четыре пода, которые так и не получили ноду

Только один раз

Для бота главное требование — ничего не делать дважды. Повторный разбор в чате выглядит как баг, а голос, учтённый дважды, меняет итоговую таблицу. Поэтому каждый вызов пода, будь то слот в расписании, вебхук или разовая команда, выполняется под блокировкой скрипта. В каждый момент во всём проекте работает не больше одного вызова, и это сразу закрывает вопрос о втором экземпляре бота: его просто негде запустить.

Каждый вызов работает как транзакция над документом с описанием состояния пода. Если обработчик завершился успешно, изменения сохраняются, если упал — отбрасываются. После каждого внешнего действия, например отправленного сообщения, бот сохраняет состояние сразу, чтобы последующее падение не заставило его задублировать публикацию.

Каждый запуск по расписанию бот отмечает в том же документе как выполненный. Это нужно потому, что таймер Apps Script ненадёжен: он может опоздать на пару минут, а может сработать дважды. Поэтому при каждом срабатывании бот смотрит назад на четверть часа, находит последний слот в расписании, который уже наступил, и выполняет его, только если он ещё не отмечен как выполненный. Опоздавший таймер всё равно выполнит пропущенное задание, но ничего не продублирует. Два наложившихся срабатывания или одно запоздавшее в итоге дают один вызов на слот. Я проверил это на тестовом поде: все слоты отработали всего по одному разу.

Для вебхуков реализована та же защита. Telegram повторяет доставку, если не получил ответ 200, и хранит неподтверждённые обновления до суток. Под может пометить событие ключом, и повторная доставка с тем же ключом ничего не изменит, даже если первая упала уже после того, как сохранила результат. Если одно и то же обновление трижды подряд роняет обработчик, рантайм откладывает его в отдельную вкладку, пишет событие и отвечает Telegram, что всё принято, чтобы одно битое сообщение не застопорило очередь.

Как рантайм отвечает на вебхук

Ситуация

Ответ

Что делает отправитель

обработано

HtmlService, 200 ok

считает доставленным

обработчик упал, первый или второй раз для этого тела

ContentService, 302

повторяет

то же тело упало три раза

200 dropped, тело сохраняется в отдельную вкладку, пишется событие

прекращает попытки

под на паузе или блокировка занята

302

повторяет позже

неверный ключ в адресе

200 forbidden

прекращает попытки

Адрес вебхука — адрес веб-приложения с именем деплоймента и случайным ключом. Ключ хранится в свойствах проекта и меняется командой skctl webhook kombat --rotate. Адрес принадлежит деплойменту, а не поду, поэтому переживает перезапуски и обновления кода.

Секреты вне ячеек

Токен бота не хранится ни в одной ячейке. Секреты лежат в свойствах проекта Apps Script, поэтому их нет и в истории версий таблицы, через которую Sheeternetes умеет восстанавливать кластер. Записать секрет можно командой skctl secret set, причём значение читается из стандартного ввода и не попадает в историю оболочки. Всё, что рантайм пишет в логи, события и ответы API, проходит через маскировку: значение секрета заменяется звёздочками, в том числе в URL-кодированном виде.

У этого требования была вполне практическая причина. Разбирая старого бота, я нашёл его токен в логах пода в Kubernetes. Библиотека requests во время сетевой ошибки пишет полный адрес запроса, а у Telegram токен входит в адрес. Это и был пункт про секреты из нашего списка, только обнаруженный в работающей системе.

Тома в ячейках

Хранилище Sheetstor существовало в экосистеме Sheeternetes и раньше, но к подам не подключалось. Для бота его переписали так, что файлы лежат прямо в ячейках таблицы кластера. Ячейка вмещает 50 000 символов, а API таблиц отдаёт более длинные строки как ошибку. Поэтому файл кодируется в base64 и режется на куски по 49 000 символов, а каждый кусок становится строкой в скрытой вкладке тома. Перед куском стоит знак тильды, чтобы таблица не попыталась прочитать его как число или формулу.

Вкладка тома kombat-media: вступительный ролик занимает 58 строк, следом идёт первый эпизодВкладка тома kombat-media: вступительный ролик занимает 58 строк, следом идёт первый эпизод

Вступительный ролик сезона весит 2 096 066 байт. В base64 это почти 2,8 миллиона символов, то есть 58 ячеек. Все восемь роликов сезона вместе с файлом текстов заняли 9,6 мегабайта и примерно 260 ячеек при лимите таблицы в десять миллионов. Каждый ролик загружался в таблицу от восьми до четырнадцати секунд.

Запись устроена так, чтобы сбой посередине ничего не ломал. Новая версия файла дописывается целиком как новое поколение, и только после этого под блокировкой переключается указатель поколения во вкладке реестра. Во время чтения для каждого файла берётся самая свежая версия не новее указателя и сверяется её контрольная сумма SHA-256. Если запись оборвалась до переключения, остаётся читаемой предыдущая версия, а осиротевшие строки убирает следующая запись. Код пода обновляется так же: новый bot.js становится новым поколением, следующий вызов уже исполняет его, а событие в кластере фиксирует новую контрольную сумму.

Реестр томов: поколение тома состояния бота — 88, у роликов — 11Реестр томов: поколение тома состояния бота — 88, у роликов — 11Как выглядит том изнутри и как проверить, что в ячейках лежит тот самый файл

Скрытая вкладка sheetstor- содержит по строке на фрагмент: поколение, путь, номер куска, число фрагментов, размер файла, его SHA-256 и данные. Для статьи я сверил последнюю ячейку вступительного ролика с локальным файлом: её содержимое после тильды совпадает с base64 исходного файла начиная с позиции 57 × 49 000, а SHA-256 в строке равен контрольной сумме файла на диске.

$ skctl volume ls kombat-mediaPATH SIZE GENERATION SHA256intro.mp4 2096066 2 sha256:34fd4fc054e9…episode_1.mp4 1090630 3 sha256:d31dc73efbac……

Содержимое тома с роликами сезонаСодержимое тома с роликами сезона

Ролики загружаются заранее и в рантайме не переделываются. Когда бот впервые отправляет ролик, Telegram возвращает идентификатор файла, и бот сохраняет его, привязав к контрольной сумме ролика. При повторных отправках, например в основной чат после тестового прогона, ролик не нужно снова читать из ячеек и загружать.

Голоса как журнал

Голоса приходят пачками: больше всего их сразу после публикации опроса. Если бы каждый голос переписывал весь документ состояния, вызовы выстроились бы в очередь за блокировкой. Поэтому голоса живут отдельно, в журнале, куда можно только добавлять записи. Каждый ответ в опросе — одна строка в скрытой вкладке, одна операция добавления под блокировкой и ничего больше.

Текущая картина вычисляется из журнала: для каждой пары «опрос — участник» побеждает запись с самым большим номером обновления Telegram. Повторная доставка того же обновления даёт ту же строку ещё раз и ничего не меняет. На снимке журнала это видно в десятой строке: во время тестового прогона обновление 1201 пришло повторно, а таблица результатов осталась прежней.

Журнал голосов: повторная доставка в строке 10 и два настоящих голоса из боевого прогонаЖурнал голосов: повторная доставка в строке 10 и два настоящих голоса из боевого прогона

Перед публикацией разбора бот закрывает опрос, и Telegram возвращает собственный подсчёт голосов по каждому варианту. Бот сравнивает его со своим журналом и при расхождении пишет событие в кластер, чтобы потерянный или лишний голос не остался незамеченным. Чтобы не упереться в лимит Apps Script на тридцать одновременных выполнений, вебхук бота настроен так, что Telegram держит не больше пяти соединений одновременно.

Бюджет времени

У бесплатного аккаунта Google на таймеры Apps Script отпущено девяносто минут в сутки. Первая версия рантайма на каждом холостом срабатывании читала вкладки нод, деплойментов, подов и автомасштабирования. Это занимало около 2,4 секунды и при запуске раз в минуту съедало примерно 58 минут из девяноста, ещё до того, как бот хоть что-то сделает.

Вторая версия после каждого полного прохода запоминала, когда у неё в следующий раз будет работа, и до этого момента срабатывание читало одно свойство и завершалось. По собственному счётчику рантайма такое срабатывание занимало в среднем 0,15 секунды, и простой выглядел дешёвым. Страница Executions проекта показала другое. Google считает выполнение с момента запуска, а до первой строки скрипта проходит около секунды, которую сам скрипт не видит. Холостое срабатывание на деле стоило от 0,6 до 1,5 секунды. Контрольная сверка раз в десять минут занимала от 9 до 22 секунд, потому что цикл управления переписывает вкладки подов и нод и ждёт пересчёта формулы в таблице, где лежат 9,6 мегабайта роликов. В ночь на 9 ноября спящий кластер тратил около 143 секунд в час, почти две трети суточного бюджета. Самое долгое срабатывание той ночи, 59 секунд в 04:03 UTC, оказалось обычной контрольной сверкой: ни выкладок, ни запросов к API в ту минуту не было, таблица просто отвечала медленно.

Отсюда два решения. Первое: лучшее срабатывание — то, которого не было. Поэтому ежеминутный таймер теперь включается только тогда, когда в ближайшие полчаса есть работа: под не спит или скоро проснётся, работает автомасштабирование или цикл сверки ещё не закончил дела. В остальное время кластер просыпается раз в полчаса, а продлевать аренду ноды и делать контрольную сверку ему достаточно раз в час. Как только появляется работа, ежеминутный таймер возвращается. Работой считается вызов API, ручная правка таблицы (её ловит отдельный триггер) или просьба пода разбудить его к определённому времени. Второе: контрольная сверка больше не гоняет цикл управления впустую. После каждого прохода кластер запоминает слепок вкладок с деплойментами, подами и нодами, а раз в час сравнивает с ним то, что сейчас лежит в таблице. Цикл запускается, только если кто-то поменял эти вкладки в обход API.

Бот, со своей стороны, после каждого слота сообщает рантайму, когда ему проснуться. Между вечерним тикетом и дневным разбором он спит, и всё это время кластер обходится двумя срабатываниями в час: один полный проход продлевает аренду и сверяет отпечаток, второе срабатывание читает одно свойство и завершается. В ночь на 10 ноября по собственному счётчику рантайма на час простоя уходило около двух секунд, а с учётом запуска, судя по странице Executions для таких же срабатываний, — около шести. Это меньше 3% суточного бюджета против почти двух третей до доработки. Расход за сутки, включая число холостых срабатываний, полных проходов и сверок, виден во вкладке Runtime и в команде skctl get budget. Время запуска в этих числах не учтено, так что к каждому срабатыванию стоит прибавлять около секунды.

Вкладка Runtime: сколько времени таймеров ушло за суткиВкладка Runtime: сколько времени таймеров ушло за сутки

У этой конструкции есть и ограничения. Вызовы внутри проекта выполняются строго по одному, и каждый может длиться не дольше шести минут. Таймер срабатывает примерно раз в минуту с разбросом, поэтому точность расписания — минута. Бесплатный аккаунт получает девяносто минут работы таймеров и двадцать тысяч исходящих запросов в сутки. Отправитель вебхука ждёт, пока обработчик закончит, поэтому обработчики должны быть короткими. Для бота с семью тикетами всё это с запасом укладывается в лимиты, а запустить в таком кластере сервис, который отвечает тысячам пользователей в секунду, не получится.

Как обновление из Telegram и минутный таймер доходят до пода и его данныхКак обновление из Telegram и минутный таймер доходят до пода и его данных

Боевой прогон

Для убедительности бот должен был пройти весь цикл на настоящем Telegram-чате. Для этого в коде бота есть тестовый режим. Он публикует сезон в отдельном чате с ускоренным расписанием: три тикета вместо семи и шаг в полторы минуты вместо суток. Пока в настройках деплоймента не стоит режим prod, бот откажется писать в какой-нибудь серьезный чат, даже если ему передадут этот идентификатор чата.

Тексты сезона не переписывались вручную. Их сгенерировал тот же код на Python, что и для старого бота, и выгрузил в файл на томе. Тест бота с тестовым Telegram-чатом прогоняет ускоренный сезон, проверяет защиту основного чата и сравнивает каждый отправленный текст с эталоном побайтово. Таких проверок 59, ещё 36 тестов есть у хранилища и 52 у рантайма.

skctl run kombat -- launch

Эта команда опубликовала вступительный ролик, анонс и первый тикет с опросом, а дальше бот шёл по расписанию сам. Весь сезон в ускоренном режиме занял около пяти минут.

Лог пода во вкладке Logs: строки боевого прогона, выше — размытые строки репетицииЛог пода во вкладке Logs: строки боевого прогонаТот же лог через skctlТот же лог через skctl

В чате это выглядело так же, как у старой версии бота. Сначала пришёл вступительный мультфильм с подписью, за ним анонс сезона, в подвале которого теперь написано, что бот работает в кластере Sheeternetes. Каждый тикет состоял из короткого мультфильма и неанонимного опроса под ним. Разбор приходил ответом на опрос, чтобы его было видно рядом с вопросом, а следом бот выкладывал таблицу участников. Команда /score в чате отвечала на сообщение участника, выдавая его баллы и место в общем зачёте, а последний разбор содеражл в том числе итоги баттла с именем победителя. Каждый из этих шагов оставил след в таблице: вызов в журнале пода, голос в журнале голосов, отметку о выполненном шаге в документе состояния.

После прогона команда статуса показала, что все шаги выполнены, а голоса по тикетам совпали с подсчётом Telegram: первый и второй тикеты получили по одному голосу, третий — ни одного. Расхождений сверка не нашла, у пода ноль перезапусков и ни одной ошибки.

Статус пода после прогона: все шаги выполнены, голоса учтеныСтатус пода после прогона: все шаги выполнены, голоса учтены

Цифры

Ответ на голос, то есть путь от запроса к вебхуку до ответа 200, на тестовом прогоне занимал от 2,4 до 4 секунд, а команда /score — 8,3 секунды. Время уходило не на подсчёт, а на чтение. Каждый /score трижды читал реестр томов, затем документ состояния, файл текстов сезона и весь журнал голосов. Теперь реестр читается один раз за вызов, тома подключаются при первом обращении, а код, состояние и текстовые файлы кэшируются в CacheService под ключом из отпечатка содержимого тома, так что любая запись в том сама делает старую запись кэша ненужной. Статистику голосов бот кэширует по числу строк журнала: журнал только растёт, поэтому одинаковая длина означает одинаковую статистику. На копии бота в режиме, когда Telegram не вызывается, ответ на /score сократился с 6,1–7,9 до 2,5–4,1 секунды, а ответ на голос — с 2,5–3,6 до 1,9–3,3 секунды. Оставшиеся пара секунд занимают запуск веб-приложения, чтение вкладок деплойментов и подов, без которого нельзя убедиться, что под работает, и сама запись в журнал. Разовая команда запуска сезона на репетиции работала от 23 до 39 секунд: она отправляет четыре сообщения и несколько раз сохраняет состояние.

Самым долгим срабатыванием таймера за день оказалось то, в котором бот отправлял ролики: 75 секунд. В нём таблица собирает десятки ячеек обратно в файл, проверяет контрольную сумму и загружает файл в Telegram. До предела в шесть минут на одно выполнение остаётся большой запас, а повторные отправки того же ролика идут уже по сохранённому идентификатору файла.

За 8 ноября счётчик рантайма показал 14,5% суточного бюджета таймеров. Теперь простой стоит меньше 3% бюджета в сутки, и почти весь бюджет остаётся на работу. Самый тяжёлый шаг сезона, отправка ролика, занял 75 секунд, так что запас на сезон многократный.

Кпе таблица обнаружила ошибку

Все тесты Sheeternetes работают с эмулятором Apps Script и таблиц, и в эмуляторе формульный планировщик работал безупречно. В Sheeternetes под на ноду назначает формула во вкладке подов: она перебирает ноды, проверяет метки, запреты и свободные ресурсы и пишет имя выбранной ноды в колонку chosen. Если формула возвращает ошибку, кластер переключается на запасной планировщик на Apps Script и пишет об этом событие.

На реальном кластере такое событие появлялось при каждом полном проходе. С первого прохода 8 ноября до утра 9 ноября строка «formula column returned an error -> script fallback for this pass» попала во вкладку событий 84 раза. Бот при этом ничего не заметил: запасной планировщик ставил его под на ту же ноду, и сезон отработал без сбоев.

Вкладка Events: загрузка роликов, секрет, рождение пода и та самая строка про запасной планировщикВкладка Events: загрузка роликов, секрет, рождение пода и та самая строка про запасной планировщик

Причина крылась в языке таблицы. Таблица кластера создана с русской локалью, а в локалях, где десятичный разделитель — запятая, аргументы функций разделяются точкой с запятой. Метод, которым скрипт записывает формулу в ячейку, разбирает текст по правилам локали таблицы. Формула, написанная через запятые, как принято в английской локали, в русской таблице превращается в ошибку. Эмулятор знал только английскую локаль и поэтому принимал такую формулу без возражений.

Исправление запрашивает синтаксис у самой таблицы. Один раз для каждой локали скрипт записывает в служебную ячейку =SUM(1,2). В английской локали это сумма двух чисел, и получается 3, а там, где запятая отделяет дробную часть, это одно число 1,2. По ответу скрипт понимает, какой синтаксис нужен, и конвертирует все формулы: запятые между аргументами становятся точками с запятой, а запятые между столбцами массива — обратной косой чертой. Строки в кавычках при этом не трогаются.

Колонка chosen после исправления: формула в синтаксисе русской локали выбирает ноду apps-scriptКолонка chosen после исправления: формула в синтаксисе русской локали выбирает ноду apps-script

Эмулятор теперь тоже знает о локалях: формулу, которую нельзя разобрать в заданной локали, он превращает в ошибку, как это делает Google. Весь набор тестов цикла управления в CI прогоняется ещё и в русской локали, а отдельный тест сверяет точный текст формулы для английской, британской, русской и немецкой таблиц. После выкладки исправления строка о запасном планировщике в событиях больше не появлялась, а формула в колонке chosen выбирает для пода бота ноду apps-script.

Чтобы следующую ошибку такого рода ловила не боевая таблица, в skctl появилась команда selftest. Веб-приложение кластера создаёт в Drive владельца временную таблицу для каждой локали, по умолчанию английской и русской, настраивает в ней вкладки, прогоняет цикл сверки с формульным планировщиком и формулы автомасштабирования, проверяет, что посчитали ячейки, и отправляет таблицу в корзину. Проверка идёт от имени владельца кластера, поэтому учётные данные Google не нужно хранить в CI. Иначе в GitHub пришлось бы положить либо токен кластера, который даёт полный контроль над ним, либо ключ с доступом к Drive. Первый запуск прошёл для en_US и ru_RU, а заодно для de_DE и ja_JP, по 14–16 секунд на локаль.

$ skctl selftesten_US: PASS in 14 s (trashed) ok formula syntax detected (comma) ok the formula scheduler placed every pod (no script fallback) ok the chosen column computed node names, no errors ok formula and Apps Script scheduler agree ok node_selector and taints are honoured ok autoscale formulas compute (cpu 80%, desired 5, ScalingUp) ok timestamps survive a round trip through a cellru_RU: PASS in 16 s (trashed) ok formula syntax detected (semicolon) …selftest passed

Как формула переводится в синтаксис локали

const FORMULA_SYNTAX_PROBE = '=SUM(1,2)'; // en_US: 3; decimal-comma locales: 1.2function toSemicolonSyntax(formula) { let out = '', quote = ''; const nest = []; for (const ch of formula) { if (quote) { out += ch; if (ch === quote) quote = ''; continue; } if (ch === '"' || ch === "'") quote = ch; else if (ch === '(' || ch === '{') nest.push(ch); else if (ch === ')' || ch === '}') nest.pop(); out += ch === ',' ? (nest[nest.length - 1] === '{' ? '\' : ';') : ch; } return out;}

Результат кэшируется на шесть часов для каждой локали, так что этот тест не повторяется на каждом проходе.

Эта ошибка хорошо показывает, зачем Sheeternetes нужна настоящая нагрузка. Эмулятор проверяет только то, что мы уже знаем о платформе, а живая таблица с живым ботом проверяет то, о чём мы не подумали заранее. Без бота кластер так и работал бы на запасном планировщике, и в его событиях раз в десять минут появлялась бы строка, которую никто не читает.

Что это значит

У KUBER KOMBAT теперь нет сервера, кластера и диска. Его код, ролики, состояние и голоса лежат в одной таблице, которую можно открыть и прочитать. Секрет хранится в одном месте, а каждый шаг бота виден во вкладках событий и логов. Да и в целом получилось прикольно и бесплатно:)

Исходники рантайма, хранилища и skctl открыты и вошли в релиз Sheeternetes v0.0.0.0.0.5 «Extensions → Apps Script», а доработки после первого прогона — два таймера, сверка по отпечатку, уборка подов без ноды, кэши и skctl selftest — в v0.0.0.0.0.6 «On change and every hour». Подробное описание рантайма лежит в docs/script-runtime.md, там же есть пошаговая установка кластера и пример пода, который отвечает на вебхук и считает слоты расписания.

Ссылки

  • Фонд SNCF: sncfoundation.github.io

  • Sheeternetes: github.com/sncfoundation/sheeternetes

Источник: habr.com

0 0 голоса
Рейтинг новости
1
0
Подписаться
Уведомить о
0 комментариев