Portare Simmetric Chat su più istanze con Redis e pg-boss
Simmetric Chat scala orizzontalmente dietro un qualsiasi load balancer, senza sessioni sticky. Come lo stato migra su Redis, Postgres e la coda pg-boss.
Un'istanza è facile. Due sono una decisione di progettazione.
Eseguire una singola istanza di Simmetric Chat è volutamente banale: un processo server, un Postgres, e ogni porzione di stato che altrimenti andrebbe condivisa vive comodamente in memoria. Nel momento in cui si mette una seconda istanza dietro un load balancer, tutto quello stato in-process diventa un problema di correttezza. I contatori dei rate limit divergono, così il limite effettivo si moltiplica per il numero di istanze. Un token revocato sull'istanza A viene ancora accettato sull'istanza B. Un utente in streaming collegato all'istanza B non vede mai gli eventi emessi sull'istanza A. Nessuno di questi guasti si annuncia - emerge più tardi, come segnalazioni "il limite non funziona" o "il logout non mi disconnette ovunque".
Simmetric Chat tratta lo scale orizzontale come una modalità di deployment di prima classe. Il lavoro multi-istanza, completato attraverso il milestone del layer Redis ed incluso in v0.22, esternalizza esattamente quelle porzioni di stato così che qualsiasi istanza possa servire qualsiasi richiesta. Questo articolo attraversa la topologia, i cinque consumatori Redis, la coda di job pg-boss, le chiavi che devono coincidere su un'intera flotta e il comportamento di graceful degradation che mantiene un deployment a istanza singola pienamente supportato.
La topologia: istanze identiche e stateless
La forma del deployment è volutamente poco appariscente. N processi server identici stanno dietro a un qualsiasi load balancer HTTP; un Postgres condiviso ospita i dati applicativi, pgvector e lo schema pgboss dei job; un Redis condiviso conserva lo stato di coordinamento cross-istanza:
Load balancer (un qualsiasi LB HTTP - sessioni sticky non necessarie)
│
┌───────┼───────┐
Server 1 Server 2 … Server N processi identici, :3000
└───────┼───────┘
│
Postgres condiviso Redis condiviso
dati app + pgvector rate limit, lista di revoca,
+ schema pgboss relay SSE, lock, cacheOgni istanza esegue lo stesso codice ed è stateless dal punto di vista del load balancer. Non esiste alcuna mappa di sessione server-side che ancori un client a un'istanza: l'autenticazione è un JWT stateless, firmato HS256 con un JWT_SECRET condiviso e validato su ogni richiesta. Qualsiasi cosa che altrimenti vivrebbe in memoria di processo viene esternalizzata verso una sede dichiarata:
- Contatori dei rate limit → Redis (finestra condivisa su tutta la flotta); senza Redis, ogni istanza conta per conto suo
- Revoca dei JWT (lista `jti`) → Redis; senza Redis la revoca non ha effetto e i token restano validi fino alla propria scadenza
- Fan-out SSE → relay via pub/sub Redis; senza Redis, gli eventi raggiungono solo i client dell'istanza che li emette
- Lock distribuiti (scheduler di reaping) → redlock su Redis; senza Redis il lock non fa nulla
- Cache di autenticazione e configurazione → Redis; senza Redis, ogni lettura ricade su una query al database
- Job in background → pg-boss, appoggiato allo stesso Postgres condiviso
Le sessioni sticky non sono necessarie
Il load balancer non ha bisogno di affinità di sessione per il traffico REST. Le uniche connessioni di lunga durata sono gli stream SSE, e neppure quelle richiedono un ancoraggio: un client mantiene una connessione in streaming verso l'istanza che l'ha accettata, ma gli eventi emessi su qualsiasi altra istanza arrivano comunque, perché ogni istanza pubblica gli eventi SSE su un canale Redis e ogni istanza riemette i messaggi di quel canale verso i propri client collegati. Il relay è verificato end-to-end in una simulazione a due istanze, incluso il comportamento di origine-salta che impedisce a un'istanza di ricevere di nuovo i propri eventi.
Se il vostro balancer offre il routing least-connections, preferitelo per il percorso SSE - le connessioni di lunga durata andrebbero distribuite in base al numero di connessioni aperte anziché in round-robin. È una scelta di efficienza, non un requisito di correttezza. Un qualsiasi load balancer HTTP va bene.
I cinque consumatori Redis
REDIS_URL è opzionale per scelta, e i fallback a istanza singola sono comportamento pienamente supportato, non incidenti. Il multi-istanza, tuttavia, richiede che ogni istanza punti allo stesso Redis (o cluster o sentinel), perché cinque sottosistemi altrimenti divergono in silenzio:
- Store dei rate limit. Con uno store Redis condiviso, un limite globale di 200 richieste al minuto significa 200 su tutto il deployment. Senza di esso, ogni istanza mantiene il proprio contatore e il limite effettivo diventa N volte quello configurato - un incidente in attesa di un picco di traffico.
- Revoca dei JWT. Il controllo di revoca consulta una blacklist che deve essere globale. Senza Redis, un token disconnesso o revocato amministrativamente continua a funzionare sulle altre istanze fino alla scadenza; "disconnetti ovunque" diventa silenziosamente "disconnetti qui".
- Fan-out SSE. Il relay cross-istanza vive sul pub/sub di Redis. Senza di esso, un utente il cui stream è atterrato sull'istanza B non vede mai gli eventi emessi sull'istanza A - il guasto si manifesta come aggiornamenti live misteriosamente bloccati.
- Lock distribuiti. Gli scheduler di manutenzione in stile reaper acquisiscono un mutex cross-istanza tramite redlock così che due istanze non eseguano mai la stessa pulizia in concorrenza. Senza Redis il wrapper di lock non fa nulla - ed è una delle ragioni per cui i job schedulati sono migrati a pg-boss, il cui dedup non dipende da un lock.
- Cache di autenticazione e configurazione. Le impostazioni di sistema e le cache dei ruoli sono condivise tramite Redis. Senza di esso non c'è cache: ogni lettura è una query Postgres fresca, che si manifesta come amplificazione di letture esattamente quando il traffico cresce.
# .env radice - identico su ogni istanza
REDIS_URL=redis://redis-host:6379
# Variante TLS:
# REDIS_URL=rediss://redis-host:6379I client parlano URL Redis standard tramite ioredis. Un avvio in produzione con REDIS_URL non impostato registra un avviso sonoro; considerate quell'avviso come il deployment che vi dice che sta girando in modalità a istanza singola, qualunque cosa dicano le istanze.
pg-boss: la coda di job senza infrastruttura aggiuntiva
Il lavoro in background di Simmetric Chat - controlli di coerenza degli archivi, reaping della sintesi, pulizia dei vettori, health check MCP, reaping MCP, retention dei messaggi di chat e pulizia delle bozze di upload - gira su pg-boss, una coda di job appoggiata a Postgres. Sette scheduler basati su setInterval sono stati migrati a job schedulati pg-boss. pg-boss crea automaticamente il proprio schema pgboss dentro il database che già eseguite per i dati applicativi, quindi scalare in orizzontale non aggiunge alcuna componente infrastrutturale e mantiene l'intero stack compatibile con l'air-gap.
La proprietà che conta di più per i deployment multi-istanza è il dedup distribuito dei job: quando più istanze raggiungono tutte lo stesso momento di scatto schedulato, esattamente un'istanza raccoglie il job e le altre saltano per contesa. La coda, non un wrapper di lock, è il meccanismo di dedup - una cosa in meno da configurare male.
Due poller sensibili alla latenza non hanno migrato volutamente. Il reaper della pipeline OCR e il reaper della pipeline di sintesi restano su intervalli in-process di 10 secondi, perché la granularità minima del cron di pg-boss è un minuto e renderebbe il prelievo delle pipeline sei volte più lento. Il loro comportamento multi-istanza è documentato e sicuro: entrambi sono cicli idempotenti claim-quindi-dispatch che deduplicano a livello di database con claim atomici, così che istanze concorrenti non possano rivendicare due volte lo stesso job in attesa, e il collector assorbe ogni dispatch ridondante come no-op.
Lo shutdown graceful fa parte del contratto. I job in volo vengono drenati sotto un grace timeout di pg-boss prima che la connessione al database si chiuda, e l'intera sequenza di shutdown gira sotto un limite di tempo esterno. Se Postgres non è disponibile all'avvio o cade durante l'esecuzione, i job pg-boss vengono saltati con un errore registrato - il server non va in crash-loop, e la coda si ripristina quando Postgres torna. Un'interruzione della coda non deve mai portare offline l'API di chat.
Le chiavi che devono coincidere sulla flotta
Due segreti meritano attenzione esplicita quando si passa da un'istanza a molte:
- `JWT_SECRET` - la chiave di firma simmetrica per i token di sessione stateless. Lo stesso valore su ogni istanza è precisamente ciò che rende vera la proprietà qualsiasi-istanza-può-servire-qualsiasi-richiesta.
- `ENCRYPTION_KEY` - la chiave di cifratura a riposo AES-256-GCM (32 byte), richiesta in produzione, dove il server fallisce sonoramente all'avvio se manca. Ogni istanza deve portare lo stesso valore, perché un blob cifrato da un'istanza deve decifrarsi su tutte.
Mantenere le due chiavi separate è di per sé una proprietà di sicurezza multi-istanza. Ruotare JWT_SECRET è economico - si limita a riemettere i token - mentre ruotare ENCRYPTION_KEY richiede un runbook completo di ri-cifratura dei dati. Il vecchio comportamento di derivare la chiave di cifratura dal segreto JWT accoppiava le due rotazioni; in una flotta, quell'accoppiamento è un mattone per l'intera flotta in attesa di una rotazione di segreti di routine.
# genera una volta, distribuisci su ogni istanza
openssl rand -hex 32 # JWT_SECRET
openssl rand -base64 32 # ENCRYPTION_KEYIl pool di connessioni Postgres è il limitatore pratico di scalabilità. Ogni istanza apre un pool applicativo limitato più un pool pg-boss separato, quindi dimensionare il pool del database e max_connections di Postgres rispetto al numero di istanze è responsabilità dell'operatore. Non c'è un limite superiore rigido al numero di istanze; partite con due e scalate al bisogno.
Cosa si rompe quando manca una dipendenza
La graceful degradation è comportamento progettato, e vale la pena conoscerla prima del capacity planning anziché dopo un incidente:
- Redis assente, un'istanza. Tutto funziona. SSE semplicemente non ha dove fare relay, i rate limit sono per-processo, le cache sono per-processo. Questo è il default supportato a istanza singola.
- Redis assente, N istanze. Le cinque divergenze sopra appaiono in silenzio - rate limit moltiplicati, revoca per-istanza, streaming cross-istanza rotto, lock inerti e letture al database surriscaldate. L'avviso di avvio in produzione è il vostro segnale.
- Postgres non disponibile. Il server si avvia e serve le richieste che non necessitano della coda; i sette scheduler pg-boss saltano le loro esecuzioni e riprovano più tardi; le richieste che necessitano della coda falliscono con grazia ed errori registrati. Un'interruzione transitoria all'avvio non manda il processo in crash-loop.
La posizione di progettazione è coerente: le dipendenze opzionali degradano verso comportamenti documentati, e l'interruzione di una dipendenza degrada una capacità anziché la piattaforma.
Da un'istanza a due
Far crescere il deployment è lavoro di configurazione, non di codice:
- Puntate ogni istanza allo stesso
DATABASE_URL,JWT_SECRET,COLLECTOR_SECRETeENCRYPTION_KEY. - Impostate
REDIS_URLsu ogni istanza, puntata a un Redis condiviso (istanza singola, cluster o sentinel). - Riavviate e confermate che il log di avvio non mostri l'avviso a istanza singola.
- Mettete le istanze dietro al vostro load balancer e instradate i health check su
GET /api/health, che riporta lo stato di database, collector e disco.
Un ultimo dettaglio multi-istanza che vale la pena conoscere: l'autenticazione con API key è una ricerca HMAC a chiave in O(1), non un ciclo bcrypt. Quel cambiamento è stato fatto proprio perché la verifica delle API key resti abbastanza economica da girare su ogni richiesta attraverso ogni istanza, senza cache condivisa né picchi di CPU per-istanza. È il tipo di decisione silenziosa che rende lo scale orizzontale noioso - che è esattamente come dovrebbe essere.


