Привет, Хабр.
Это история о том, как я писал балансировщик нагрузки для телеграм‑ботов, на полпути понял, что телеграм тут вообще ни при чём, выкинул половину проекта — и теперь у меня Rust‑конструктор, из которого одинаково собирается реверс‑прокси, самодельный ngrok, мост HTTP → RabbitMQ → Kafka и тот самый балансировщик ботов, с которого всё началось.
Проект называется tgin. Раньше это был Telegram Gateway Interface, теперь — Traffic Gateway Interface.
С чего всё началось
Началось с банальной боли: когда телеграм‑бот перестаёт влезать в один процесс, начинается цирк. Обновления надо раскидывать по нескольким инстансам, следить, чтобы сообщения одного чата не обрабатывались вразнобой, деплоиться без потери апдейтов. Я посмотрел на это и подумал: так это же nginx, только для Bot API. И сел писать «nginx для телеграм‑ботов» — лонгполл и вебхук на входе, round‑robin по инстансам на выходе, конфиг‑файл, вот это вот всё.
Работало. Даже неплохо. А потом я сел переделывать это по‑нормальному и заметил вещь, которая сломала мне всё позиционирование.
Прозрение
Ни одно сложное место моего «телеграмного» балансировщика не было про телеграм.
Смотрите, в чем заключалась суть проекта: принять поток сообщений, буферизовать, раскидать по потребителям, понять доставлено или нет, повторить, если нет, не захлебнуться, когда потребители тупят, и умереть красиво по ctrl+c, ничего не потеряв. Теперь замените «обновление от телеграма» на «HTTP‑запрос» или «сообщение из RabbitMQ» — список не изменился ни на пункт.
Телеграм оказался просто первой формой трафика, которая протекала через код.
И тут же вторая мысль, обиднее первой: этот же клей я уже писал руками на других проектах, и не раз. Триста строк tokio‑лапши, которые перекладывают запросы из HTTP в очередь. Пишутся за вечер, отлаживаются до пенсии: backpressure забыли, ack соврали, graceful shutdown осуществляется через kill -9. Подозреваю, такой файлик есть в каждом втором проекте, просто все делают вид, что его нет.
Так родился план: один раз честно решить скучные проблемы в маленьком ядре, а всё остальное вынести в батарейки.
Как это выглядит
Шлюз на tgin — это ваш обычный Rust‑бинарь строк на двадцать. Вот полноценный реверс‑прокси:
let server = HttpServer::new("0.0.0.0:8080");let http = HttpClient::new();Tgin::new() .pipeline( HttpIngress::catch_all(&server), Route::new() .when( |r: &RequestData| r.uri.path().starts_with("/api"), RoundRobin::new() .to(HttpEgress::new(&http, "http://10.0.0.1:3000")) .to(HttpEgress::new(&http, "http://10.0.0.2:3000")), ) .otherwise(HttpEgress::new(&http, "http://10.0.0.3:3000")), ) .run() .await;
/api балансится между двумя бэкендами, всё остальное едет на третий. Убьёте один бэкенд — трафик молча перетечёт на живой. Убьёте оба — клиент получит 502, а не вечно висящий коннект.
Конфиг‑файла нет, конфиг это код. Здесь несовместимая пара «вход‑выход» не сработает, тк все типы должны быть согласованы по типу который принимают и отдают.
Три сущности и один конверт
Внутри всё стоит на трёх китах. Ingress — откуда трафик приходит. Egress — куда уходит. Между ними ездит Envelope: данные + метаданные + опциональный канал для ответа. Пайплайн = ingress + egress. Больше в ядре ничего нет.
А декораторы — это egress`ы, оборачивающие другие egress`ы. RoundRobin, Route, Retry, фан‑аут All — ядро вообще не знает об их существовании, для него это просто ещё один выход.
Reply — это ack и backward
Самое важное место архитектуры — поле reply в Envelope. Разберем на примере HTTP на входе, RabbitMQ на выходе
Приходит POST /input. HTTP‑хендлер кладёт запрос в конверт, вкладывает туда oneshot‑канал и курит бамбук на нём. Конверт доезжает до Rabbit‑egress’а, тот публикует сообщение (persistent, в durable‑очередь) и, ключевой момент — ждёт publisher confirm от брокера. Исход уезжает обратно в oneshot, хендлер просыпается и переводит его на язык HTTP:
-
брокер подтвердил → клиент получает 202 Accepted. Не 200 — мы не сделали работу, мы приняли её на хранение. Двести второй ровно для этого и придуман, просто им никто не пользуется;
-
брокер лежит → 502. Клиент знает, что доставки не было, и может повторить;
-
внутренняя очередь переполнена → 429, backpressure вместо пожирания памяти.
А теперь разворачиваем стрелку: Rabbit на входе, HTTP‑воркер на выходе. Тот же самый reply теперь работает как ack: воркер ответил — ack, ретраи кончились — nack с requeue, ошибка фатальная — nack без requeue. Одна механика на два мира: для HTTP reply становится статус‑кодом, для очереди — сообщением.
Побочный эффект: система различает «апстрим ответил 502» и «я не смог достучаться до апстрима». Звучит банально? Сходите гляньте в свой прод, я подожду.
Самодельный ngrok
Когда ядро устаканилось, захотелось проверить композицию чем‑то наглым. Я взял и запилил туннель как у ngrok: батарейка на ~370 строк, ноль правок ядра.
// на VPS: всё входящее заворачиваем в туннельTgin::new().pipeline( HttpIngress::catch_all(&server), TunnelEgress::new(&server, "/tunnel", &token),)// дома: вылезаем наружу и раздаём локалхостTgin::new().pipeline( TunnelIngress::new("ws://мой-vps:8080/tunnel", &token), HttpEgress::new(&http, "http://127.0.0.1:3000"),)
Локалхост наружу отдаётся, kill клиента — сервер честно отвечает 502, поднял обратно — переподключился сам за секунду.
Отдельно смешно, что туннель сам живёт на WebSocket, но проксировать чужие WebSocket’ы не умеет. Об ограничениях ниже.
Телеграм никуда не делся
Старое позиционирование не умерло, а превратилось в набор батареек, и по дороге прокачалось.
Вход — лонгполл или вебхук. Лонгполл‑ingress сдвигает offset у телеграма только после того, как обновление реально доставлено дальше по цепочке: упал ваш бот — телеграм передоставит. Каждому апдейту проставляется ключ = chat_id, и ядро бесплатно даёт порядок внутри чата при параллельности между чатами.
Выход интереснее. Понятно, что можно пушить обновления инстансам по HTTP, как делает сам телеграм — это вебхук‑egress. Но есть и наглый вариант: tgin поднимает у себя эндпоинт getUpdates, и немодифицированные лонгполл‑боты поллят его, свято веря, что разговаривают с api.telegram.org. Свои update_id, длинный полл с таймаутом, подтверждение оффсетом, передоставка при падении — всё как у большого. Самозванец as a service.
Tgin::new().pipeline( TelegramBotPollingIngress::new(&http, &token), RoundRobin::new() .to(TelegramBotPollingEgress::new(&server, "/bot1")) .to(TelegramBotWebhookEgress::new(&http, "http://10.0.0.2:3000/webhook")),)
Один бот поллит, второму пушится, между ними балансировка — это, кстати, дословный перевод конфига из самой первой версии tgin.
Кафка
С RabbitMQ всё довольно прямолинейно: durable‑очереди, persistent‑сообщения, publisher confirms, ack по исходу reply. А вот с кафкой пришлось подумать, потому что у кафки нет ack’ов.
Поэтому Kafka‑ingress коммитит только непрерывный префикс подтверждённого: на каждой партиции сидит acker, который ждёт reply в порядке offset`ов и стопорит коммит на первом провале, а потом делает seek назад и передоставляет. Медленнее бездумного авто‑коммита? Да. Теряет сообщения? Нет. Я за второе.
На выходе симметрично: meta.key конверта становится partition key. Цепочка телеграм‑чат → tgin → кафка сохраняет порядок сообщений чата в партиции, и для этого не написано ни строчки специального кода — ключ просто едет в конверте через весь пайплайн.
Чего оно не умеет
-
Стриминг тел. Тело запроса буферизуется целиком. Гонять двухгигабайтные файлы через tgin — пока плохая идея.
-
WebSocket passthrough. Да, туннель сам ездит по WebSocket. Да, проксировать чужие WebSocket’ы нельзя.
-
Hot reload топологии. Конфигурация статическая и компилируется. Новая топология — новый бинарь.
Потрогать руками
В репозитории лежат демки на docker compose: reverse‑proxy, туннель, HTTP→Rabbit→воркер и HTTP→Kafka→воркер. Каждая — один docker compose up --build плюс README с прогоном.
Зачем это всё
Я не строю убийцу nginx. По голому перфу glue‑архитектура всегда проиграет специализированному прокси ‑за композицию заплачено микросекундами на внутреннем хопе, и я этот размен сделал сознательно.
Я строю инструмент для стыков — мест, где один узкий инструмент заканчивается, другой начинается, а между ними по традиции живёт та самая самописная лапша из трёхсот строк. Теперь вместо неё можно взять ядро, у которого backpressure, ack’и и graceful shutdown сделаны один раз и правильно, досочинить свою батарейку — и получить туннель, прокси, очередь и свою логику одним бинарём.
Репозиторий: https://github.com/chesnokpeter/tgin
Пишите комментарии, пообщаемся, мне интересно что за штуку я придумал
ссылка на оригинал статьи https://habr.com/ru/articles/1056156/