<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Redis on Guillaume Delré</title><link>https://guillaumedelre.github.io/fr/tags/redis/</link><description>Recent content in Redis on Guillaume Delré</description><generator>Hugo</generator><language>fr-FR</language><lastBuildDate>Sat, 16 May 2026 15:00:00 +0000</lastBuildDate><atom:link href="https://guillaumedelre.github.io/fr/tags/redis/index.xml" rel="self" type="application/rss+xml"/><item><title>Le cache qui nous mentait</title><link>https://guillaumedelre.github.io/fr/2026/05/16/le-cache-qui-nous-mentait/</link><pubDate>Sat, 16 May 2026 15:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/05/16/le-cache-qui-nous-mentait/</guid><description>Part 6 of 8 in &amp;quot;Symfony vers le Cloud : Douze Facteurs, Treize Services&amp;quot;: Comment une seule ligne de config bloquait le scaling horizontal de 13 microservices Symfony, et ce que la méthodologie twelve-factor avait à dire là-dessus.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>La première fois qu&rsquo;on a lancé deux replicas du même service Symfony derrière un load balancer, tout avait l&rsquo;air d&rsquo;aller. Les health checks passaient. Le trafic se répartissait proprement. Les temps de réponse étaient bons.</p>
<p>Puis quelqu&rsquo;un a remarqué que le rate limiter se comportait bizarrement. Cinq appels à l&rsquo;API, accès bloqué. Cinq appels supplémentaires à la requête suivante, accès accordé. Selon quel pod répondait, on était une personne différente.</p>
<p>C&rsquo;était le cache qui parlait. Une ligne de config, répliquée sur treize services, bloquait le scaling horizontal dans sa totalité.</p>
<h2 id="un-fichier-de-config-treize-fois">Un fichier de config, treize fois</h2>
<p>On préparait une plateforme de treize microservices Symfony pour passer sur Kubernetes. La stack était déjà en bon état : FrankenPHP pour le serveur HTTP, des Dockerfiles multi-étapes, un GitLab CI qui poussait des images taguées vers un registre cloud. Les pièces étaient là. Il fallait juste vérifier que rien ne casserait quand on commencerait à scaler les pods horizontalement.</p>
<p>Une bonne checklist pour ce type d&rsquo;audit, c&rsquo;est la <a href="https://12factor.net" target="_blank" rel="noopener noreferrer">méthodologie twelve-factor app</a> — douze principes pour construire des logiciels qui tournent proprement dans des environnements cloud. La plupart des facteurs étaient déjà couverts sans qu&rsquo;on y ait pensé délibérément.</p>
<p>Le Facteur VII (port binding) était gratuit. FrankenPHP embarque Caddy directement dans le processus PHP. Le container expose son propre endpoint HTTP, sans Apache ni Nginx à ajouter. L&rsquo;image est autonome, ce que le facteur demande exactement :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#66d9ef">HEALTHCHECK</span> --start-period<span style="color:#f92672">=</span>60s CMD curl -f http://localhost:2019/metrics <span style="color:#f92672">||</span> exit <span style="color:#ae81ff">1</span><span style="color:#960050;background-color:#1e0010">
</span></span></span></code></pre></div><p>Le Facteur II (dépendances) était géré par <code>composer.json</code> et les extensions du Dockerfile. Le Facteur X (parité dev/prod) était suffisamment couvert pour notre périmètre : même image, mêmes backing services en local et en CI, ce qui est la partie qui compte vraiment pour l&rsquo;audit.</p>
<p>Puis j&rsquo;en suis arrivé au Facteur VI.</p>
<h2 id="le-problème-avec--ça-marche-sur-un-seul-serveur-">Le problème avec « ça marche sur un seul serveur »</h2>
<p>Le Facteur VI dit que les processus ne doivent rien partager. Rien d&rsquo;écrit sur disque entre les requêtes, rien en mémoire locale qu&rsquo;une autre instance ne puisse pas voir. Si on a besoin de persister de l&rsquo;état, on le met dans un backing service — une base de données, un cluster de cache, une queue. Le processus lui-même reste jetable.</p>
<p>J&rsquo;ai ouvert <code>authentication/config/packages/cache.yaml</code>. Puis <code>content/config/packages/cache.yaml</code>. Puis <code>media/config/packages/cache.yaml</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">framework</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">cache</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">app</span>: <span style="color:#ae81ff">cache.adapter.filesystem</span>
</span></span></code></pre></div><p>Treize services. Treize fois, mot pour mot.</p>
<p>Chaque instance de chaque service écrivait son cache sur le filesystem local. Ce qui signifiait que chaque pod avait son propre cache privé, invisible pour tous les autres pods. Quand le load balancer envoyait une requête au pod A, il obtenait la version mise en cache par le pod A. Le pod B avait construit la sienne. Elles pouvaient avoir été générées à des moments différents, depuis des données sources différentes, ou l&rsquo;une d&rsquo;elles pouvait ne pas encore avoir été construite du tout.</p>
<p>Le rate limiter était le symptôme le plus visible parce qu&rsquo;il avait un compteur. Mais la même divergence affectait chaque donnée qu&rsquo;on mettait en cache : métadonnées du sérialiseur, collections de routes, caches de résultats Doctrine. Deux utilisateurs envoyant des requêtes identiques pouvaient obtenir des réponses différentes selon quel nœud avait récupéré la connexion.</p>
<h2 id="redis-était-déjà-là">Redis était déjà là</h2>
<p>C&rsquo;est la partie qui pique un peu. Redis était déjà dans la stack. Chaque service l&rsquo;avait configuré via SncRedisBundle :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># config/packages/snc_redis.yaml — présent sur les 13 services</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">snc_redis</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">clients</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">default</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">type</span>: <span style="color:#e6db74">&#39;phpredis&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">alias</span>: <span style="color:#e6db74">&#39;default&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">dsn</span>: <span style="color:#e6db74">&#39;%env(IN_MEM_STORE__URI)%&#39;</span>
</span></span></code></pre></div><p>Le Facteur IV de la twelve-factor app dit que les backing services doivent être des ressources attachées, interchangeables via la configuration. Redis était exactement ça : joignable via une variable d&rsquo;environnement, prêt à être remplacé par une instance managée dans le cloud. La plomberie était faite. On ne s&rsquo;en servait juste pas pour le cache applicatif.</p>
<p>Certains services l&rsquo;avaient même juste pour des pools spécifiques. Le rate limiter dans le service d&rsquo;authentification :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">pools</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">rate_limiter.cache</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">adapter</span>: <span style="color:#ae81ff">cache.adapter.redis</span>
</span></span></code></pre></div><p>Ce qui explique l&rsquo;incohérence qu&rsquo;on a vue en premier. Le <em>compteur</em> du rate limit allait vers Redis (partagé entre les pods). Le cache qui alimentait la <em>vérification</em> du rate limit allait vers le filesystem (local au pod). Deux sources de vérité, l&rsquo;une invisible à l&rsquo;autre.</p>
<p>La correction tenait en une ligne par service :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">framework</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">cache</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">app</span>: <span style="color:#ae81ff">cache.adapter.redis</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">default_redis_provider</span>: <span style="color:#ae81ff">snc_redis.default</span>
</span></span></code></pre></div><p>Treize fichiers. Treize changements identiques. Le genre de correction qui donne l&rsquo;impression qu&rsquo;on aurait dû la repérer avant, sauf qu&rsquo;elle est parfaitement invisible quand on tourne sur une seule instance.</p>
<h2 id="ce-qui-doit-migrer-vers-redis">Ce qui doit migrer vers Redis</h2>
<p>Le cache filesystem violait le Facteur VI (les processus portent de l&rsquo;état local qu&rsquo;ils ne devraient pas) et le Facteur VIII (on ne peut pas scaler sans partager cet état). C&rsquo;est le même problème vu sous deux angles : VI décrit ce qui ne va pas, VIII décrit ce qu&rsquo;on ne peut pas faire à cause de ça.</p>
<p>Avec un backend de cache partagé, un deuxième pod est sûr. Les deux pods construisent le même cache, voient les mêmes invalidations, s&rsquo;accordent sur les mêmes limites de rate. On peut ajouter un troisième pod sous charge et le retirer quand le trafic baisse. L&rsquo;orchestrateur s&rsquo;en occupe ; l&rsquo;application n&rsquo;a pas besoin de le savoir.</p>
<p>Sans ça, le scaling horizontal est un risque. Plus de pods, c&rsquo;est plus de divergence, plus de bugs « ça marche chez moi » qu&rsquo;il est impossible de reproduire en local parce qu&rsquo;en local on tourne avec un seul container.</p>
<p>Les sessions avaient le même problème — et potentiellement pire. Douze des treize services utilisaient <code>session.storage.factory.native</code> — qui écrit les sessions sur le filesystem par défaut. Un utilisateur dont la requête atterrit sur le pod A obtient une session liée au pod A. Sa requête suivante va sur le pod B. Session perdue, il est déconnecté. Un seul service avait <code>RedisSessionHandler</code> configuré.</p>
<p>La mitigation partielle : la plupart de la plateforme tourne sur des APIs stateless avec des JWT, donc l&rsquo;usage des sessions est limité. Mais « limité » n&rsquo;est pas « zéro ». Les services qui créent des sessions — flows d&rsquo;authentification, état temporaire pendant les handshakes OAuth — ont un mode de défaillance visible par l&rsquo;utilisateur qui attend le deuxième pod. Soit ces sessions migrent vers Redis, soit le code qui les crée est supprimé. Les laisser en l&rsquo;état est une décision qui attend le premier utilisateur dont la session disparaît sans explication.</p>
<h2 id="lautre-genre-détat">L&rsquo;autre genre d&rsquo;état</h2>
<p>Redis résout le problème cross-pod. FrankenPHP introduit un autre problème qu&rsquo;il vaut la peine de connaître.</p>
<p>Dans le modèle PHP-FPM standard, chaque requête forke un processus frais. Tout objet en mémoire — toute valeur mise en cache, tout résultat calculé — meurt avec la réponse. Le processus est stateless par construction.</p>
<p>FrankenPHP a un mode worker qui ne suit pas ce modèle. En mode worker, un seul processus PHP démarre une fois, charge le kernel, câble le container, et gère plusieurs requêtes successives sans redémarrer. Le débit de requêtes s&rsquo;améliore : pas de cold start de l&rsquo;autoloader, pas de rebuild du container par requête, moins d&rsquo;allocations. La contrepartie : le processus PHP a maintenant un cycle de vie qui enjambe les requêtes.</p>
<p>Pour le cache, ça ajoute une complexité. Un adaptateur <code>array</code> ou un pool APCu accumule des entrées à travers les requêtes sur le même worker. Une invalidation de cache poussée vers Redis atteint immédiatement les autres pods — mais ne vide pas ce qui est assis dans la mémoire du worker. Deux requêtes sur le même pod peuvent voir des choses différentes : l&rsquo;une touche une entrée en mémoire chaude, la suivante déclenche un fetch Redis après expiration de l&rsquo;entrée in-process.</p>
<p>La plateforme garde le mode worker désactivé (<code>APP__WORKER_MODE__ENABLED=false</code>). Il est disponible — l&rsquo;infrastructure est là, le flag est câblé — mais pas actif. Le gain de performance ne justifiait pas l&rsquo;audit. Chaque pool de cache aurait besoin d&rsquo;être vérifié contre la sémantique du mode worker ; chaque endroit où de l&rsquo;état fuit entre les requêtes deviendrait un bug potentiel.</p>
<p>La position conservatrice : garder PHP stateless au niveau du processus même quand le runtime ne l&rsquo;exige pas. Le principe shared-nothing du Facteur VI s&rsquo;applique non seulement au filesystem — il s&rsquo;applique au processus lui-même.</p>
<h2 id="ce-qui-fonctionnait-déjà">Ce qui fonctionnait déjà</h2>
<p>Pour être juste envers la codebase : le Scheduler Symfony utilisait déjà Redis pour les locks distribués :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$schedule<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">lock</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">lockFactory</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">createLock</span>(<span style="color:#e6db74">&#39;schedule_purge&#39;</span>));
</span></span></code></pre></div><p>Dans un environnement multi-pod, on ne veut pas cinq instances lancer le même job de purge simultanément. Le lock l&rsquo;empêche. Redis rend le lock visible entre les pods. Celui qui a écrit le scheduler savait exactement ce qu&rsquo;il faisait.</p>
<p>Le même raisonnement ne s&rsquo;était juste pas propagé à la configuration du cache — probablement parce qu&rsquo;en tournant sur une seule instance, <code>cache.adapter.filesystem</code> est invisible. Ça fonctionne, c&rsquo;est rapide, ça ne demande aucune configuration. Le problème n&rsquo;apparaît qu&rsquo;à deux.</p>
<h2 id="les-quatre-questions">Les quatre questions</h2>
<p>Le Facteur VI prend la plupart des applications par surprise lors d&rsquo;une migration cloud. Pas parce que les développeurs ne connaissent pas les processus stateless — ils le savent généralement — mais parce que le filesystem est toujours là, et le problème reste caché jusqu&rsquo;à ce qu&rsquo;on essaie de lancer une deuxième instance.</p>
<p>Avant de scaler un service Symfony horizontalement, quatre questions méritent une réponse :</p>
<ul>
<li>Où va le cache applicatif ? (<code>cache.adapter.filesystem</code> doit devenir <code>cache.adapter.redis</code>)</li>
<li>Où vont les sessions ? (<code>session.storage.factory.native</code> a besoin de Redis — ou supprimer les sessions entièrement si on est full JWT)</li>
<li>Est-ce que quelque chose écrit dans <code>var/</code> à l&rsquo;exécution qu&rsquo;un autre pod aurait besoin de lire ?</li>
<li>Est-ce qu&rsquo;il y a quelque chose dans le chemin de code qui doit être mutuellement exclusif entre pods ? (si oui, c&rsquo;est un job pour le <a href="https://symfony.com/doc/current/components/lock.html" target="_blank" rel="noopener noreferrer">composant Lock de Symfony</a> adossé à Redis, pas un mutex local)</li>
</ul>
<p>Si toutes les réponses pointent vers des backing services partagés, on est prêt. Si l&rsquo;une d&rsquo;elles pointe vers le filesystem local, la production finira par trouver le pod qui a construit son cache il y a trois heures et le servira à l&rsquo;utilisateur qui s&rsquo;y attend le moins.</p>
]]></content:encoded></item></channel></rss>