Бот с расписанием всех групп вуза — в телеграме и во ВКонтакте. Держит локальную копию расписания в SQLite и отвечает из неё за доли миллисекунды, не дёргая сервер вуза на каждое нажатие кнопки.
Два статических бинаря в двух контейнерах, деплой пушем в main: на сервере нужен только докер.
┌────────────────────────────────────┐
apeks.tksu.ru ◄──┤ raspd │
2 req/s, ночью │ apeks · store · syncer · schedule │
│ HTTP API @ /run/rasp/api.sock │
└──────┬───────────────────┬─────────┘
│ │
schedule.db (SQLite WAL) │
│ │
┌────────────▼───────┐ ┌───────▼──────────┐
│ botd │ │ admind │
│ botcore · tg · vk │ │ admin │
└────────────┬───────┘ └───────┬──────────┘
│ long polling │ :8305 за caddy
api.telegram.org · api.vk.ru панель /admin
raspd — единственный процесс, который ходит к вузу. Синхронизирует расписание в фоне и отдаёт его боту по unix-сокету.
botd — сценарии и обе площадки. Не знает ни про Апекс, ни про SQLite. Адаптеры тонкие, а сценарии и кэши общие, поэтому разносить платформы по процессам незачем: платформа поднимается, если для неё задан токен.
admind — панель наблюдения. Ходит в тот же сокет, что и бот, и базу расписания не открывает: у неё по-прежнему ровно один писатель.
Почему не прокси поверх upstream: один запрос расписания группы — это 241 КБ и 0.8 с, из которых 78 % занимает каталог групп, приезжающий неизменным в каждом ответе. Зато весь датасет вуза (675 групп × ~10 месяцев) в нормализованном виде — это десятки мегабайт, которые целиком помещаются в page cache. Отсюда и решение: не кэшировать чужие ответы, а держать полную локальную копию.
Побочные выгоды: бот продолжает работать, когда Апекс лежит (с честной пометкой о свежести данных), и открывается доступ к запросам, которых upstream не умеет в принципе — расписание преподавателя, свободные аудитории, уведомления об изменениях.
| Пакет | Ответственность |
|---|---|
apeks |
HTTP-клиент вуза: токен, троттлинг, ретраи, разбор JSON. Единственное место, знающее формат Апекса |
store |
SQLite: схема, миграции, репозитории |
syncer |
Планировщик фетчей, singleflight, хэши, детект изменений, уборка |
schedule |
Домен: сборка дня и недели, окна, «сейчас». Без единого I/O |
api |
Контракт raspd ↔ боты поверх unix-сокета |
botcore |
Сценарии, тексты, вёрстка сообщений. Платформо-независим |
tg |
Тонкий адаптер телеграма |
vk |
Тонкий адаптер ВКонтакте |
admin |
Панель: вайтлист, метрики машины, история, страница |
logbuf |
Кольцевой буфер последних жалоб демона |
Два правила, которые держат это честным: schedule не делает I/O, botcore не
знает про платформы. Формат upstream заперт в apeks — когда вуз обновит
движок, правится один пакет.
Второе правило окупилось на VK: адаптер получился на четыре файла, а сценарии, тексты и вёрстка не изменились ни на строку.
Реверс живых данных дал четыре вещи, которых нет в документации виджета.
1. Занятие адресуется одним из четырёх способов, и от этого зависит и фильтрация, и подпись в сообщении:
| Режим | Признак | Пример groupName |
|---|---|---|
| Группа | group_id заполнен, flow пуст |
С-ЛД-12 |
| Поток | flow заполнен |
Поток 1 (С-ЛД-11, С-ЛД-12, …) |
| Подгруппа | subgroup_id заполнен, group_id пуст |
С-ЛД-12/2 |
| Сборный поток | superflowGroupsIds непуст |
перечисление групп |
2. Потоковое занятие приходит с одним и тем же id в ленты всех групп
потока, а его собственное group_id указывает на якорную группу, которая
может не совпадать с запрошенной. Проверено: все 26 потоковых занятий группы
232 лежат в ленте группы 231 с теми же идентификаторами.
Поэтому занятия нельзя раскладывать по lessons.group_id, как предполагал
первоначальный набросок схемы: вторая запись затирала бы первую. Занятие
хранится один раз, а принадлежность лентам вынесена в group_lessons (M:N).
3. subgroup_id — глобальный по вузу идентификатор (334, 335), а не
порядковый номер внутри группы. Прошлая версия бота вытаскивала подгруппу
суффиксом из строки С-ЛД-12/1 и работала ровно для одной зашитой в код
группы.
4. В каталоге встречаются одноимённые записи групп. Раньше это было
системным правилом: одна запись относилась к нынешнему названию, вторая — к
названию следующего учебного года. Вуз перенумеровывает группы ежегодно, и
первая цифра номера означает курс: второкурсники «С-ЛД-12» на следующий год
станут «С-ЛД-22». В августе 2026 обе записи С-ЛД-22 ещё были активны, имя у
них совпадало до буквы, но занятия нового года были ровно у одной:
| Запись | Курс в дереве | Учебный план | Занятий за 09.2025 / 02.2026 | Занятий за 09.2026 |
|---|---|---|---|---|
С-ЛД-22 id=39 |
2 | 302 | 94 / 65 | 0 |
С-ЛД-22 id=545 |
1 | 84 | 0 / 0 | 96 |
Тогда из 675 активных групп были задвоены 209 имён (211 лишних записей). На
1 сентября 2026 КГУ почистил почти весь каталог: живой API отдаёт 412 активных
групп с 411 уникальными именами. Задвоенным осталось только
Мз-ПППЭ-21 (id=789 и id=349). У С-ЛД-22 теперь одна запись — id=545
на втором курсе; id=39 из дерева удалён.
Поэтому механизм нельзя удалить целиком. Пока обе записи есть, лишняя
помечается признаком shadowed и не показывается в поиске и обзоре. Когда
upstream удаляет старую запись, она становится неактивной, а привязанные к ней
пользователи автоматически переезжают на единственную оставшуюся одноимённую
группу; подгруппа переносится по имени. Каталог обновляется сразу при запуске,
чтобы после деплоя устаревшая строка «Копия группы» не жила до суточного
фонового прохода.
Ловушка в том, что роли записей переворачиваются раз в год. Внутри
учебного года настоящая запись — та, чей курс совпадает с первой цифрой имени.
Но в августе вуз перенумеровывает имена, не трогая курсы, и правило
обращается: с сентября 2026 расписание С-ЛД-22 лежит у записи с курсом 1, а
у записи с курсом 2 — той самой, что весь прошлый год была настоящей, — ноль
занятий. Проверено на живых данных: из 12 задвоенных имён, у которых
расписание на сентябрь 2026 заведено, во всех 12 случаях оно у записи с курсом
на единицу меньше первой цифры имени.
Поэтому решение принимается по занятиям, а не по соглашению об именах: занятия начиная с текущего месяца → у кого расписание тянется дальше → и только в последнюю очередь курс, соответствующий имени с поправкой на фазу каталога. Саму поправку тоже считают данные: среди всех групп с актуальными занятиями берётся смещение курса, которое встретилось чаще. Решение пересчитывается после каждого обновления каталога и после каждой записи расписания, так что и следующая августовская перенумерация разрулится сама.
Остаётся случай, когда занятий нет вообще ни у кого — свежая установка, пока не прошёл первый обход. Угадать тут нечего, и раньше код всё же угадывал: голосов за смещение нет, значит ноль, значит прошлогодний расклад. В августе это ровно наоборот, и первый же деплой спрятал настоящую запись у каждого из 209 задвоенных имён — половина каталога исчезла из поиска и обзора. Теперь без свидетельств не прячется никто: видны обе копии, и ни одна группа не пропадает. В обзоре это даже не заметно — копии лежат на разных курсах.
Свести окно к минутам помогает второе изменение: первый полный обход идёт сразу после установки, а не ждёт трёх ночи. Каталог без расписания всё равно бесполезен.
Когда свидетельства есть, но решение всё же промахнулось, в настройках бота есть строка «🔀 Копия группы» (появляется только у тех, у кого две активные одноимённые записи действительно остались) с состоянием расписания каждой: «расписание идёт» или «закончилось 10.06.2026». Переключение переносит подгруппу по имени: у копий названия подгрупп совпадают, а id различаются.
Плюс мелочи, которые ломали бы вывод: lessonTimesEnabled задаёт учебные дни
данными (воскресенья в ответе просто нет — хардкодить его не нужно), а поле
classroom иногда приезжает с кусками вёрстки внутри
(<i class="fa fa-times"></i> без ауд.), которые обязаны быть срезаны до того,
как попадут в сообщение с parse_mode=HTML.
Что изменилось по сравнению с прошлой версией:
-
Выбор любой группы вуза — поиском по названию (
слд12,с лд 12,С-ЛД-12находят одно и то же) или обзором по институтам и курсам. Раньше группа была зашита в код. -
Настоящие подгруппы с их именами из API вместо жёстких «Подгруппа 1/2».
-
Навигация по абсолютной дате. В
callback_dataкнопок лежит дата, а не смещение от «сегодня». Это убирает целый класс багов: прошлая версия считала смещения от текущего дня и отдельно подпихивала пропуск воскресенья, из-за чего стрелка «вперёд» срабатывала со второго раза. -
Чужая группа — просмотр, а не переезд. Написал название любой группы — показывается её расписание с пометкой и кнопкой «✅ Сделать моей», а собственная привязка, подгруппа и адресат рассылки остаются нетронутыми. Группа едет в
callback_dataвместе с датой, поэтому чужую неделю можно листать сколько угодно, не заводя состояния диалога. Раньше любой выбор группы молча переписывал настройки — при том что/helpпрямо приглашал «просто напиши название другой группы». -
Компактная неделя — одна строка на пару вместо шести полноразмерных дней, склеенных в простыню. Пустой учебный день остаётся в выдаче строкой «занятий нет»: молча пропущенная среда неотличима от среды без пар, а спрашивают как раз об этом.
-
«Окна» между парами считаются по пропущенным слотам сетки звонков, а не по минутам: перемена в 20 минут окном не считается.
-
Обрезанный поиск называет себя обрезанным. Восемь результатов из двадцати, показанные молча, читаются как «моей группы бот не знает».
-
Длинный курс листается, а не обрезается. На первом курсе медицинского института 42 группы — больше, чем помещается на экран мессенджера. Раньше хвост списка отбрасывался с подписью «не поместилось»: выбрать такую группу кнопкой было нельзя вовсе. Теперь список идёт страницами со стрелками, и номер страницы виден в заголовке.
-
«Сейчас / следующая пара» — с учётом часового пояса пользователя.
-
Уведомления — отдельная вкладка настроек вместо одной кнопки «утренняя рассылка». Главный тумблер, утреннее сообщение (расписание на сегодня, с 00:00 до 08:30), вечернее (следующий учебный день, с 09:00 до 23:30), правки расписания — каждое включается порознь. Время пишется текстом:
7:15,7.15,715и7понимаются одинаково, а рядом лежат кнопки с привычными значениями. Ради этого одного ввода заведено единственное во всей системе состояние диалога — и живёт оно в записи пользователя, а не в памяти botd, так что перезапуск не роняет человека на середине настройки. -
Новость о правке — только тем, у кого правили, и только про будущее. Отпечаток содержимого считается по ленте каждой группы отдельно, поэтому перестановка пары у С-ЛД-22 не разбудит С-ЛД-23; потоковую лекцию видят все группы потока — и все они узнают, потому что у каждой изменилась своя лента. Второй отпечаток считается только по дням, которые ещё не прошли: вуз постоянно дописывает темы и меняет аудитории задним числом, и новость про пару двухнедельной давности — чистый шум. Граница окна хранится вместе с отпечатком: сравнивать их можно только на одной и той же дате, иначе первый же синк после полуночи разослал бы «расписание изменилось» всей базе.
-
Новость называет дни и умеет показать «до/после». «Расписание изменилось» без единой подробности заставляет открывать месяц и искать разницу глазами. Поэтому в момент правки raspd сравнивает старый месяц с новым день за днём, кладёт задетые числа прямо в сообщение и откладывает снимок каждого такого дня в
change_days. По кнопке «подробнее» бот показывает «было / стало» одного дня: пропавшие пары помечены➖, появившиеся —➕, совпавшие идут точкой.Дни в тексте — потому что «меня это касается?» самый частый вопрос и он должен решаться без нажатий. Сравнение за кнопкой — потому что его открывают единицы, и платить за них запросом на каждого адресата рассылки нельзя. Снимок хранится только «до»: «после» и так лежит в базе, а его копия устарела бы первой. Живут снимки трое суток — дольше, чем сообщение в очереди, чтобы кнопка работала и назавтра; когда снимок истёк, бот честно говорит, что уже не помнит, вместо того чтобы выдавать нынешний день за неизменённый. Если правка задела чужую подгруппу, сравнение так и скажет: два одинаковых списка человек сличать не должен.
-
Отписка от правок — в самой новости. Эта рассылка включена по умолчанию и приходит без спроса, поэтому под новостью стоит «🔕 Не сообщать о правках»: за ней человек не пойдёт в «Настройки → Уведомления», он либо стерпит, либо заблокирует бота — и утреннее расписание уедет вместе с новостями. Кнопка не переключатель, а именно «выключить»: сообщение живёт в переписке вечно, и нажатие спустя месяц не должно молча вернуть то, от чего человек отписался (нужное состояние едет в самой кнопке —
ncs:offиncs:on). Подтверждение приходит отдельным сообщением, а не правкой новости: иначе вместе с ней пропала бы кнопка «что изменилось» — ровно та, ради которой переписку и открыли. В подтверждении сказано, что остальные рассылки на месте, и лежит кнопка возврата; она заодно поднимает главный тумблер, если тот успел выключиться. -
Пустые дни: молчание, объяснённое один раз. Раньше бот писал и в дни без пар. Теперь такие сообщения выключены по умолчанию — «занятий нет» никто не просил, — но в первый же пустой день приходит разовое объяснение с кнопкой «присылать и в пустые дни». Без него молчание бота неотличимо от поломки, а с ним человек узнаёт о настройке ровно тогда, когда она ему понадобилась.
-
Пометка о свежести вместо молчания, когда сервер вуза недоступен.
-
Нет кнопки «Обновить данные» — она была заплаткой над кэшем. Данные обновляются сами, а пользователю не нужно знать про существование кэша.
-
В поиске нет пустых двойников групп — см. находку 4 выше, а если автоматика всё же ошиблась, копия переключается руками из настроек.
Три отличия, из-за которых адаптер VK длиннее телеграмного, — и все они спрятаны в нём, а не протекли в сценарии:
- Разметки в сообщениях нет. Ни HTML, ни markdown, ни
parse_mode, а юникодного жирного для кириллицы не существует — передать структуру можно только самими символами. Поэтому адаптер теги не выбрасывает, а отрисовывает: жирный и курсив уходят, а<blockquote>становится полоской│. Без неё заголовок дня ничем не отличался от строки с парой, и неделя читалась как ровная простыня. - Клавиатур две, и они разные. Инлайн-клавиатура живёт внутри сообщения и
позволяет его редактировать — но вмещает всего 10 кнопок в 6 строках. Нижняя
панель вмещает 40, зато она одна на диалог и к сообщению не привязана.
Поэтому кнопки едут инлайном, пока помещаются, а списки институтов и групп —
на панель; заняв её, адаптер держит там и следующие экраны, пока первый
ответ с меню не вернёт панель на место. Список, который не влезает и туда, —
42 группы первого курса медицинского института — листается страницами:
botcore режет длинные списки по 16 групп и 9 институтов, оставляя строку под
«
◀️ Стр. N / Стр. M▶️ », а номер страницы едет в callback-данных меткой «pN». Размер страницы посчитан по панели ВКонтакте — самому тесному из двух экранов; телеграму те же страницы не жмут, а расходиться раскладкам ради него значило бы завести в сценариях знание о платформе. Обрезка с подписью «не поместилось» осталась в адаптере последней сеткой: лимиты считаются в двух местах, и разъехаться они могут молча. - Подпись кнопки — не больше 40 символов, и на длинной ВКонтакте отвергает клавиатуру целиком, а не одну кнопку. «Институт искусств и социокультурного проектирования» — это 51, поэтому адаптер режет подписи по границе слова.
- Про свой retry_after ВКонтакте молчит. Паузы для рассылки назначаются адаптером, а «недоставляемых» адресатов он узнаёт по кодам 900/901/902.
- Расписание — для подписчиков сообщества (
VK_REQUIRE_SUBSCRIPTION, по умолчанию включено). Незнакомец получает просьбу подписаться и кнопку «Я подписался»; сценарии открываются после проверки. Дословная реализация —groups.isMemberперед каждым ответом — стоила бы лишнего запроса на каждое нажатие: это и десятки миллисекунд к задержке, и треть от лимита в 20 запросов в секунду, одного на все методы ключа сообщества. Поэтому бот держит список подписчиков целиком и отвечает по нему из памяти: на пути человека сети нет вовсе. Список приезжает полным обходом (groups.getMembers, тысяча id за запрос) раз в сутки — несколько запросов в день на весь бот, сколько бы человек им ни пользовалось, — а между обходами его правят событияgroup_joinиgroup_leave. Единственный запрос про одного человека делается по кнопке «Я подписался», где его и ждут. - События — ускорение, а не источник истины. Полагаться на
group_leaveнельзя: длинный опрос после любого обрыва поднимается с новымts, то есть события за время обрыва не доезжают; сервер и сам просит начать заново, отвечаяfailed1 и 3; а рестарт при деплое обнуляет память процесса. Ровно поэтому свежесть держит полный обход, и расхождение «в кэше подписчик, а на деле нет» не живёт дольше суток независимо от событий. Точнее и не нужно: отписка сразу после подписки — редкость, а тот, кто отписался ради того, чтобы через день упереться в ту же просьбу, скорее перестанет пробовать, чем найдёт в этом лазейку. Включать типы событий в настройках сообщества руками не нужно — их включает самbotdпо зарегистрированным обработчикам, для чего и требуется право «Управление сообществом».
Long polling здесь тоже свой: библиотека выходит из опроса на первой же ошибке, включая обычный обрыв связи, поэтому цикл поднимается заново сам.
Локально:
APEKS_TOKEN=<токен> make run-raspTELEGRAM_TOKEN=<токен> VK_TOKEN=<ключ сообщества> make run-botТокены независимы: с одним поднимется одна площадка, без обоих botd откажется
стартовать. Требование подписки на сообщество на время отладки снимается
VK_REQUIRE_SUBSCRIPTION=false.
Сокет отлаживается обычным curl:
curl -s --unix-socket run/api.sock "http://rasp/schedule/day?group=232&date=2025-09-19"CI в .github/workflows/deploy.yml: на каждый пуш в main Actions гоняет
go vet и тесты, собирает docker-образ (ghcr.io/uwuocha/dairy-303) и по SSH
говорит серверу стянуть его и перезапустить контейнеры. VPS ничего не
компилирует — на гигабайте это существенно: пиковые сотни мегабайт сборки
modernc.org/sqlite остаются на раннерах GitHub.
Вместе с образом уезжает и compose.yaml, поэтому правка лимитов или тома
доезжает до прода тем же пушем. Секреты этим путём не ездят вовсе: .env
живут только на сервере.
Разовая настройка сервера:
sudo mkdir -p /opt/rasp/deploy && sudo chown -R $USER /opt/rasp
# положить сюда из репо: compose.yaml и deploy/*.env по образцу *.env.example
scp compose.yaml user@vps:/opt/rasp/
scp deploy/rasp.env.example user@vps:/opt/rasp/deploy/rasp.env
scp deploy/bot.env.example user@vps:/opt/rasp/deploy/bot.env
scp deploy/admin.env.example user@vps:/opt/rasp/deploy/admin.env
ssh user@vps 'chmod 600 /opt/rasp/deploy/*.env'Заполнить на сервере APEKS_TOKEN в rasp.env, токены площадок в bot.env
(TELEGRAM_TOKEN, VK_TOKEN — что именно нужно каждому, написано в
bot.env.example) и ADMIN_ALLOW_IPS в admin.env — без него панель не
поднимется. Дальше первый запуск:
cd /opt/rasp
# GHCR приватный. Постоянный PAT на сервере не нужен: при автодеплое workflow
# сам логинится одноразовым GITHUB_TOKEN прогона. Руками, для первого раза, —
# PAT (classic, scope read:packages):
# docker login ghcr.io -u UwUOcha
docker compose pull && docker compose up -dРазовая настройка репозитория — три секрета в Settings → Secrets and variables → Actions:
| Секрет | Что это |
|---|---|
DEPLOY_HOST |
адрес VPS |
DEPLOY_USER |
SSH-пользователь |
DEPLOY_PORT |
порт SSH, если он не 22 — иначе секрет можно не заводить |
DEPLOY_SSH_KEY |
приватный deploy-ключ (сгенерировать отдельный: ssh-keygen -t ed25519 -f deploy_key, публичную часть — в ~/.ssh/authorized_keys на сервере) |
Пока секреты не заданы, workflow просто публикует образ, шаг деплоя
пропускается. Выкатить main без нового коммита — кнопка Run workflow.
Портов наружу нет ни у одного контейнера: обе площадки работают длинным опросом, так что ни обратного прокси, ни сертификатов, ни дырок в файрволе этому деплою не нужно.
Проверка и логи:
ssh user@vps 'cd /opt/rasp && docker compose ps'
ssh user@vps 'cd /opt/rasp && docker compose logs -f'raspd показывает healthy, когда база открыта и запросы обслуживаются.
Пробу выполняет сам бинарь — raspd -health, — потому что в образе нет ни
шелла, ни curl. Состояние фонового синка в пробу намеренно не входит: под него
есть отдельное поле sync_error, и алертить стоит по нему. Разделение не
косметическое: недоступный сервер вуза не делает бота неработоспособным —
локальная копия ровно для того и держится, — а вот перезапуск живого демона
из-за ночи без связи с вузом был бы чистым вредом.
Сокет отлаживается изнутри контейнера:
docker compose exec raspd /usr/local/bin/raspd -healthmake backup HOST=user@vpsАрхив едет потоком в файл на ноутбуке, на сервере не остаётся ничего: база лежит в томе докера, а tar запускает одноразовый контейнер с alpine — в самом образе нет ни шелла, ни tar.
Забирать базу стоит не ради расписания — оно за ночь скачается заново, — а ради таблицы пользователей: привязки, подгруппы и подписки восстановить неоткуда. Снимок берётся с живой базы: WAL едет в архиве вместе с ней, поэтому копия открывается и доигрывает журнал сама.
Периметр каждого процесса сузился до того, что ему правда нужно:
- У ботов нет ни базы, ни токена вуза. Токены разнесены по двум env-файлам,
том с базой примонтирован только к
raspd. Скомпрометированный адаптер площадки не даёт ни ключа Апекса, ни файла с пользователями. Под systemd оба демона ходили одним пользователем и читали оба env-файла. - Сокет у
botdсмонтирован только на чтение. Подключиться это позволяет (для сокета проверяются права инода, а не режим монтирования), а создать рядом файл — уже нет. Сети между контейнерами не заведено вообще. - Внутри образа нечего эксплуатировать: distroless без шелла и пакетного
менеджера, процесс не root (uid 65532), корневая ФС только на чтение, все
capability сброшены,
no-new-privileges. Единственное записываемое место —/tmpна tmpfs сnoexec. - Потолки памяти те же, что были у systemd: 256 МБ на
raspd, 128 МБ наbotd, swap запрещён. - На сервер не попадает ни исходников, ни ключей от реестра: образ приходит готовым, а логин в GHCR живёт до конца прогона Actions.
Оба на месте — на случай, когда Actions недоступен или образ не должен уезжать в реестр:
make deploy-manual HOST=user@vps # собрать образ локально и отдать по ssh
make deploy-binary HOST=user@vps # вообще без докера: два бинаря под systemddeploy-manual собирает образ на ноутбуке и гонит его потоком
(docker save | gzip | ssh docker load) — реестр в этом пути не участвует.
Порт ssh берётся из ~/.ssh/config; задать явно — SSH_PORT=2222.
deploy-binary — прежняя схема с systemd, юниты лежат в deploy/*.service.
Первичная установка:
sudo useradd --system --home /var/lib/rasp --shell /usr/sbin/nologin rasp
sudo mkdir -p /etc/rasp && sudo chown rasp:rasp /etc/rasp
sudo cp deploy/rasp.env.example /etc/rasp/rasp.env
sudo cp deploy/bot.env.example /etc/rasp/bot.env
sudo cp deploy/admin.env.example /etc/rasp/admin.env
sudo chmod 640 /etc/rasp/*.env && sudo chown rasp:rasp /etc/rasp/*.env
sudo cp deploy/*.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now raspd botd admindЛоги — journalctl -u raspd -f, живость — sudo -u rasp /usr/local/bin/raspd -health.
Панель живёт по пути /admin — витрина цифр: аптайм, пользователи, популярные
группы, ресурсы машины, здоровье синка, очередь и последние жалобы демонов.
Графики строятся из собственной истории admind — точка раз в пять минут,
тридцать дней хранения, около мегабайта на диске.
Панель ничего не меняет. Ни одной кнопки, ни одного POST, ни одного пути,
который бы что-то записал в расписание или в пользователей, — поэтому вместо
авторизации у неё список адресов в deploy/admin.env:
ADMIN_ALLOW_IPS=203.0.113.10, 198.51.100.0/24
Список намеренно не в репозитории: домашний адрес админа — личные данные, а публичный git для них плохое место. Пустой список — не «пускать всех», а отказ стартовать.
Граница доверия проходит по прокси. Контейнер admind не публикует наружу ни
одного порта: до него дотягивается только caddy по сети докера. Поэтому
X-Forwarded-For читается только у соединений из приватной сети, и подделать
свой адрес заголовком снаружи нельзя — снаружи до панели просто нет пути.
Посторонний получает 404, а не 403: ему незачем знать, что по этому адресу
что-то есть.
Три источника данных сходятся в одном ответе:
- база — агрегаты собирает
raspd(store.Stats), потому что владелец базы один и лезть в файл вторым процессом ради красивых цифр незачем; - машина —
/procхоста, смонтированный в контейнер только на чтение: изнутри собственный/procпоказывает контейнер, а знать надо про машину; - бот —
botdраз в полминуты присылаетraspdсвои счётчики и последние жалобы. Направление именно такое: у бота нет ни одного входящего порта, и заводить его ради статистики значило бы разменять это свойство на удобство.
Когда raspd молчит, панель показывает прошлый снимок с честной пометкой,
сколько ему минут, — тем же приёмом, которым бот переживает падение Апекса.
Страница целиком лежит внутри бинаря (embed) и не тянет ни одного внешнего
файла: ни шрифтов, ни библиотек графиков. Панель нужна ровно в тот момент,
когда с сервером что-то не так, и зависеть в этот момент от чужого CDN — значит
остаться без единственного прибора. Графики нарисованы руками в SVG.
Настройка caddy (он живёт в соседнем compose-проекте); домен подставьте свой:
<ваш-домен> {
encode zstd gzip
handle /admin* {
reverse_proxy admind:8305 {
# Перезаписываем, а не дополняем: иначе клиент прислал бы свой
# X-Forwarded-For и объявил себя кем угодно.
header_up X-Forwarded-For {remote_host}
header_up -X-Real-IP
}
}
}
admind подключается к сети проекта caddy как к внешней (dev303_default в
compose.yaml). На сервере она уже есть; на ноутбуке её создаёт make docker-up.
| Компонент | RSS |
|---|---|
raspd |
40–80 МБ |
botd |
30–50 МБ |
admind |
15–30 МБ |
| SQLite page cache | 100–200 МБ |
Снимки изменённых дней (change_days) в этот бюджет заметного вклада не
вносят: снимок дня — это полтора-два килобайта, откладывается только на
настоящей правке будущего дня, не больше 20 дней на месяц группы, и вычищается
через трое суток тем же уборщиком, что и очередь исходящих. Лишний запрос к
базе на правку тоже один — и приходится он на месяц, который в этот момент и
так переписывается целиком.
Панель к этому бюджету добавляет немного: она спит почти всё время — страница собирается за миллисекунды, точка истории пишется раз в пять минут, и опрос браузера раз в десять секунд упирается не в сервер, а в то, с какой скоростью человек вообще способен замечать изменения.
Помещается на VPS с 1 ГБ с запасом около половины. Ночной полный обход — 675 групп × 2 месяца = 1350 запросов, при 2 req/s это примерно 11 минут и 325 МБ трафика раз в сутки. Днём работает только контур «горячих» групп — тех, за которыми стоят живые пользователи; их из 675 набирается несколько десятков.
Это сервер колледжа, а не CDN, и единственный реальный риск проекта —
что нас сочтут нагрузкой и закроют доступ. Поэтому: 2 запроса в секунду,
максимум 2 одновременных, тяжёлый обход ночью, User-Agent с контактом,
singleflight против дублей. Менять эти значения в сторону увеличения —
плохая идея.
Токен взят из публичного виджета вуза и в код не зашит: он живёт ровно до тех пор, пока админ Апекса не решит его сменить.
Порядок из плана архитектуры, пункты 1–3 сделаны:
-
apeks+store+syncer— синк наполняет базу -
schedule— домен с табличными тестами -
users+botcore+tg— телеграм-бот с текстовым выводом -
vk— адаптер поверх готовогоbotcore - Уведомления об изменениях расписания —
content_hashловит правку, raspd ставит её подписчикам группы вoutbox, botd разгребает очередь - «Что именно изменилось» — список задетых дней в самом сообщении и
«до/после» по кнопке, из снимков в
change_days - Вкладка уведомлений — утро, вечер, пустые дни и правки по отдельности, время рассылки текстом
-
renderd+media_cache— картинки: SVG-шаблон → resvg, кэшfile_id - Расписание преподавателя и свободные аудитории — индексы под них в схеме уже есть, запросов пока нет
- Экспорт в iCal — подписка в календарь телефона