Колобок-стек: я от бабушки ушёл, или как мы написали свой сервер алертов на 16 МБ
- суббота, 4 апреля 2026 г. в 00:00:06
Нет повести печальнее на свете, чем повесть о лежачем алерте.

Pusk — self‑hosted сервер алертов на 16 МБ. Один бинарник, без внешних сервисов, частично совместим с Telegram Bot API (13 методов из 80+).
Типичная ситуация: несколько серверов, Zabbix собирает метрики, Python‑боты шлют алерты в Telegram. У кого‑то это веб‑проект, у кого‑то видеонаблюдение, у кого‑то живые эфиры, где 2 минуты без алерта = зрители видят чёрный экран. Работало годами.
А потом канал до API отвалился. Причина неважна — лимиты, блокировки, авария на стороне провайдера. Алерты встали. Нужен был свой канал доставки, который не зависит от внешних сервисов.

Первая мысль — поднять self‑hosted мессенджер. Посмотрели на варианты — один другого краше:
Mattermost | Matrix | Telegram | |
Доп. сервисы | PostgreSQL, SMTP | Synapse, PostgreSQL | Cloud |
Миграция ботов | Переписывать | Переписывать | — |
ACK алертов | Плагин | Нет | Нет |
Mattermost/Rocket.Chat: Это полноценные платформы со своими схемами, миграциями, обновлениями. Да, Postgres у нас есть — Zabbix на нём живёт. Но накатывать на него ещё одну базу с миграциями и отдельными бэкапами ради доставки алертов — перебор. У многих «сервер» — это виртуалка на 4 ГБ, где Zabbix уже съел свои 2 ГБ.
Matrix: Synapse требует тюнинга, Element избыточен для алертов. Нам нужен браузер и одна кнопка, а не мессенджер с федерацией.
Главное: все эти варианты — полноценные мессенджеры. Команды, пространства, плагины, база данных. Нам не нужен мессенджер. Нужен алертинг с ламповым чатиком: принял webhook, показал дежурному, дал нажать ACK, дал написать коллегам «я взял». Тащить PostgreSQL и 500 МБ RAM ради этого — как поднимать кубер ради пары контейнеров.
Плюс все существующие боты написаны под Telegram Bot API. Можно скормить их AI и портировать за вечер. Но зачем переписывать, если можно не переписывать? И главное под что переписывать?
А VK Teams / MAX / Signal / Яндекс.Мессенджер? VK Teams (ex‑Myteam) — self‑hosted только в корпоративном тарифе, Bot API несовместим с Telegram, боты переписывать. MAX — Bot API есть, но публикация только через верифицированные юрлица РФ, API несовместим с Telegram, self‑hosted нет. Signal — нет Bot API, нет вебхуков. Сервер на GitHub, но Java, минимум 4 CPU / 8 GB и выбор без выбора: или облако AWS/GCP, или поднимать у себя MinIO с обвязкой. Отдельный проект, а не «поставил и забыл». Яндекс.Мессенджер — часть Яндекс 360, облачный, self‑hosted нет.
А Gotify? ntfy? Gotify — push в одну сторону, нет чата, нет ACK. ntfy — аналогично. Нам нужны были ACK, командный чат и совместимость с существующими Telegram‑ботами. Это не про push‑трубу.
А может VPN и оставить Telegram? Туннели, меш‑сети и прочие пляски с бубном — и вот бот снова видит api.telegram.org. Можно, а зачем? Сегодня VPN работает, завтра endpoint заблокирован, послезавтра нужен новый выходной узел. Плюс туннель — это ещё одна точка отказа, которую тоже надо мониторить. Мониторинг мониторинга — мы не за этим. Проще поднять своё за те же 30 минут.
Нам шашечки или ехать? Нам ехать. Написали свой сервер, который прикидывается Telegram Bot API. Поменял base_url в боте и он работает с твоей инфрой и даже не знает об этом.
Почему Go, а не Rust? На Rust тоже пишу, но для этой задачи выбрали Go: net/http из коробки, сборка за 7 секунд с нуля, полсекунды повторная. При десятках запросов в минуту разницы между Go и Rust не увидишь.
Выбор БД был коротким: Postgres — жирная зависимость ради алертов. BoltDB/BadgerDB — key‑value, а нам нужны JOIN'ы и нормальные запросы. SQLite — файл, SQL, ноль настройки. Взяли modernc.org/sqlite — это SQLite, транслированный в чистый Go. Никакого CGO, gcc в Docker не нужен. Платим ~18 МБ в весе бинарника. На старте жрёт ~4 МБ RSS. Логи через slog — structured JSON или text, как нравится.

