Aller au contenu

Magasin de sessions Redis sur ElastiCache — le patron qui survit au failover

Application sans état + sessions Redis ne fonctionne que si le pool de connexions, le TTL, le namespace des clés et le comportement de failover s'alignent. Voici le patron qui fonctionne avec connect-redis et iron-session.

L’application SaaS est passée de sessions collantes en mémoire à des sessions adossées à Redis il y a six mois. Il y a trois déploiements, l’application a commencé à perdre des sessions utilisateurs à chaque remplacement de nœud — failover, fenêtre de maintenance, événement de mise à l’échelle. Les utilisateurs étaient déconnectés en pleine action. L’équipe a blâmé Redis, puis le répartiteur de charge, puis la bibliothèque cliente. La cause réelle était trois petits désalignements qui se composaient : un timeout court sur le pool de connexions, des clés de session qui ne partageaient pas un slot de hash avec leurs données d’authentification, et une configuration connect-redis qui ne réessayait pas sur les erreurs READONLY. Chacune est une correction d’une ligne une fois qu’on les voit.

Ce qui se passe

Un magasin de sessions Redis a quatre pièces mobiles qui doivent s’accorder.

Pool de connexions. Le serveur applicatif maintient une connexion TCP (ou un pool) vers Redis. Les connexions survivent entre les requêtes. Pendant un failover, ces connexions TCP sont mortes — le primaire a bougé. Le client doit détecter rapidement la connexion morte, se reconnecter au nouveau primaire (souvent via le même endpoint DNS) et reprendre. Les timeouts de connexion par défaut dans plusieurs clients sont de 30 secondes. Avec un timeout de connexion de 30 secondes et un timeout client de 60 secondes, un failover peut bloquer les workers applicatifs pendant plus d’une minute, ce qui se propage en amont comme des timeouts 504.

Alignement du TTL. Les sessions ont un délai d’expiration — disons 24 heures glissantes. Le jeton d’authentification (JWT, cookie) a aussi une durée de vie. Si le cookie d’authentification survit à la session Redis, l’utilisateur présente un cookie valide mais le serveur ne trouve plus la session correspondante — déconnecté. Si la session Redis survit au cookie, vous gaspillez de la mémoire sur des sessions mortes. Le patron glissant (EXPIRE à chaque accès) n’est correct que si le cookie a aussi une durée de vie glissante qui correspond.

Namespace des clés. Une seule instance Redis peut contenir des sessions pour plusieurs environnements, tenants ou applications. Sans namespace, une session de préproduction peut entrer en collision avec la production. En mode cluster, le namespace doit aussi jouer avec les slots de hash — les patrons de clé multi-tenant comme sess:tenant:{tid}:user:{uid} doivent hasher toutes les clés liées d’un tenant vers le même fragment, sinon les ops MULTI/EXEC entre clés pour une session échoueront.

Comportement de failover. Pendant le failover automatique, les écritures sont rejetées pendant quelques secondes. Les clients reçoivent READONLY You can't write against a read only replica s’ils touchent l’ancien primaire maintenant rétrogradé en réplica, ou des refus de connexion durant la brève fenêtre où le nouveau primaire est en promotion. Le client doit réessayer — et réessayer avec backoff, pas dans une boucle serrée qui bombarde le cluster pendant qu’il se stabilise. Une bibliothèque qui ne réessaie pas fait apparaître le failover comme des 500 visibles à l’utilisateur.

Le patron qui fonctionne est l’alignement des quatre pièces : pool avec timeout de connexion court, TTL correspondant à la durée d’authentification, clés namespacées avec hash tags là où les ops multi-clés ont lieu, et un client qui réessaie READONLY et les erreurs de connexion avec backoff.

Comment vérifier

Testez les quatre pièces indépendamment avant de faire confiance à la pile complète.

Test du pool de connexions. Connectez-vous à Redis depuis le serveur applicatif et vérifiez la configuration du pool dans la bibliothèque cliente.

Pour ioredis (Node) :

const Redis = require('ioredis');
const redis = new Redis({
  host: 'saas-prod.abc123.ng.0001.use1.cache.amazonaws.com',
  port: 6379,
  connectTimeout: 5000,
  commandTimeout: 3000,
  enableReadyCheck: true,
  maxRetriesPerRequest: 3,
  retryStrategy: (times) => Math.min(times * 200, 2000),
  reconnectOnError: (err) => {
    const targetError = 'READONLY';
    if (err.message.includes(targetError)) return true;
    return false;
  },
});

Les quatre éléments qui comptent : connectTimeout: 5000 (5s, pas le défaut de 10s), commandTimeout: 3000 (pour qu’une commande bloquée ne verrouille pas un worker), maxRetriesPerRequest: 3 (pour que les échecs transitoires réessaient), et reconnectOnError retournant true sur READONLY (pour que le client lâche la connexion et se reconnecte au nouveau primaire).

Pour phpredis (PHP) :

$redis = new Redis();
$redis->connect(
    'saas-prod.abc123.ng.0001.use1.cache.amazonaws.com',
    6379,
    2.0,   // timeout de connexion (secondes)
    null,
    100,   // intervalle de réessai (ms)
    1.0    // timeout de lecture (secondes)
);
$redis->setOption(Redis::OPT_TCP_KEEPALIVE, 60);

