Blog
Analyses techniques

Mettre Simmetric Chat à l'échelle sur plusieurs instances avec Redis et pg-boss

Simmetric Chat se scale horizontalement derrière n'importe quel load balancer, sans sessions sticky. Comment l'état migre vers Redis, Postgres et la file de jobs pg-boss.

Simmetric Team9 min de lecture03/09/2026

Une instance, c'est facile. Deux, c'est une décision de conception.

Exécuter une seule instance de Simmetric Chat est volontairement trivial : un processus serveur, un Postgres, et chaque portion d'état qui devrait autrement être partagée vit confortablement en mémoire. Dès que vous mettez une seconde instance derrière un load balancer, tout cet état in-process devient un problème de correction. Les compteurs de rate limit divergent, donc la limite effective se multiplie par le nombre d'instances. Un token révoqué sur l'instance A est encore accepté sur l'instance B. Un utilisateur en streaming connecté à l'instance B ne voit jamais les événements émis sur l'instance A. Aucun de ces défauts ne s'annonce - ils émergent plus tard, sous forme de rapports « la limite ne fonctionne pas » ou « la déconnexion ne me déconnecte pas partout ».

Simmetric Chat traite le scale horizontal comme un mode de déploiement de première classe. Le travail multi-instance, achevé à travers le milestone du layer Redis et inclus dans v0.22, externalise exactement ces portions d'état pour que n'importe quelle instance puisse servir n'importe quelle requête. Cet article parcourt la topologie, les cinq consommateurs Redis, la file de jobs pg-boss, les clés qui doivent coïncider sur toute une flotte et le comportement de graceful degradation qui garde un déploiement à instance unique pleinement pris en charge.

La topologie : instances identiques et stateless

La forme du déploiement est volontairement discrète. N processus serveur identiques se tiennent derrière n'importe quel load balancer HTTP ; un Postgres partagé héberge les données applicatives, pgvector et le schéma pgboss des jobs ; un Redis partagé conserve l'état de coordination cross-instance :

text
       Load balancer (un LB HTTP quelconque - aucune session sticky nécessaire)
           │
   ┌───────┼───────┐
Server 1  Server 2  …  Server N     processus identiques, :3000
   └───────┼───────┘
           │
   Postgres partagé        Redis partagé
   données app + pgvector  rate limit, liste de révocation,
   + schéma pgboss         relais SSE, locks, cache

Chaque instance exécute le même code et est stateless du point de vue du load balancer. Il n'existe aucune carte de session côté serveur qui ancre un client à une instance : l'authentification est un JWT stateless, signé HS256 avec un JWT_SECRET partagé et validé à chaque requête. Tout ce qui vivrait autrement dans la mémoire du processus est externalisé vers un emplacement déclaré :

  • Compteurs de rate limit → Redis (fenêtre partagée sur toute la flotte) ; sans Redis, chaque instance compte pour son compte
  • Révocation des JWT (liste `jti`) → Redis ; sans Redis la révocation n'a pas d'effet et les tokens restent valides jusqu'à leur expiration
  • Fan-out SSE → relais via pub/sub Redis ; sans Redis, les événements n'atteignent que les clients de l'instance qui les émet
  • Locks distribués (schedulers de reaping) → redlock sur Redis ; sans Redis le lock ne fait rien
  • Cache d'authentification et de configuration → Redis ; sans Redis, chaque lecture recourt à une requête en base
  • Jobs en arrière-plan → pg-boss, adossé au même Postgres partagé

Les sessions sticky ne sont pas nécessaires

Le load balancer n'a pas besoin d'affinité de session pour le trafic REST. Les seules connexions longue durée sont les flux SSE, et eux non plus n'ont pas besoin d'ancrage : un client garde une connexion en streaming vers l'instance qui l'a accepté, mais les événements émis sur n'importe quelle autre instance arrivent quand même, parce que chaque instance publie les événements SSE sur un canal Redis et chaque instance réémet les messages de ce canal vers ses propres clients connectés. Le relais est vérifié de bout en bout dans une simulation à deux instances, y compris le comportement d'origine-saut qui empêche une instance de recevoir à nouveau ses propres événements.