Что внутри бинарника
pusk (16 МБ, ~6600 строк Go, 110 тестов) ├── Bot API — /bot/<token>/<method> (13 из 80+ методов Telegram Bot API) ├── Client API — /api/* (бэкенд PWA) ├── WebSocket — /api/ws (real-time статусы, typing) ├── Web Push — FCM / Mozilla (уведомления без polling) ├── Файлы — /file/<id> (медиа) ├── PWA — / (веб-клиент) └── SQLite — data/orgs/*/pusk.db (отдельная БД на каждую организацию)
Telegram Bot API — это не только ценный протокол миграции, но и 13 методов легкоусвояемого API.
Взяли самое необходимое: sendMessage, editMessageText, deleteMessage, answerCallbackQuery, sendPhoto, sendDocument, sendVoice, sendVideo, setWebhook, deleteWebhook, getWebhookInfo, getMe, getUpdates. Этого хватает для типичного мониторингового бота: отправить сообщение, показать кнопки, принять callback, отправить фото/файл. Стикеры и inline‑mode (@бот запрос) пока не тащим, inline‑кнопки под сообщениями — есть. Нереализованные методы возвращают 400 unknown method, а не молча глотают.
Нюанс: Telegram‑клиенты шлют запросы на /botTOKEN/method (слитно), а наш роут POST /bot/ ловит только /bot/.... Мидлварь на 5 строк переписывает путь на лету:
func TelegramCompat(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path if strings.HasPrefix(p, "/bot") && !strings.HasPrefix(p, "/bot/") { r.URL.Path = "/bot/" + p[4:] } next.ServeHTTP(w, r) }) }
Бот на python‑telegram‑bot переезжает так:
# Было: app = ApplicationBuilder().token(TOKEN).build() # Стало: app = ApplicationBuilder().token(TOKEN).base_url("https://pusk.internal/bot").build()
Python‑библиотеки (python-telegram-bot, aiogram) ожидают от /getUpdates конкретного поведения: учитывай offset, держи соединение timeout секунд, отдавай только новые. Реализовали через очередь в памяти (100 апдейтов на бота). Poll() ждёт первое сообщение или таймаут, потом сливает всё что накопилось. Апдейты с UpdateID <= offset пропускаются. Персистенс не нужен, если бот перезапустился, он просто получит следующие алерты.
Каждая организация — отдельный файл SQLite. Честно: взяли SQLite потому что не хотели тащить Postgres как зависимость. Оказалось, что для алертов (десятки сообщений в минуту) этого хватает с запасом. Бонусом получили изоляцию: бэкап организации = cp файла, удалить = rm файла. WAL‑режим — SQLite не блокирует читателей при записи. Горячий бэкап: sqlite3 pusk.db ".backup backup.db" прямо на живом сервере.
Webhook работает, когда Pusk может достучаться до бота по HTTP. Если бот за NAT, тогда он сам подключается к Pusk по WebSocket и получает апдейты через обратный канал:
import websockets, json, asyncio async def relay(): async with websockets.connect("wss://pusk.internal/bot/TOKEN/relay") as ws: async for msg in ws: update = json.loads(msg) print(update["message"]["text"]) asyncio.run(relay())
Pusk сам определяет способ доставки: webhook, relay или очередь getUpdates. Webhook URL проверяется на подмену адреса (SSRF) — нельзя заставить Pusk стучаться во внутренние сервисы. Relay требует валидный bot token. Ограничения: 10 auth/мин, 30 msg/мин, 10 upload/мин.
Мы сейчас активно проводим тесты с командой. Вот как выглядит рабочий процесс.
Шаг 1. Админ поднимает сервер. docker run или просто закинул бинарник на сервер и запустил. Создаёт организацию — становится первым админом. Автоматически создаётся #general и системный бот.

Шаг 2. Приглашаем команду. Одна ссылка на 7 дней, до 50 человек. Кинули в рабочий чат — люди регаются сами. При регистрации появляется сообщение «→ username joined the team» в #general. Утекла ссылка — отозвал в настройках.
Шаг 3. Админ создаёт канал #alerts. Подключает webhook из Grafana/Zabbix/Alertmanager. Все участники автоматически подписываются.

Шаг 4. Дежурный (member) видит алерт. Push на телефон, открыл в браузере, нажал ACK. Вся команда видит, кто взял. Member может писать в каналы, @mention'ить коллег, отправлять файлы. Но не может создавать каналы, ботов, приглашать.

Разделение ролей:
Admin | Member | |
Писать в каналы | ✓ | ✓ |
ACK алертов | ✓ | ✓ |
@mention + push | ✓ | ✓ |
Файлы, фото | ✓ | ✓ |
Создать канал/бот | ✓ | ✘ |
Пригласить | ✓ | ✘ |
Переименовать канал | ✓ | ✘ |
Удалить юзера | ✓ | ✘ |
Выдать админа | ✓ | ✘ |
Совместимость с Bot API нужна, чтобы не переписывать ботов. Но раз уж свой сервер — добавили то, чего в Telegram нет:
ACK одной кнопкой. Под алертом кнопка «Подтвердить». Нажал и вся команда видит, кто взял инцидент. Никаких «я смотрю, в процессе, кто смотрит?» в чате.
Кляп для Alertmanager. Нажал ACK — Pusk шлёт POST /api/v2/silences в Alertmanager и глушит повторные алерты. Настройка: одна переменная PUSK_ALERTMANAGER_URL. Проверено на живом Alertmanager — silence создаётся, повторные алерты глохнут.

