Масштабирование Simmetric Chat на несколько инстансов с Redis и pg-boss
Simmetric Chat масштабируется горизонтально за любым балансировщиком, без sticky-сессий. Как состояние переезжает в Redis, Postgres и очередь задач pg-boss.
Один инстанс - просто. Два - дизайнерское решение.
Запуск одного инстанса Simmetric Chat намеренно тривиален: один серверный процесс, один Postgres, и каждая часть состояния, которую иначе пришлось бы делить, спокойно живёт в памяти. В тот момент, когда вы ставите второй инстанс за балансировщик, всё это внутрипроцессное состояние становится проблемой корректности. Счётчики rate limit расходятся, поэтому эффективный лимит умножается на число инстансов. Токен, отозванный на инстансе A, всё ещё принимается на инстансе B. Пользователь в стриме на инстансе B никогда не видит события, отправленные на инстансе A. Ни один из этих сбоев не объявляет о себе - он всплывает позже в виде сообщений «лимит не работает» или «выход не отключает меня везде».
Simmetric Chat относится к горизонтальному масштабированию как к первоклассному режиму развёртывания. Работа над многоинстансностью, завершённая в рамках milestone'а слоя Redis и включённая в v0.22, выносит именно эти части состояния, чтобы любой инстанс мог обслужить любой запрос. Эта статья проходит по топологии, пяти потребителям Redis, очереди задач pg-boss, ключам, которые должны совпадать на всей флоте, и поведению graceful degradation, которое сохраняет одноинстансное развёртывание полностью поддерживаемым.
Топология: идентичные инстансы без состояния
Форма развёртывания намеренно неприметна. N идентичных серверных процессов стоят за любым HTTP-балансировщиком; общий Postgres хранит данные приложения, pgvector и схему задач pgboss; общий Redis держит состояние кросс-инстансной координации:
Балансировщик (любой HTTP LB - sticky-сессии не нужны)
│
┌───────┼───────┐
Server 1 Server 2 … Server N идентичные процессы, :3000
└───────┼───────┘
│
Общий Postgres Общий Redis
данные приложения rate limit, список отзыва,
+ pgvector + pgboss SSE-релей, локи, кэшКаждый инстанс выполняет тот же код и безétat'ен с точки зрения балансировщика. Нет никакой серверной карты сессий, привязывающей клиента к инстансу: аутентификация - stateless JWT, подписанный HS256 общим JWT_SECRET и проверяемый на каждом запросе. Всё, что иначе жило бы в памяти процесса, выносится в объявленное место:
- Счётчики rate limit → Redis (общее окно на всю флоту); без Redis каждый инстанс считает сам
- Отзыв JWT (список `jti`) → Redis; без Redis отзыв не действует, и токены валидны до истечения
- Fan-out SSE → релей через Redis pub/sub; без Redis события доходят только до клиентов отправляющего инстанса
- Распределённые локи (reaping-планировщики) → redlock на Redis; без Redis лок ничего не делает
- Кэш аутентификации и конфигурации → Redis; без Redis каждое чтение падает на запрос к базе
- Фоновые задачи → pg-boss, опирающийся на тот же общий Postgres
Sticky-сессии не нужны
Балансировщику не нужна привязка сессий для REST-трафика. Единственные долгоживущие соединения - SSE-стримы, и даже они не требуют привязки: клиент держит потоковое соединение с принявшим его инстансом, но события, отправленные на любом другом инстансе, всё равно доходят, потому что каждый инстанс публикует SSE-события в канал Redis, а каждый инстанс переизлучает сообщения этого канала своим подключённым клиентам. Релей проверен end-to-end в двухинстансной симуляции, включая поведение origin-skip, не дающее инстансу снова получать собственные события.
Если ваш балансировщик предлагает least-connections-маршрутизацию, предпочитайте её для SSE-пути: долгоживущие соединения стоит распределять по числу открытых соединений, а не по round-robin. Это выбор эффективности, а не требование корректности. Подойдёт любой HTTP-балансировщик.
Пять потребителей Redis
REDIS_URL опционален по замыслу, и одноинстансные откаты - полностью поддерживаемое поведение, а не инциденты. Многоинстансность, однако, требует, чтобы каждый инстанс указывал на один и тот же Redis (или cluster, или sentinel), потому что пять подсистем иначе молча расходятся:
- Хранилище rate limit. С общим Redis-хранилищем глобальный лимит в 200 запросов в минуту означает 200 на всё развёртывание. Без него каждый инстанс держит свой счётчик, и эффективный лимит становится в N раз больше настроенного - инцидент в ожидании пика трафика.
- Отзыв JWT. Проверка отзыва обращается к чёрному списку, который должен быть глобальным. Без Redis токен, разлогиненный или отозванный администратором, продолжает работать на других инстансах до истечения; «разлогинить везде» молча становится «разлогинить здесь».
- Fan-out SSE. Кросс-инстансный релей живёт на Redis pub/sub. Без него пользователь, чей стрим приземлился на инстансе B, никогда не видит события инстанса A - сбой проявляется как загадочно зависшие live-обновления.
- Распределённые локи. Reaper-подобные планировщики обслуживания берут кросс-инстансный мьютекс через redlock, чтобы два инстанса никогда не выполняли одну уборку одновременно. Без Redis обёртка лока ничего не делает - и это одна из причин, почему плановые задачи переехали на pg-boss, чей dedup не зависит от лока.
- Кэш аутентификации и конфигурации. Системные настройки и кэши ролей делятся через Redis. Без него кэша нет: каждое чтение - свежий запрос к Postgres, что проявляется как усиление чтений ровно тогда, когда трафик растёт.
# Корневой .env - одинаковый на каждом инстансе
REDIS_URL=redis://redis-host:6379
# TLS-вариант:
# REDIS_URL=rediss://redis-host:6379Клиенты говорят на стандартных Redis URL через ioredis. Продакшен-старт без заданного REDIS_URL пишет громкое предупреждение; считайте это предупреждение развёртыванием, говорящим вам, что оно работает в одноинстансном режиме, что бы ни говорили инстансы.
pg-boss: очередь задач без дополнительной инфраструктуры
Фоновая работа Simmetric Chat - проверки согласованности архивов, reaping синтеза, очистка векторов, health check'и MCP, reaping MCP, retention сообщений чата и очистка черновиков загрузок - идёт на pg-boss, очереди задач на Postgres. Семь планировщиков на setInterval мигрировали на плановые задачи pg-boss. pg-boss автоматически создаёт собственную схему pgboss в базе, которую вы уже используете для данных приложения, поэтому горизонтальное масштабирование не добавляет ни одного инфраструктурного компонента и сохраняет весь стек air-gap-совместимым.
Свойство, важнейшее для многоинстансных развёртываний, - распределённый dedup задач: когда несколько инстансов одновременно достигают одного планового момента срабатывания, ровно один инстанс забирает задачу, остальные пропускают из-за конкуренции. Очередь, а не обёртка лока, - механизм dedup: на одну вещь меньше, которую можно настроить неправильно.
Два чувствительных к задержке poller'а намеренно не мигрировали. Reaper OCR-конвейера и reaper конвейера синтеза остаются на внутрипроцессных интервалах в 10 секунд, потому что минимальная гранулярность cron у pg-boss - минута, что сделало бы забор конвейеров в шесть раз медленнее. Их многоинстансное поведение задокументировано и безопасно: оба - идемпотентные циклы claim-then-dispatch, дедуплицирующие на уровне базы атомарными claim'ами, так что конкурирующие инстансы не могут дважды забрать одну ожидающую задачу, а collector поглощает каждый избыточный dispatch как no-op.
Graceful shutdown - часть контракта. Задачи в полёте дренируются под grace timeout pg-boss до закрытия соединения с базой, и вся последовательность остановки идёт под внешним лимитом времени. Если Postgres недоступен при старте или падает во время работы, задачи pg-boss пропускаются с записанной ошибкой: сервер не уходит в crash-loop, а очередь восстанавливается, когда Postgres возвращается. Сбой очереди никогда не должен уводить chat API в офлайн.
Ключи, которые должны совпадать на флоте
Два секрета заслуживают явного внимания при переходе от одного инстанса к многим:
- `JWT_SECRET` - симметричный ключ подписи stateless сессионных токенов. Одинаковое значение на каждом инстансе - именно то, что делает истинным свойство «любой инстанс может обслужить любой запрос».
- `ENCRYPTION_KEY` - ключ шифрования at rest AES-256-GCM (32 байта), обязательный в продакшене, где сервер громко падает при старте, если его нет. Каждый инстанс должен нести одинаковое значение, потому что blob, зашифрованный одним инстансом, должен расшифроваться на всех.
Держать два ключа раздельно - само по себе многоинстансное свойство безопасности. Ротация JWT_SECRET дешева - сводится к перевыпуску токенов, - тогда как ротация ENCRYPTION_KEY требует полного runbook'а перешифрования данных. Прежнее поведение с выводом ключа шифрования из JWT-секрета связывало две ротации; на флоте эта связка - кирпич для всей флоты в ожидании рутинной ротации секретов.
# сгенерируйте однажды, раздайте на каждый инстанс
openssl rand -hex 32 # JWT_SECRET
openssl rand -base64 32 # ENCRYPTION_KEYПул соединений Postgres - практический ограничитель масштаба. Каждый инстанс открывает ограниченный прикладной пул плюс отдельный пул pg-boss, поэтому подбирать размер пула базы и max_connections Postgres относительно числа инстансов - ответственность оператора. Жёсткого верхнего предела числа инстансов нет; начинайте с двух и масштабируйте по потребности.
Что ломается при отсутствии зависимости
Graceful degradation - спроектированное поведение, и его стоит знать до планирования мощностей, а не после инцидента:
- Нет Redis, один инстанс. Всё работает. SSE просто негде релеить, rate limit'ы на процесс, кэши на процесс. Это поддерживаемый одноинстансный дефолт.
- Нет Redis, N инстансов. Пять расхождений выше появляются молча: умноженные лимиты, отзыв на инстанс, сломанный кросс-инстансный стриминг, инертные локи и перегретые чтения из базы. Продакшен-предупреждение при старте - ваш сигнал.
- Postgres недоступен. Сервер стартует и обслуживает запросы, не нуждающиеся в очереди; семь планировщиков pg-boss пропускают прогоны и повторяют позже; запросы, нуждающиеся в очереди, изящно падают с записанными ошибками. Транзиентный сбой при старте не уводит процесс в crash-loop.
Позиция дизайна последовательна: опциональные зависимости деградируют к задокументированному поведению, а сбой зависимости деградирует одну возможность, а не платформу.
От одного инстанса к двум
Выращивание развёртывания - работа с конфигурацией, а не с кодом:
- Направьте каждый инстанс на один и тот же
DATABASE_URL,JWT_SECRET,COLLECTOR_SECRETиENCRYPTION_KEY. - Задайте
REDIS_URLна каждом инстансе, указывающий на общий Redis (одиночный, cluster или sentinel). - Перезапустите и убедитесь, что стартовый лог не показывает одноинстансное предупреждение.
- Поставьте инстансы за балансировщик и направьте health check'и на
GET /api/health, который сообщает состояние базы, collector и диска.
Последняя многоинстансная деталь, которую стоит знать: аутентификация по API key - это O(1)-поиск по ключеванному HMAC, а не цикл bcrypt. Эта правка была сделана именно для того, чтобы проверка API key оставалась достаточно дешёвой для выполнения на каждом запросе через каждый инстанс, без общего кэша и без пиков CPU на инстанс. Это тот тип тихого решения, который делает горизонтальное масштабирование скучным - а это ровно то, как должно быть.