Si votre balancer offre le routage least-connections, préférez-le pour le chemin SSE - les connexions longue durée devraient être réparties selon le nombre de connexions ouvertes plutôt qu'en round-robin. C'est un choix d'efficacité, pas une exigence de correction. N'importe quel load balancer HTTP convient.

Les cinq consommateurs Redis

REDIS_URL est optionnel par choix, et les replis à instance unique sont un comportement pleinement pris en charge, pas un incident. Le multi-instance, en revanche, exige que chaque instance pointe vers le même Redis (ou cluster ou sentinel), parce que cinq sous-systèmes divergent sinon en silence :

  • Store des rate limits. Avec un store Redis partagé, une limite globale de 200 requêtes par minute signifie 200 sur tout le déploiement. Sans lui, chaque instance tient son propre compteur et la limite effective devient N fois celle configurée - un incident en attente d'un pic de trafic.
  • Révocation des JWT. La vérification de révocation consulte une liste noire qui doit être globale. Sans Redis, un token déconnecté ou révoqué administrativement continue de fonctionner sur les autres instances jusqu'à l'expiration ; « déconnecter partout » devient silencieusement « déconnecter ici ».
  • Fan-out SSE. Le relais cross-instance vit sur le pub/sub de Redis. Sans lui, un utilisateur dont le flux a atterri sur l'instance B ne voit jamais les événements émis sur l'instance A - le défaut se manifeste comme des mises à jour live mystérieusement bloquées.
  • Locks distribués. Les schedulers de maintenance en style reaper acquièrent un mutex cross-instance via redlock pour que deux instances n'exécutent jamais le même nettoyage en concurrence. Sans Redis le wrapper de lock ne fait rien - et c'est l'une des raisons pour lesquelles les jobs planifiés ont migré vers pg-boss, dont le dedup ne dépend pas d'un lock.
  • Cache d'authentification et de configuration. Les paramètres système et les caches de rôles sont partagés via Redis. Sans lui il n'y a pas de cache : chaque lecture est une requête Postgres fraîche, ce qui se manifeste comme une amplification de lectures exactement quand le trafic croît.
env
# .env racine - identique sur chaque instance
REDIS_URL=redis://redis-host:6379
# Variante TLS :
# REDIS_URL=rediss://redis-host:6379

Les clients parlent des URL Redis standard via ioredis. Un démarrage en production avec REDIS_URL non défini enregistre un avertissement sonore ; considérez cet avertissement comme le déploiement qui vous dit qu'il tourne en mode instance unique, quoi qu'en disent les instances.

pg-boss : la file de jobs sans infrastructure supplémentaire

Le travail en arrière-plan de Simmetric Chat - contrôles de cohérence des archives, reaping de la synthèse, nettoyage des vecteurs, health checks MCP, reaping MCP, rétention des messages de chat et nettoyage des brouillons d'upload - tourne sur pg-boss, une file de jobs adossée à Postgres. Sept schedulers basés sur setInterval ont migré vers des jobs planifiés pg-boss. pg-boss crée automatiquement son propre schéma pgboss dans la base que vous exécutez déjà pour les données applicatives, donc le scale horizontal n'ajoute aucun composant d'infrastructure et garde toute la stack compatible air-gap.

La propriété qui compte le plus pour les déploiements multi-instance est le dedup distribué des jobs : quand plusieurs instances atteignent toutes le même instant de déclenchement planifié, exactement une instance récupère le job et les autres sautent pour contention. La file, pas un wrapper de lock, est le mécanisme de dedup - une chose de moins à mal configurer.

Deux pollers sensibles à la latence n'ont volontairement pas migré. Le reaper de la pipeline OCR et le reaper de la pipeline de synthèse restent sur des intervalles in-process de 10 secondes, parce que la granularité minimale du cron de pg-boss est d'une minute et rendrait la reprise des pipelines six fois plus lente. Leur comportement multi-instance est documenté et sûr : les deux sont des cycles idempotents claim-puis-dispatch qui dédupliquent au niveau de la base avec des claims atomiques, pour que des instances concurrentes ne puissent pas réclamer deux fois le même job en attente, et le collector absorbe chaque dispatch redondant comme un no-op.