Les workers PHP-FPM ne pool pas naturellement — chaque requête ouvre une connexion à moins d’utiliser pconnect. pconnect réutilise la connexion entre requêtes dans le même worker FPM, ce qui est désiré. Le coût est qu’au failover, la connexion persistante doit être réinitialisée.

Vérification de l’alignement du TTL. Pour une session avec expiration glissante de 24 heures, le Max-Age du cookie et le TTL de la clé Redis doivent correspondre.

redis-cli -h saas-prod.abc123.ng.0001.use1.cache.amazonaws.com \
  TTL sess:abc123

La sortie doit être près de 86 400 (24 heures) juste après une connexion, et rafraîchie près de 86 400 après chaque requête. Si vous voyez 3 600 (une heure), le magasin de sessions réécrit le TTL à chaque sauvegarde avec une valeur courte — le cookie survit à la session.

Test de failover. Déclenchez un failover planifié et observez l’application :

aws elasticache test-failover \
  --replication-group-id saas-prod \
  --node-group-id 0001

Lancez une charge constante contre l’application — wrk ou vegeta faisant une requête par seconde par worker, toutes touchant la session. Le comportement attendu est une fenêtre de 5–15 secondes où ~1 % des requêtes touchent un READONLY ou ECONNREFUSED, le client réessaie, et la requête complète sur le nouveau primaire. Si l’application fait remonter cela en 500 à l’utilisateur, la configuration de réessai du client est mauvaise.

La correction

Le patron de bout en bout qui fonctionne pour une application Node avec connect-redis :

const Redis = require('ioredis');
const session = require('express-session');
const RedisStore = require('connect-redis').default;

const redis = new Redis({
  host: process.env.REDIS_HOST,
  port: 6379,
  connectTimeout: 5000,
  commandTimeout: 3000,
  maxRetriesPerRequest: 3,
  retryStrategy: (times) => Math.min(times * 200, 2000),
  reconnectOnError: (err) => err.message.includes('READONLY'),
  keyPrefix: `sess:${process.env.ENV}:`,
});

app.use(session({
  store: new RedisStore({
    client: redis,
    prefix: 'sess:',
    ttl: 86400,
    disableTouch: false,
  }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  rolling: true,
  cookie: {
    maxAge: 86400 * 1000,
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
  },
}));

Trois éléments à noter. keyPrefix namespace le cluster par environnement pour que préproduction et production puissent partager une Redis (elles ne devraient pas, mais si elles le font les clés n’entrent pas en collision). rolling: true plus disableTouch: false et des ttl et cookie.maxAge accordés gardent le cookie et la session expirant en même temps. Le callback reconnectOnError gère le chemin d’erreur read-only-replica pendant le failover.

Pour iron-session (cookies sans état style JWT avec adossement Redis optionnel pour la révocation) :

import { getIronSession } from 'iron-session';

const sessionOptions = {
  password: process.env.IRON_SESSION_PASSWORD,
  cookieName: 'app_session',
  cookieOptions: {
    secure: true,
    httpOnly: true,
    sameSite: 'lax',
    maxAge: 86400,
  },
};

iron-session n’a pas besoin de Redis du tout si votre session est petite et que vous acceptez que « déconnexion partout » soit un problème d’expiration de cookie plutôt qu’une invalidation côté serveur. Si vous avez besoin de révocation côté serveur (fonction « déconnecter tous les appareils »), couplez iron-session à un ensemble de révocation Redis : à la déconnexion, SADD revoked:user:<uid> <jti> et à chaque requête vérifiez SISMEMBER. L’ensemble de révocation a son propre TTL correspondant à la durée de vie maximale du jeton.

Pour ElastiCache en mode cluster, la construction des clés de session compte. Utilisez sess:{<sid>}: pour que toutes les clés liées à une session atterrissent sur le même fragment. Si vous stockez aussi un état d’authentification (auth:{<sid>}:profile), le hash tag les aligne — le bloc MULTI/EXEC que connect-redis émet pendant l’écriture de session fonctionnera.

La dernière vérification est le cycle de vie des connexions au redémarrage de l’application. Les redémarrages sous charge ne doivent pas inonder Redis de nouvelles connexions en stampede. ioredis fait cela naturellement — les connexions sont créées paresseusement et réutilisées. phpredis avec pconnect fait cela naturellement par worker FPM. La chose à éviter est d’ouvrir une connexion fraîche à chaque requête, ce qui en FPM signifie éviter connect au profit de pconnect.

Une dernière leçon à retenir : ne mettez pas cache et sessions sur le même Redis sans réfléchir à l’éviction. Les nœuds ElastiCache évictent sous volatile-lru ou allkeys-lru par défaut selon le groupe de paramètres — si une clé de session se trouve sélectionnée pour éviction pendant une pression mémoire, l’utilisateur est déconnecté. Le patron défensif est un groupe de réplication séparé pour les sessions (avec noeviction ou volatile-lru) et un groupe de réplication séparé pour le cache de réponses jetables (avec allkeys-lru). Les deux ont des caractéristiques opérationnelles très différentes — ne partagez jamais un nœud.


Stack Harbor conçoit les topologies Redis dans le cadre du travail sur le palier cluster en Gestion AWS et de la couche Clusters HA. Si votre application perd encore les sessions à chaque redémarrage du cache, commencez par une consultation d’infrastructure.