Цветовые полоски. Видно на скриншоте выше — красная полоска слева = горит, зелёная = решено. Статус инцидента считывается за секунду, не читая текст.
Webhook из любого мониторинга. Alertmanager, Grafana (да, кто‑то шлёт алерты прямо из неё — не осуждаем), Zabbix, raw JSON — один URL, формат в query‑параметре: ?format=alertmanager. Подключение — один URL в настройках мониторинга.
Web Push без батарейки. Pusk не опрашивает сервер — push идёт через стандартные push‑сервисы Google (FCM) и Mozilla — те же, что используют Telegram и Discord.
На десктопе алерт прилетает по WebSocket — мгновенно. На мобилке PWA в фоне — Android убьёт сокет через полминуты. Тут работает Web Push через стандартные push‑сервисы браузеров.
Чтобы не дублировать: перед отправкой push проверяем, подключён ли юзер по WS и смотрит ли он этот канал прямо сейчас. Если да — push не шлём, он и так видит. Если нет — шлём.
Pusk работает и без внешнего интернета. Фронтенд вшит в бинарник, никаких CDN с подгрузкой шрифтов и прочего. Единственное исключение — Web Push (нужны серверы FCM/Mozilla). В полной изоляции push на мобилку не работает — FCM/Mozilla за периметром ведь. Если дежурный один и сидит перед монитором — хватит и Grafana. Pusk нужен когда дежурных несколько и важно видеть кто взял инцидент. В изоляции он работает через браузер по WebSocket. Push на телефон внутри контура — через ntfy.sh (self‑hosted, без Google), интеграция в планах.
Плюс тот самый ламповый чатик: каналы, @mention, файлы. Не Telegram, но чтобы написать коллеге «я взял, перезагружаю» — хватает.
Онлайн‑статус. Зелёная точка — онлайн. Жёлтая — отошёл. Сразу видно кто на связи — важно в целом для дежурств.
Защита владельца. Создатель организации не может быть удалён или понижен. #general нельзя удалить или переименовать.
Prometheus /metrics. Подключайте к своей Grafana.
Docker‑образы для российских ОС. Alpine, RED OS, Astra Linux SE — три образа в GHCR. Cosign‑подпись (проверка, что образ не подменён), SBOM (список всех компонентов для аудита).
Сначала генерировали ключи при старте. Рестарт и ключи новые, подписки сдохли, push молчит. Без ошибок в логах — просто тишина. Классическое «а в логах всё чисто». Вынесли ключи в env.
И опять двадцать пять. SW кешировал всё намертво. Юзер неделю сидел на бажной версии фронта. Перешли на network‑first для JS/CSS. За один день тестирования прошли от v13 до v52.
Три бота и Zabbix на одном IP. Один бот с кривым токеном задолбил API и заблокировал всех. Теперь лимиты считаются по IP:token.
Фронтенд — 10 ES‑модулей, без Webpack/Vite. Меньше движущихся частей при сборке, проще дебажить. Обратная сторона — вложенные бэктики (\${... \...\ ...}) молча убивают весь модуль. Белый экран, ноль ошибок в консоли. Добавили проверку в pre‑commit hook.
Go-код | ~6600 строк |
Фронтенд | ~1600 строк (JS 1300 + CSS 320) |
Бинарник | 16 МБ |
RAM | ~4 МБ RSS на старте |
Тесты | 110 unit (Go) + E2E (Playwright) |
Нагрузка | ~500 req/sec, p95 < 20 ms |
Миграция бота | 1 строка, 30 секунд |
Безопасность:
Сообщения в SQLite открытым текстом, шифрования нет. Для алертов приемлемо, для переписки, конечно же, нет
Токены ботов в БД хранятся как есть. Пароли юзеров — bcrypt
Функционал:
Нет автоэскалации. ACK есть, но если никто не нажал — алерт просто висит. Расписание дежурств в планах (скорее всего внутри SQLite, без внешних календарей)
Стикеры, групповые чаты, опросы — пока не планируются. Каждый новый метод — это новые баги
Эксплуатация:
Один процесс, один сервер. При обновлении ~2 сек простоя (клиент покажет «Переподключение...» и сам вернётся). Для команды до 50 человек хватает
Код открыт, PR принимаются
Алерты доходят. Даже когда внешние сервисы недоступны. SLA не страдает.
Не нужен штат админов. Один файл, ноль настройки, живёт рядом с Zabbix.
Миграция без боли. Существующие боты продолжают работать — меняется одна строка в коде.
Работает в проде. Алерты доходят, ACK работает, Alertmanager молчит, когда надо.
Нагрузочный тест (k6, Xeon E5 2012 года): ~500 req/sec, p95 < 20 мс. Для алертов более чем с запасом.
Попробовать:
- Демо (без смс и регистрации): getpusk.ru
- Исходники: github.com/getpusk/pusk
- Docker: docker run -d -p 8443:8443 ghcr.io/getpusk/pusk:latest
Ставьте, ломайте, пишите в Issues, что отвалилось.
Это первая статья из цикла «Колобок-стек: я от бабушки ушёл». Следующий колобок — свой реестр артефактов на Rust. От кого мы там ушли — в следующей серии.