L'arrêt gracieux fait partie du contrat. Les jobs en vol sont drainés sous un grace timeout de pg-boss avant que la connexion à la base ne se ferme, et toute la séquence d'arrêt tourne sous un délai externe. Si Postgres est indisponible au démarrage ou tombe en cours d'exécution, les jobs pg-boss sont sautés avec une erreur enregistrée - le serveur n'entre pas en crash-loop, et la file se rétablit quand Postgres revient. Une panne de file ne doit jamais mettre hors ligne l'API de chat.

Les clés qui doivent coïncider sur la flotte

Deux secrets méritent une attention explicite quand vous passez d'une instance à plusieurs :

  • `JWT_SECRET` - la clé de signature symétrique pour les tokens de session stateless. La même valeur sur chaque instance est précisément ce qui rend vraie la propriété « n'importe quelle instance peut servir n'importe quelle requête ».
  • `ENCRYPTION_KEY` - la clé de chiffrement au repos AES-256-GCM (32 octets), requise en production, où le serveur échoue bruyamment au démarrage si elle manque. Chaque instance doit porter la même valeur, parce qu'un blob chiffré par une instance doit se déchiffrer sur toutes.

Garder les deux clés séparées est en soi une propriété de sécurité multi-instance. Faire tourner JWT_SECRET est bon marché - cela se limite à réémettre les tokens - tandis que faire tourner ENCRYPTION_KEY exige un runbook complet de re-chiffrement des données. L'ancien comportement consistant à dériver la clé de chiffrement du secret JWT couplait les deux rotations ; dans une flotte, ce couplage est une brique pour toute la flotte en attente d'une rotation de secrets de routine.

bash
# générez une fois, distribuez sur chaque instance
openssl rand -hex 32     # JWT_SECRET
openssl rand -base64 32  # ENCRYPTION_KEY

Le pool de connexions Postgres est le limiteur pratique de l'échelle. Chaque instance ouvre un pool applicatif borné plus un pool pg-boss séparé, donc dimensionner le pool de la base et max_connections de Postgres par rapport au nombre d'instances est la responsabilité de l'opérateur. Il n'y a pas de limite supérieure rigide au nombre d'instances ; commencez à deux et montez selon le besoin.

Ce qui casse quand une dépendance manque

La graceful degradation est un comportement conçu, et cela vaut la peine de la connaître avant le capacity planning plutôt qu'après un incident :

  • Redis absent, une instance. Tout fonctionne. Les SSE n'ont simplement nulle part où faire relais, les rate limits sont par-processus, les caches sont par-processus. C'est le défaut pris en charge à instance unique.
  • Redis absent, N instances. Les cinq divergences ci-dessus apparaissent en silence - rate limits multipliés, révocation par-instance, streaming cross-instance cassé, locks inertes et lectures en base surchauffées. L'avertissement de démarrage en production est votre signal.
  • Postgres indisponible. Le serveur démarre et sert les requêtes qui n'ont pas besoin de la file ; les sept schedulers pg-boss sautent leurs exécutions et réessaient plus tard ; les requêtes qui ont besoin de la file échouent gracieusement avec des erreurs enregistrées. Une panne transitoire au démarrage ne fait pas entrer le processus en crash-loop.

La position de conception est cohérente : les dépendances optionnelles dégradent vers des comportements documentés, et la panne d'une dépendance dégrade une capacité plutôt que la plateforme.

D'une instance à deux

Faire croître le déploiement est un travail de configuration, pas de code :

  1. Pointez chaque instance vers le même DATABASE_URL, JWT_SECRET, COLLECTOR_SECRET et ENCRYPTION_KEY.
  2. Définissez REDIS_URL sur chaque instance, pointé vers un Redis partagé (instance unique, cluster ou sentinel).
  3. Redémarrez et confirmez que le log de démarrage ne montre pas l'avertissement à instance unique.
  4. Placez les instances derrière votre load balancer et routez les health checks vers GET /api/health, qui rapporte l'état de la base, du collector et du disque.

Un dernier détail multi-instance qui vaut la peine d'être connu : l'authentification par API key est une recherche HMAC à clé en O(1), pas une boucle bcrypt. Ce changement a été fait précisément pour que la vérification des API keys reste assez bon marché pour tourner à chaque requête sur chaque instance, sans cache partagé ni pics de CPU par-instance. C'est le genre de décision silencieuse qui rend le scale horizontal ennuyeux - ce qui est exactement comme il devrait être.

Lire le guide complet dans la doc →

Articles associés