<?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>12factor on Guillaume Delré</title><link>https://guillaumedelre.github.io/fr/tags/12factor/</link><description>Recent content in 12factor on Guillaume Delré</description><generator>Hugo</generator><language>fr-FR</language><lastBuildDate>Sun, 17 May 2026 15:00:00 +0000</lastBuildDate><atom:link href="https://guillaumedelre.github.io/fr/tags/12factor/index.xml" rel="self" type="application/rss+xml"/><item><title>Onze sur douze</title><link>https://guillaumedelre.github.io/fr/2026/05/17/onze-sur-douze/</link><pubDate>Sun, 17 May 2026 15:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/05/17/onze-sur-douze/</guid><description>Part 8 of 8 in &amp;quot;Symfony vers le Cloud : Douze Facteurs, Treize Services&amp;quot;: Onze facteurs résolus proprement. Le douzième : des migrations Doctrine dans l&amp;#39;entrypoint, en attente d&amp;#39;une question de gouvernance que le code seul ne peut pas trancher.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>Le <code>composer.json</code> de chaque service avait ça dans sa section <code>post-install-cmd</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-json" data-lang="json"><span style="display:flex;"><span><span style="color:#e6db74">&#34;post-install-cmd&#34;</span><span style="color:#960050;background-color:#1e0010">:</span> [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;bin/console cache:clear --env=prod&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;bin/console doctrine:migrations:migrate --no-interaction&#34;</span>
</span></span><span style="display:flex;"><span>]
</span></span></code></pre></div><p><code>post-install-cmd</code> s&rsquo;exécute pendant <code>composer install</code>, qui dans le Dockerfile de production tourne au moment du build de l&rsquo;image. Il n&rsquo;y a pas de base de données disponible pendant un build Docker. La commande de migration échouait silencieusement, se connectait à rien, ou était ignorée par Doctrine faute de schéma à comparer. Dans tous les cas, elle ne migrait rien.</p>
<p>C&rsquo;est une violation nette du <a href="https://12factor.net/admin-processes" target="_blank" rel="noopener noreferrer">Facteur XII</a>
 : les processus d&rsquo;administration — migrations, scripts ponctuels, commandes console — doivent s&rsquo;exécuter dans le même environnement que l&rsquo;application, contre les vraies données de production. Les faire tourner au build inverse la relation. L&rsquo;image ne devrait pas savoir qu&rsquo;il y a une base de données. La base devrait être là quand l&rsquo;image en a besoin.</p>
<h2 id="le-déplacement-vers-lentrypoint">Le déplacement vers l&rsquo;entrypoint</h2>
<p>La commande de migration a quitté <code>composer.json</code> pour <code>docker-entrypoint.sh</code>. Le changement semble petit dans un diff. Les implications ne le sont pas.</p>
<p>L&rsquo;entrypoint s&rsquo;exécute quand le container démarre, pas quand l&rsquo;image est construite. La base de données est accessible. L&rsquo;entrypoint l&rsquo;attend — jusqu&rsquo;à 60 secondes, une tentative par seconde — avant de faire quoi que ce soit :</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-sh" data-lang="sh"><span style="display:flex;"><span>ATTEMPTS_LEFT_TO_REACH_DATABASE<span style="color:#f92672">=</span><span style="color:#ae81ff">60</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">until</span> <span style="color:#f92672">[</span> $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq <span style="color:#ae81ff">0</span> <span style="color:#f92672">]</span> <span style="color:#f92672">||</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  DATABASE_ERROR<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>php bin/console dbal:run-sql -q <span style="color:#e6db74">&#34;SELECT 1&#34;</span> 2&gt;&amp;1<span style="color:#66d9ef">)</span>; <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>    sleep <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    ATTEMPTS_LEFT_TO_REACH_DATABASE<span style="color:#f92672">=</span><span style="color:#66d9ef">$((</span>ATTEMPTS_LEFT_TO_REACH_DATABASE <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span><span style="color:#66d9ef">))</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">done</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq <span style="color:#ae81ff">0</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;</span>$DATABASE_ERROR<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    exit <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><p>Si la base ne répond pas dans les 60 secondes, le container sort en erreur et Kubernetes le redémarre. Une fois la base prête, la migration tourne :</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-sh" data-lang="sh"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span> find ./migrations -iname <span style="color:#e6db74">&#39;*.php&#39;</span> -print -quit <span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><p>Deux changements par rapport à la commande d&rsquo;origine : <code>--all-or-nothing</code> garantit que si une migration dans un batch échoue, le batch entier est annulé. Et le guard <code>find</code> passe la commande si aucun fichier de migration n&rsquo;existe — utile pour les services qui n&rsquo;utilisent pas les migrations Doctrine.</p>
<p>C&rsquo;est franchement mieux. La base est là. La migration tourne dans le vrai environnement. Le flag <code>--all-or-nothing</code> apporte une atomicité que la version au build n&rsquo;avait jamais eue.</p>
<h2 id="ce-que-ça-ne-résout-pas">Ce que ça ne résout pas</h2>
<p>Deux pods qui redéploient simultanément exécutent tous les deux l&rsquo;entrypoint. Tous les deux atteignent la base. Tous les deux trouvent des migrations en attente. Tous les deux appellent <code>doctrine:migrations:migrate</code>.</p>
<p>Doctrine a un mécanisme de verrouillage : une table <code>doctrine_migration_versions</code> qui enregistre quelles migrations ont tourné, et la commande la consulte avant d&rsquo;appliquer quoi que ce soit. Dans les conditions normales c&rsquo;est suffisant : le deuxième pod trouve la table à jour et sort proprement. Les cas de défaillance réels sont plus précis : une migration assez longue pour dépasser le timeout du verrou de base de données, ce qui laisse un deuxième runner démarrer la même migration avant que le premier ait terminé ; ou un pod qui se vautre à mi-migration avant d&rsquo;avoir enregistré la version dans la table, laissant le schéma dans un état appliqué-mais-non-enregistré que le pod suivant va tenter d&rsquo;appliquer à nouveau.</p>
<p>La position de l&rsquo;équipe est explicite : un downtime léger au déploiement est acceptable. Les versions d&rsquo;application ne sont pas nécessairement compatibles avec des versions de schéma plus anciennes, donc faire tourner N et N+1 simultanément contre la même base ne serait de toute façon pas sûr. La stratégie de déploiement est Recreate : tous les anciens pods sont terminés avant que les nouveaux ne démarrent. La migration tourne au premier démarrage, sans chevauchement entre les versions. Ça fonctionne.</p>
<p>Mais &ldquo;ça fonctionne&rdquo; et &ldquo;c&rsquo;est la bonne architecture&rdquo; sont deux réponses différentes.</p>
<h2 id="ce-que-feraient-les-alternatives">Ce que feraient les alternatives</h2>
<p>Le <a href="https://12factor.net/admin-processes" target="_blank" rel="noopener noreferrer">Facteur XII</a>
 dit que les processus d&rsquo;administration doivent tourner dans des &ldquo;processus ponctuels&rdquo;. Un processus qui tourne une fois, dans un but précis, contre l&rsquo;environnement de production. L&rsquo;entrypoint n&rsquo;est pas ponctuel — il tourne à chaque démarrage de container, y compris les redémarrages, les événements de scaling, et les déplacements de pods par Kubernetes.</p>
<p>Trois alternatives existent, chacune avec une réponse différente à la question de la propriété :</p>
<p><strong>Un init container Kubernetes</strong> tourne avant le container principal, dans le même pod. Il peut exécuter la migration, sortir, et laisser le container principal démarrer seulement après son succès. La migration est isolée du runtime applicatif. Le problème : l&rsquo;init container est une image supplémentaire à construire et maintenir, et il tourne à chaque démarrage de pod — une plateforme de 14 services démarrant simultanément a toujours une race potentielle.</p>
<p><strong>Un Kubernetes Job</strong> tourne une fois, à la demande ou déclenché par le pipeline de déploiement. Il peut être configuré pour s&rsquo;exécuter avant la mise à jour des pods — séquentiel, isolé, avec un signal clair de succès ou d&rsquo;échec. La race condition disparaît. La complexité se déplace vers le processus de déploiement : le Job doit se terminer avant que le rollout du Deployment commence, et le pipeline CI doit coordonner les deux.</p>
<p><strong>Un hook Helm</strong> est le même concept exprimé de façon déclarative dans le chart Helm. Un hook <code>pre-upgrade</code> exécute la migration avant la mise à jour des pods applicatifs. C&rsquo;est la réponse la plus idiomatique pour Kubernetes. Ça signifie aussi que le chart Helm est désormais responsable de l&rsquo;exécution des migrations — une décision qui appartient à qui possède le chart.</p>
<p>Cette dernière phrase explique pourquoi l&rsquo;entrypoint n&rsquo;a pas changé. Déplacer les migrations hors de l&rsquo;application signifie décider que l&rsquo;infrastructure de déploiement — pas l&rsquo;application elle-même — est responsable du schéma. C&rsquo;est une question de gouvernance autant que de technique, et les questions de gouvernance prennent plus de temps à résoudre que les changements de code.</p>
<h2 id="la-fin-honnête">La fin honnête</h2>
<p>Le bloc de migration dans l&rsquo;entrypoint, c&rsquo;est deux lignes. Littéralement : le guard <code>if [ &quot;$( find ./migrations... )&quot; ]</code>, et le <code>php bin/console doctrine:migrations:migrate</code> qui suit. Onze autres facteurs ont des résolutions nettes. Le cache est passé sur Redis. Les logs vont vers stdout. Le système de fichiers est un bucket S3. Le CI assemble les images de production depuis le même commit qu&rsquo;il teste. Les secrets ne voyagent plus dans les layers d&rsquo;image.</p>
<p>Le Facteur XII a une réponse. Ce n&rsquo;est juste pas la réponse finale.</p>
<p>Les migrations tournent au démarrage, avec une vraie base de données, avec atomicité, avec une fenêtre de retry bornée. C&rsquo;est mieux que de tourner au build contre rien. La question de savoir si elles finiront dans un Job ou un hook Helm est une conversation sur qui possède le schéma — une question à laquelle un <code>kubectl apply</code> ne peut pas répondre.</p>
]]></content:encoded></item><item><title>Démarré ne veut pas dire prêt</title><link>https://guillaumedelre.github.io/fr/2026/05/17/d%C3%A9marr%C3%A9-ne-veut-pas-dire-pr%C3%AAt/</link><pubDate>Sun, 17 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/05/17/d%C3%A9marr%C3%A9-ne-veut-pas-dire-pr%C3%AAt/</guid><description>Part 7 of 8 in &amp;quot;Symfony vers le Cloud : Douze Facteurs, Treize Services&amp;quot;: Le script d&amp;#39;entrypoint qui fonctionne parfaitement sous Docker Compose a cinq responsabilités. Sur Kubernetes, chacune d&amp;#39;elles a sa place ailleurs.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>Le rolling deploy avait l&rsquo;air propre. Un nouveau pod démarrait. Kubernetes voyait le healthcheck passer — <code>php -v</code> renvoyait zéro — et commençait à router du trafic vers le nouveau container.</p>
<p>Pendant les quarante secondes suivantes — sur les soixante possibles — ce container était en train de poller la base de données.</p>
<p>Les requêtes qui atterrissaient dessus pendant cette fenêtre récoltaient des erreurs. Pas beaucoup — la fenêtre était courte — mais assez pour apparaître comme du bruit dans le monitoring. Le genre de bruit qu&rsquo;on classe comme « problème réseau transitoire » et qu&rsquo;on ne signale nulle part. Le déploiement a réussi. Le pod a fini par devenir prêt. Le mécanisme qui en était la cause était toujours là, attendant le prochain déploiement.</p>
<p>Le script d&rsquo;entrypoint fait cinq choses avant que FrankenPHP démarre : copier un fichier de version, vérifier le répertoire vendor, attendre jusqu&rsquo;à soixante secondes la base de données, jouer les migrations en attente, installer les assets et configurer les permissions filesystem. Sous Docker Compose, c&rsquo;est invisible. Sur Kubernetes, l&rsquo;écart devient du trafic en erreur.</p>
<h2 id="lécart-entre-démarré-et-prêt">L&rsquo;écart entre démarré et prêt</h2>
<p>Kubernetes décide d&rsquo;envoyer du trafic à un pod en surveillant sa readiness probe. Un pod dont la readiness probe passe reçoit des requêtes. Un pod dont la readiness probe échoue est retiré de la rotation du load balancer jusqu&rsquo;à ce qu&rsquo;il récupère. C&rsquo;est le mécanisme qui rend les rolling deploys sûrs : Kubernetes ne bascule pas vers un nouveau pod tant que ce pod n&rsquo;indique pas qu&rsquo;il est prêt.</p>
<p>Le compose.yaml définit un healthcheck sur chaque 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">healthcheck</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">test</span>: [ <span style="color:#e6db74">&#34;CMD&#34;</span>, <span style="color:#e6db74">&#34;php&#34;</span>, <span style="color:#e6db74">&#34;-v&#34;</span> ]
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">interval</span>: <span style="color:#ae81ff">30s</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">timeout</span>: <span style="color:#ae81ff">10s</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">retries</span>: <span style="color:#ae81ff">3</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">start_period</span>: <span style="color:#ae81ff">10s</span>
</span></span></code></pre></div><p><code>php -v</code> réussit dès que le binaire PHP est présent — ce qui est vrai depuis la première milliseconde de vie du container. Le <code>start_period: 10s</code> donne dix secondes avant que les vérifications commencent. Mais la boucle de polling de l&rsquo;entrypoint tourne jusqu&rsquo;à soixante secondes avant que FrankenPHP démarre. À la dixième seconde, le healthcheck passe. L&rsquo;application attend toujours la base de données.</p>
<p>Le Dockerfile a un meilleur signal :</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 port 2019 est le serveur de métriques intégré à Caddy, embarqué directement dans FrankenPHP. L&rsquo;endpoint est compatible Prometheus et ne répond qu&rsquo;une fois que la stack HTTP de Caddy est pleinement initialisée et que les workers PHP acceptent des connexions. <code>php -v</code> se termine en cinquante millisecondes quel que soit l&rsquo;état de l&rsquo;application — il vérifie le binaire, pas le serveur. <code>:2019/metrics</code> ne répond que quand le serveur sert vraiment. Ce n&rsquo;est pas non plus un endpoint ajouté exprès pour la probe : chaque service de la plateforme l&rsquo;a déjà scraped par Prometheus, donc le signal est actif indépendamment de toute configuration de healthcheck.</p>
<p>C&rsquo;est plus proche. Mais sur Kubernetes, l&rsquo;instruction <code>HEALTHCHECK</code> du Dockerfile est totalement ignorée. Kubernetes utilise sa propre configuration de probes. Sans définitions de probes explicites dans les manifests Kubernetes, il n&rsquo;y a aucune vérification de readiness — et un pod est considéré prêt dès que son container démarre.</p>
<p>Ce qui signifie : le pod démarre, l&rsquo;entrypoint commence à poller, Kubernetes route du trafic, l&rsquo;application n&rsquo;est pas encore en état de le traiter. Les requêtes arrivent sur un container qui n&rsquo;est pas prêt à les gérer.</p>
<h2 id="trois-signaux-trois-questions">Trois signaux, trois questions</h2>
<p>Kubernetes sépare le cycle de vie d&rsquo;un container en trois questions distinctes, chacune avec son propre type de probe :</p>
<p><strong>startupProbe</strong> — « L&rsquo;application a-t-elle fini de démarrer ? » Se déclenche à répétition jusqu&rsquo;à ce qu&rsquo;elle passe, puis passe la main à la liveness. Empêche la liveness probe de tuer un container qui est légitimement long à initialiser. Pour un container dont l&rsquo;entrypoint peut prendre soixante secondes, c&rsquo;est l&rsquo;outil adapté.</p>
<p><strong>readinessProbe</strong> — « L&rsquo;application est-elle prête à traiter des requêtes ? » Échoue et passe tout au long de la vie du container. Quand elle échoue, le pod est retiré du load balancer. C&rsquo;est ce qui rend un rolling deploy sûr.</p>
<p><strong>livenessProbe</strong> — « L&rsquo;application est-elle toujours vivante ? » Si elle échoue, Kubernetes redémarre le container. Conçue pour détecter les processus bloqués, pas les démarrages lents.</p>
<p>La boucle de polling de soixante secondes appartient à la patience de la startupProbe, pas au code applicatif :</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">startupProbe</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">httpGet</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">path</span>: <span style="color:#ae81ff">/metrics</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">port</span>: <span style="color:#ae81ff">2019</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">failureThreshold</span>: <span style="color:#ae81ff">12</span>    <span style="color:#75715e"># 12 tentatives × 5s = 60s max</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">periodSeconds</span>: <span style="color:#ae81ff">5</span>
</span></span></code></pre></div><p>Une fois la startupProbe passée, une readinessProbe sur le même endpoint prend le relais — indiquant à Kubernetes quand le pod peut recevoir du trafic — et une livenessProbe surveille les processus bloqués. Mais c&rsquo;est la startupProbe qui absorbe le démarrage lent. La boucle de polling de l&rsquo;entrypoint devient redondante : son rôle était de maintenir le container en vie pendant que la base de données devenait disponible. Sans elle, l&rsquo;application tente de se connecter, échoue, et le container quitte — Kubernetes redémarre alors le pod, et la startupProbe maintient son cycle de tentatives jusqu&rsquo;à ce que la base réponde et que l&rsquo;application démarre proprement. La responsabilité du retry passe de l&rsquo;intérieur de l&rsquo;entrypoint à l&rsquo;orchestrateur, ce qui est exactement là où elle devrait être.</p>
<h2 id="le-problème-des-migrations">Le problème des migrations</h2>
<p>La boucle de polling est le problème le plus visible, mais les migrations en créent un plus subtil.</p>
<p>Avec un rolling deploy et deux replicas, Kubernetes démarre un nouveau pod pendant que l&rsquo;ancien sert encore du trafic. Les deux pods jouent le même entrypoint. Les deux atteignent <code>doctrine:migrations:migrate</code>.</p>
<p>La table de migrations de Doctrine trace quelles migrations ont déjà été exécutées, donc une migration complétée ne se jouera pas deux fois. Mais si deux pods démarrent simultanément et voient tous les deux une migration en attente, les deux tentent de la jouer en même temps. Si c&rsquo;est sûr ou non dépend de la migration : les changements de schéma additifs passent en général bien ; les destructifs moins. Et on ne choisit pas lesquels s&rsquo;exécutent lors d&rsquo;un déploiement qui n&rsquo;a pas prévu de se coordonner. <code>--all-or-nothing</code> enveloppe les migrations dans une transaction et fait un rollback si l&rsquo;une échoue — c&rsquo;est une question d&rsquo;atomicité au sein d&rsquo;une seule exécution, pas de coordination entre processus.</p>
<p>L&rsquo;approche plus propre sépare ces deux préoccupations en deux init containers : l&rsquo;un qui attend la base de données, l&rsquo;autre qui joue les migrations. Le container principal ne démarre qu&rsquo;une fois les deux terminé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-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">initContainers</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">wait-for-db</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">image</span>: <span style="color:#ae81ff">authentication:latest</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">command</span>: [<span style="color:#e6db74">&#34;php&#34;</span>, <span style="color:#e6db74">&#34;bin/console&#34;</span>, <span style="color:#e6db74">&#34;dbal:run-sql&#34;</span>, <span style="color:#e6db74">&#34;-q&#34;</span>, <span style="color:#e6db74">&#34;SELECT 1&#34;</span>]
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">migrate</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">image</span>: <span style="color:#ae81ff">authentication:latest</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">command</span>: [<span style="color:#e6db74">&#34;php&#34;</span>, <span style="color:#e6db74">&#34;bin/console&#34;</span>, <span style="color:#e6db74">&#34;doctrine:migrations:migrate&#34;</span>, <span style="color:#e6db74">&#34;--no-interaction&#34;</span>, <span style="color:#e6db74">&#34;--all-or-nothing&#34;</span>]
</span></span></code></pre></div><p>Les deux init containers réutilisent la même image que l&rsquo;application. Ce n&rsquo;est pas du gaspillage : ils ont besoin du même binaire PHP et du même câblage d&rsquo;environnement pour atteindre la base de données et trouver les classes de migration. Une image dédiée plus légère réduirait le temps de démarrage, mais nécessiterait de maintenir une installation PHP séparée en synchronisation avec l&rsquo;image principale.</p>
<p>Même avec des init containers, plusieurs pods démarrant simultanément — déploiement initial, après une défaillance de nœud, ou sous pression d&rsquo;autoscaling — tenteront chacun de jouer les migrations. Le résoudre proprement — via un hook pre-upgrade Helm, une stratégie <code>maxSurge: 0</code>, ou un Job de migration séparé — est un sujet en soi. Ce qui compte ici, c&rsquo;est que l&rsquo;entrypoint est le mauvais endroit pour prendre cette décision : il ne peut pas se coordonner entre pods, et il lie l&rsquo;exécution des migrations au démarrage de l&rsquo;application d&rsquo;une façon difficile à démêler plus tard. La question de quelle alternative convient à cette codebase — et pourquoi l&rsquo;entrypoint n&rsquo;a pas encore été remplacé — fait l&rsquo;objet de <a href="/2026/05/17/eleven-out-of-twelve/">l&rsquo;article suivant dans cette série</a>
.</p>
<p>Le Facteur XII de la <a href="https://12factor.net/admin-processes" target="_blank" rel="noopener noreferrer">méthodologie twelve-factor</a> — les processus d&rsquo;administration tournent dans le même environnement que l&rsquo;application — est respecté dans les deux cas. La question est de savoir si « même environnement » signifie « même script d&rsquo;entrypoint » ou « même image, processus séparé ». Sur Kubernetes, le second est plus sûr.</p>
<h2 id="la-vraie-responsabilité-de-lentrypoint">La vraie responsabilité de l&rsquo;entrypoint</h2>
<p>Enlever l&rsquo;attente de la base de données (maintenant une startupProbe ou un init container), les migrations (maintenant un init container ou un Job), et l&rsquo;installation des assets (une opération de build-time qui appartient au Dockerfile), et l&rsquo;entrypoint n&rsquo;a plus qu&rsquo;une seule responsabilité : démarrer l&rsquo;application.</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-sh" data-lang="sh"><span style="display:flex;"><span>exec docker-php-entrypoint <span style="color:#e6db74">&#34;</span>$@<span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><p>Le Facteur IX de la twelve-factor app demande un démarrage rapide et un arrêt propre. Un container dont le démarrage prend soixante secondes parce qu&rsquo;il attend des dépendances externes n&rsquo;est pas rapide. Ça signifie des rolling deploys lents, une reprise après crash lente, et un scale-out horizontal qui crée une fenêtre de soixante secondes avant que chaque nouveau pod contribue.</p>
<p>Le démarrage rapide n&rsquo;est pas juste un confort. C&rsquo;est ce qui fait fonctionner le reste du modèle cloud. Quand un pod peut démarrer en secondes, l&rsquo;orchestrateur peut scaler agressivement et récupérer vite. Quand ça prend une minute, on ajoute des marges partout — timeouts de probes plus longs, fenêtres de déploiement plus larges, politiques de scaling plus conservatrices — et le système devient rigide.</p>
<h2 id="la-taxe-docker-compose">La taxe Docker Compose</h2>
<p>L&rsquo;entrypoint accumule ces responsabilités pour une raison. Sous Docker Compose, il n&rsquo;y a pas de concept d&rsquo;init container. Pas de startupProbe. Les services déclarent <code>depends_on</code>, mais sans conditions de santé, c&rsquo;est juste de l&rsquo;ordre de démarrage — pas de la readiness. L&rsquo;entrypoint comble le vide.</p>
<p>Ce n&rsquo;est pas un défaut de conception. C&rsquo;est une adaptation raisonnable aux contraintes de Docker Compose. Le script fonctionne. Il gère les cas limites (le timeout de la base, les erreurs irrécupérables, le répertoire de migrations absent). Quelqu&rsquo;un l&rsquo;a testé.</p>
<p>Le problème, c&rsquo;est l&rsquo;hypothèse que le même script fonctionne aussi bien sur Kubernetes. Il tourne. L&rsquo;application finit par démarrer. Mais il contourne le système de probes qui rend les déploiements Kubernetes fiables, et il place la responsabilité des migrations à un endroit où la coordination entre pods est difficile à raisonner.</p>
<p>Plusieurs des changements de cette série — <a href="/2026/05/14/the-ghost-of-the-ci-runner/">stockage des médias</a>
, <a href="/2026/05/14/what-survives-the-build/">secrets dans les images</a>
, <a href="/2026/05/15/no-witnesses/">handlers de logs</a>
, <a href="/2026/05/15/the-host-that-hid-the-graph/">dépendances de services</a>
, <a href="/2026/05/16/fifteen-minutes-before-the-first-test/">parité d&rsquo;environnement CI</a>
, <a href="/2026/05/16/the-cache-that-was-lying-to-us/">adaptateurs de cache</a>
 — étaient des changements au code applicatif ou à la configuration. Celui-ci est différent. Il demande à l&rsquo;infrastructure de comprendre ce que « prêt » signifie pour cette application, et il demande à l&rsquo;entrypoint de céder des responsabilités qu&rsquo;il détient actuellement.</p>
<p>C&rsquo;est une conversation plus difficile. Mais la startupProbe attend.</p>
]]></content:encoded></item><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><item><title>Quinze minutes avant le premier test</title><link>https://guillaumedelre.github.io/fr/2026/05/16/quinze-minutes-avant-le-premier-test/</link><pubDate>Sat, 16 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/05/16/quinze-minutes-avant-le-premier-test/</guid><description>Part 5 of 8 in &amp;quot;Symfony vers le Cloud : Douze Facteurs, Treize Services&amp;quot;: Comment un pipeline CI qui provisionnait une VM Azure par run — sans RabbitMQ, MinIO ni Varnish — est devenu un pipeline qui assemble l&amp;#39;environnement de production depuis les mêmes images qu&amp;#39;il livre.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>Le pipeline avait deux stages qui n&rsquo;avaient rien à voir avec le code : <code>provision</code> et <code>deprovision</code>. Entre eux, dans l&rsquo;ordre : <code>phpunit</code>, <code>phpmetrics</code>, <code>behat</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">stages</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">build</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">provision</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">phpunit</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">phpmetrics</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">behat</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">deprovision</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">deploy</span>
</span></span></code></pre></div><p>Avant que la première assertion s&rsquo;exécute, quinze minutes s&rsquo;étaient écoulées. Terraform avait cloné un dépôt d&rsquo;infrastructure, s&rsquo;était authentifié sur Azure, avait appliqué une configuration de VM. Ansible s&rsquo;était connecté à la nouvelle VM, avait installé PHP, configuré l&rsquo;application, câblé une base de données et une instance Redis. Ensuite les tests tournaient. Ensuite Terraform détruisait ce qu&rsquo;Ansible avait construit.</p>
<p>Pour chaque pipeline. Depuis chaque branche. Pour chaque pull request, de l&rsquo;ouverture au merge.</p>
<h2 id="ce-que-ces-quinze-minutes-ne-contenaient-pas">Ce que ces quinze minutes ne contenaient pas</h2>
<p>Le stage <code>provision</code> mettait en place deux services : PostgreSQL et Redis. Trois services dont l&rsquo;application dépendait en production étaient absents : RabbitMQ, MinIO et Varnish.</p>
<p>RabbitMQ traitait tout le travail asynchrone — 56 consumers sur 14 microservices. MinIO gérait le stockage de médias. Varnish était devant le cache HTTP. En CI, aucun d&rsquo;eux n&rsquo;existait. Les tests qui couvraient les files de messages ou le stockage de fichiers avaient deux options : ignorer ces chemins, ou les laisser non testés jusqu&rsquo;au staging. Varnish est un cas à part : les tests tapent directement dans l&rsquo;application et contournent intentionnellement la couche de cache, son absence en CI est donc un choix délibéré plutôt qu&rsquo;un manque.</p>
<p>C&rsquo;est le problème que le <a href="https://12factor.net/dev-prod-parity" target="_blank" rel="noopener noreferrer">Facteur X</a>
 décrit comme l&rsquo;écart d&rsquo;environnement. L&rsquo;écart ici n&rsquo;était pas une question de configuration — il était structurel. La VM était construite par Ansible depuis un script dans un dépôt séparé. Ce n&rsquo;était pas une image de container. Elle n&rsquo;était pas versionnée aux côtés de l&rsquo;application. Si une branche modifiait la topologie de messages RabbitMQ, il n&rsquo;y avait aucun moyen de tester cette modification en CI. Le changement de topologie et le code qui en dépendait ne se rencontreraient qu&rsquo;en staging.</p>
<p>Le script de provisioning Ansible lui-même fait partie du problème :</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">launch_vm</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">stage</span>: <span style="color:#ae81ff">provision</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">script</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">git clone git@gitlab.internal/infra/ci-vm.git</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">cd ci-vm</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">az login --service-principal -u $ARM_CLIENT_ID ...</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">terraform apply -var &#34;prefix=${CI_PIPELINE_ID}-vm&#34; ...</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">sleep 45</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">ansible-playbook behat/test-env.yml ...</span>
</span></span></code></pre></div><p>Le <code>sleep 45</code> est là parce qu&rsquo;Ansible a besoin que la VM finisse de booter avant de pouvoir s&rsquo;y connecter. Ce n&rsquo;est pas un oubli — c&rsquo;est le délai minimum qu&rsquo;une VM fraîchement provisionnée nécessite avant que SSH fonctionne. C&rsquo;est inscrit dans le processus.</p>
<h2 id="ce-qui-la-remplacé">Ce qui l&rsquo;a remplacé</h2>
<p>Le nouveau pipeline n&rsquo;a pas de stage <code>provision</code>. Il n&rsquo;a pas de stage <code>deprovision</code>. L&rsquo;environnement, ce sont les images, et les images existent avant que les tests commencent.</p>
<p>Chaque job de test déclare ses dépendances comme des services Docker :</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">services</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">$REGISTRY_URL/platform/rabbitmq:$CI_COMMIT_REF_SLUG</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">rabbitmq</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">$REGISTRY_URL/platform/minio:$CI_COMMIT_REF_SLUG</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">minio</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">redis:7.4.1</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">redis</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">$ARTIFACTORY_URL/postgresql:13</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">postgresql</span>
</span></span></code></pre></div><p>Les services démarrent en parallèle quand le job commence. Avant que le script de test tourne, un <code>before_script</code> attend qu&rsquo;ils soient tous prêts :</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">before_script</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">$CI_PROJECT_DIR/dockerize</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://postgresql:5432</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://rabbitmq:5672</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://minio:9000</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://redis:6379</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">timeout 120s</span>
</span></span></code></pre></div><p>Du démarrage du pipeline à la première assertion : quatre-vingt-dix secondes — en supposant que les images sont déjà dans le cache du runner ; un cold pull rallonge les choses, mais devient négligeable une fois que le pipeline a tourné une fois sur une branche donnée.</p>
<h2 id="ce-que-signifie-ci_commit_ref_slug">Ce que signifie <code>$CI_COMMIT_REF_SLUG</code></h2>
<p>Le timing est le résultat visible. Ce qui le produit est plus intéressant encore : les noms des images.</p>
<p><code>$REGISTRY_URL/platform/rabbitmq:$CI_COMMIT_REF_SLUG</code> n&rsquo;est pas l&rsquo;image officielle RabbitMQ de Docker Hub. C&rsquo;est une image construite par le même pipeline, depuis la même branche, au même commit que le code testé. L&rsquo;image RabbitMQ embarque la topologie : un <code>definitions.json</code> avec chaque exchange, chaque queue, chaque binding, chaque configuration de dead-letter — versionné dans git aux côtés de l&rsquo;application qui en dépend.</p>
<p>Si une branche modifie la topologie de messages, le pipeline CI construit une nouvelle image RabbitMQ qui inclut ces modifications, puis exécute les tests contre elle. Le changement de topologie et le code qui en dépend sont testés ensemble, au même commit, avant que quoi que ce soit n&rsquo;atteigne le staging.</p>
<p>La même logique s&rsquo;applique à MinIO, décrite dans le <a href="/2026/05/14/the-ghost-of-the-ci-runner/">premier article de cette série</a>
 : l&rsquo;image MinIO embarque des fixtures de test préchargées. L&rsquo;environnement CI n&rsquo;a pas besoin d&rsquo;une étape de setup pour peupler le stockage. L&rsquo;état est intégré à l&rsquo;artefact.</p>
<p>Le runner de tests lui-même suit le même pattern. Chaque job utilise une variante debug de l&rsquo;image applicative — construite depuis la même branche, au même commit — avec les dépendances de test incluses :</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">image</span>: <span style="color:#ae81ff">$REGISTRY_URL/platform/$service:$CI_COMMIT_REF_SLUG-debug</span>
</span></span></code></pre></div><p>Tout l&rsquo;environnement s&rsquo;assemble depuis des artefacts construits au même point de l&rsquo;historique git.</p>
<h2 id="ce-que-ça-a-demandé-dabandonner">Ce que ça a demandé d&rsquo;abandonner</h2>
<p>Behat et la VM provisionnée étaient couplés. La suite de tests Behat tournait contre un serveur HTTP sur la VM ; supprimer la VM signifiait supprimer Behat.</p>
<p>Ça s&rsquo;est révélé moins bloquant que ça n&rsquo;en avait l&rsquo;air. La suite Behat vivait dans un dépôt séparé, nécessitait la VM pour tourner, et avait accumulé une charge de maintenance significative. PHPUnit, tournant dans le container applicatif avec les services Docker, couvrait les mêmes scénarios par un chemin plus direct : tests fonctionnels qui exercent la couche HTTP, tests unitaires pour les composants individuels, suites organisées par domaine fonctionnel et générées dynamiquement en jobs CI parallèles.</p>
<p>La couche BDD a disparu. La couverture de tests est restée — et pouvait désormais tourner contre les vrais services.</p>
<h2 id="le-facteur-x-appliqué">Le Facteur X, appliqué</h2>
<p>Le <a href="https://12factor.net/dev-prod-parity" target="_blank" rel="noopener noreferrer">Facteur X</a>
 se lit souvent comme &ldquo;utilise la même base de données en local qu&rsquo;en production.&rdquo; C&rsquo;est la version la plus simple. La version plus profonde concerne l&rsquo;écart entre ce qu&rsquo;on teste et ce qu&rsquo;on livre.</p>
<p>L&rsquo;écart dans l&rsquo;ancien pipeline était large : une VM configurée manuellement, privée de services clés, reconstruite de zéro à chaque run. L&rsquo;écart dans le nouveau pipeline est étroit : le CI assemble l&rsquo;environnement depuis les mêmes images que la production, construites au même commit que le code sous test.</p>
<p>Les quinze minutes de Terraform et Ansible n&rsquo;étaient pas seulement lentes. Elles construisaient quelque chose qui n&rsquo;était pas ce que la production faisait tourner, à chaque fois, avant que le moindre test puisse commencer. Les quatre-vingt-dix secondes de <code>docker pull</code> construisent exactement ce que la production fait tourner — et les tests qui suivent testent ça, pas une approximation.</p>
]]></content:encoded></item><item><title>L'hôte qui cachait le graphe</title><link>https://guillaumedelre.github.io/fr/2026/05/15/lh%C3%B4te-qui-cachait-le-graphe/</link><pubDate>Fri, 15 May 2026 15:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/05/15/lh%C3%B4te-qui-cachait-le-graphe/</guid><description>Part 4 of 8 in &amp;quot;Symfony vers le Cloud : Douze Facteurs, Treize Services&amp;quot;: Treize services partageant six variables de gateway identiques. La config semblait simple. Le graphe de dépendances était invisible — jusqu&amp;#39;à ce que Kubernetes demande où chaque service habitait vraiment.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>Chaque service de la plateforme avait ces six variables :</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-bash" data-lang="bash"><span style="display:flex;"><span>APP__GATEWAY__PRIVATE__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__GATEWAY__PRIVATE__PORT<span style="color:#f92672">=</span><span style="color:#ae81ff">80</span>
</span></span><span style="display:flex;"><span>APP__GATEWAY__PRIVATE__SCHEME<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;http&#34;</span>
</span></span><span style="display:flex;"><span>APP__GATEWAY__PUBLIC__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__GATEWAY__PUBLIC__PORT<span style="color:#f92672">=</span><span style="color:#ae81ff">80</span>
</span></span><span style="display:flex;"><span>APP__GATEWAY__PUBLIC__SCHEME<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;http&#34;</span>
</span></span></code></pre></div><p>Treize services, six variables chacun, une seule valeur. En lisant la config d&rsquo;un service quelconque, l&rsquo;architecture semblait plate. Tout parlait au même hôte. C&rsquo;était tout le tableau.</p>
<p>Ce ne l&rsquo;était pas.</p>
<h2 id="comment-fonctionnait-la-gateway">Comment fonctionnait la gateway</h2>
<p>La gateway se trouvait devant chaque service et gérait tout le trafic inter-services. Un service appelant l&rsquo;API content construisait une requête vers <code>http://platform.internal/content/api/</code> — la gateway la recevait, identifiait la cible depuis le chemin de l&rsquo;URL, et la transmettait au bon backend. Chaque client HTTP inter-service dans <code>framework.yaml</code> suivait le même schéma :</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">content.client</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">base_uri</span>: <span style="color:#e6db74">&#34;%http_client.gateway.base_uri%/content/api/&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">headers</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">Host</span>: <span style="color:#e6db74">&#34;%env(APP__GATEWAY__PRIVATE__HOST)%&#34;</span>
</span></span></code></pre></div><p>Le paramètre <code>http_client.gateway.base_uri</code> était assemblé depuis les variables GATEWAY. La gateway savait où tournait chaque service. Les services n&rsquo;avaient pas besoin de le savoir. De leur point de vue, tout était <code>platform.internal</code>.</p>
<p>Ça fonctionnait. Pendant des années, ça fonctionnait bien. Ajouter un service signifiait ajouter un alias DNS dans la config de la gateway, pas toucher treize fichiers <code>.env</code>. La gateway abstraisait la topologie. Les services restaient découplés du détail d&rsquo;infrastructure de qui tournait où.</p>
<h2 id="ce-que-la-gateway-absorbait">Ce que la gateway absorbait</h2>
<p>L&rsquo;abstraction avait un coût qui n&rsquo;apparaissait pas tant qu&rsquo;on n&rsquo;essayait pas de lire le système.</p>
<p>En regardant le fichier env de <code>content</code>, on voyait six variables de gateway et rien d&rsquo;autre sur la communication inter-services. Pour découvrir que <code>content</code> appelait <code>conversion</code>, <code>shorty</code> et <code>media</code>, il fallait lire <code>framework.yaml</code>. Pour découvrir que <code>pilot</code> appelait dix services externes, il fallait tracer les clients HTTP un par un et compter.</p>
<p>Le chiffre était dix. Authentication, bam, config, content, conversion, media, product, shorty, sitemap, social. Dix des treize services de la plateforme dont <code>pilot</code> dépendait à l&rsquo;exécution, aucun d&rsquo;eux visible depuis sa configuration. Six variables disaient : parle à la gateway. Elles ne disaient rien de la forme de ce qui se trouvait derrière.</p>
<p>Cette information existait — dans le code, dans la config framework, dans les têtes des gens qui avaient construit ces intégrations. Elle ne vivait juste nulle part où on pouvait la lire d&rsquo;un coup d&rsquo;œil.</p>
<h2 id="ce-que-kubernetes-a-rendu-explicite">Ce que Kubernetes a rendu explicite</h2>
<p>On-premise, la gateway était un seul hostname résolvable. Un enregistrement DNS, un jeu de variables, un seul endroit à mettre à jour. Kubernetes ne fonctionne pas comme ça. Chaque service obtient son propre nom DNS à l&rsquo;intérieur du cluster — <code>content.namespace.svc.cluster.local</code>, <code>conversion.namespace.svc.cluster.local</code>. Le trafic inter-services passe directement, service à service, sans gateway partagée.</p>
<p>Passer à Kubernetes signifiait que l&rsquo;abstraction de la gateway devait céder la place. Chaque service devait savoir, concrètement, où vivait chacune de ses dépendances. Les six variables génériques ne pouvaient pas exprimer ça.</p>
<p>Le refacto les a remplacées par des variables HOST par cible — une par dépendance de service, nommée d&rsquo;après la cible :</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-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># content/.env — content appelle ces quatre services</span>
</span></span><span style="display:flex;"><span>APP__CONFIG__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__CONVERSION__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__MEDIA__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__SHORTY__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span></code></pre></div><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-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># pilot/.env — dix dépendances de service</span>
</span></span><span style="display:flex;"><span>APP__AUTHENTICATION__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__BAM__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__CONFIG__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__CONTENT__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__CONVERSION__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__MEDIA__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__PRODUCT__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__SHORTY__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__SITEMAP__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__SOCIAL__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span></code></pre></div><p>Chaque client HTTP dans <code>framework.yaml</code> a reçu sa propre <code>base_uri</code> construite depuis la variable HOST de sa cible, et le header <code>Host</code> a cédé la place à un <code>User-Agent</code> qui identifie l&rsquo;appelant :</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">content.client</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">base_uri</span>: <span style="color:#e6db74">&#34;%env(APP__HTTP__SCHEME)%://%env(APP__CONTENT__HOST)%:%env(APP__HTTP__PORT)%/content/api/&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">headers</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">User-Agent</span>: <span style="color:#e6db74">&#34;Platform Content - %semver%&#34;</span>
</span></span></code></pre></div><p>Le changement n&rsquo;est pas cosmétique. Dans l&rsquo;ancienne configuration, le header <code>Host</code> explicite garantissait que les requêtes atteignaient le bon virtual host de la gateway quelle que soit la résolution DNS. Dans la nouvelle, chaque client pointe directement vers le nom DNS de sa cible — le <code>Host</code> correct est dérivé automatiquement de la <code>base_uri</code>. L&rsquo;emplacement du header ne reste pas vide : le <code>User-Agent</code> identifie désormais le service appelant, ce qui remonte dans les logs et le traçage distribué sans instrumentation supplémentaire.</p>
<h2 id="linconfort-de-la-lisibilité">L&rsquo;inconfort de la lisibilité</h2>
<p>Le fichier env de <code>pilot</code> est passé de neuf variables de gateway à dix variables HOST spécifiques par service. Le fichier est devenu plus long. L&rsquo;architecture n&rsquo;est pas devenue plus simple — les dix dépendances étaient là avant et elles sont toujours là. Ce qui a changé, c&rsquo;est qu&rsquo;elles sont lisibles.</p>
<p>Le <a href="https://12factor.net/config" target="_blank" rel="noopener noreferrer">Facteur III</a>
 dit de stocker la config dans l&rsquo;environnement. L&rsquo;ancienne approche satisfaisait ça à la lettre : six variables, toutes dans des fichiers env, aucune en dur dans le code. Mais des variables qui effondrent le graphe de dépendances entier dans un seul hostname opaque ne sont pas vraiment de la configuration — elles sont un raccourci qui échange la lisibilité contre la commodité. Le Facteur III ne demande pas seulement que la config soit externalisée — il suppose implicitement qu&rsquo;elle reste informative une fois externalisée.</p>
<p>Le refacto n&rsquo;a rien simplifié. Il a rendu la complexité visible. Les dix variables HOST de <code>pilot</code> documentent, dans le fichier <code>.env</code> lui-même, les dix services dont il dépend. Un nouveau membre d&rsquo;équipe qui lit ce fichier apprend quelque chose de réel sur l&rsquo;architecture. L&rsquo;ancien fichier lui apprenait qu&rsquo;il y avait une gateway.</p>
<p>Il y a une version de cette histoire où on lit l&rsquo;état final et on conclut que l&rsquo;équipe a fait un travail inutile — elle a remplacé six variables par dix, toutes pointant vers le même hôte de toute façon. En développement local, <code>platform.internal</code> résout toujours au même endroit. Le comportement fonctionnel n&rsquo;a pas changé.</p>
<p>Le changement est dans ce que la config communique. Dans Kubernetes, les valeurs HOST divergent : chaque cible obtient son propre nom DNS interne au cluster, différent par environnement. Les variables portent maintenant une vraie information. Le refacto a préparé la config à être honnête sur une topologie qu&rsquo;elle simplifiait silencieusement depuis des années.</p>
]]></content:encoded></item><item><title>Aucun témoin</title><link>https://guillaumedelre.github.io/fr/2026/05/15/aucun-t%C3%A9moin/</link><pubDate>Fri, 15 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/05/15/aucun-t%C3%A9moin/</guid><description>Part 3 of 8 in &amp;quot;Symfony vers le Cloud : Douze Facteurs, Treize Services&amp;quot;: Un service s&amp;#39;est crashé en production sans laisser de logs. Pourquoi fingers_crossed et les déploiements cloud ne font pas bon ménage.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>Le service s&rsquo;était crashé. On avait l&rsquo;alerte. On avait le timestamp à la seconde. On avait <a href="https://grafana.com/oss/loki/" target="_blank" rel="noopener noreferrer">Loki</a> ouvert avec une requête prête.</p>
<p>Ce qu&rsquo;on n&rsquo;avait pas, c&rsquo;était les logs des cinq minutes précédant le crash.</p>
<p>Promtail tournait. Il était healthy. Il collectait les logs de tous les autres services sans problème. Mais pour celui-ci, dans la fenêtre qui comptait, il n&rsquo;y avait rien. Le service s&rsquo;était crashé sans laisser de trace.</p>
<h2 id="le-setup-qui-semblait-correct">Le setup qui semblait correct</h2>
<p>La stack de logging était raisonnable. Chaque service écrivait du JSON structuré vers stdout avec le formatter logstash de Monolog :</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">stdout</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;php://stdout&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">level</span>: <span style="color:#e6db74">&#34;%env(MONOLOG_LEVEL__DEFAULT)%&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#39;monolog.formatter.logstash&#39;</span>
</span></span></code></pre></div><p><a href="https://grafana.com/docs/loki/latest/" target="_blank" rel="noopener noreferrer">Promtail</a> collectait la sortie des containers via la socket Docker, parsait le JSON, extrayait des labels, poussait vers Loki :</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">scrape_configs</span>:
</span></span><span style="display:flex;"><span>    -
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">job_name</span>: <span style="color:#ae81ff">docker</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">docker_sd_configs</span>:
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">host</span>: <span style="color:#ae81ff">unix:///var/run/docker.sock</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">refresh_interval</span>: <span style="color:#ae81ff">5s</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">pipeline_stages</span>:
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">drop</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">older_than</span>: <span style="color:#ae81ff">168h</span>
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">json</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">expressions</span>:
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">level</span>: <span style="color:#ae81ff">level</span>
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">msg</span>: <span style="color:#ae81ff">message</span>
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">service</span>: <span style="color:#ae81ff">service</span>
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">labels</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">level</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">service</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">relabel_configs</span>:
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">source_labels</span>: [ <span style="color:#e6db74">&#39;__meta_docker_container_log_stream&#39;</span> ]
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">target_label</span>: <span style="color:#ae81ff">stream</span>
</span></span></code></pre></div><p>Deux stages font plus de travail que les autres. Le stage <code>json</code> extrait <code>level</code> et <code>service</code> de chaque ligne de log ; le stage <code>labels</code> qui suit immédiatement les promeut en labels d&rsquo;index Loki, ce qui fait de <code>{service=&quot;content&quot;, level=&quot;error&quot;}</code> une lookup directe plutôt qu&rsquo;un scan plein texte sur les lignes stockées. Le relabeling <code>stream</code> conserve si une ligne venait de stdout ou stderr — une distinction requêtable dès que Monolog envoie les erreurs vers stderr et le reste vers stdout. Le stage <code>drop older_than: 168h</code> est une soupape de sécurité : si Promtail redémarre après une longue interruption et rejoue des lignes bufferisées, tout ce qui est plus vieux de sept jours est écarté avant d&rsquo;atteindre Loki.</p>
<p>En théorie : les logs vont vers stdout, Promtail lit stdout, les logs apparaissent dans Loki. La <a href="https://12factor.net/logs" target="_blank" rel="noopener noreferrer">méthodologie twelve-factor</a> décrit exactement ce modèle pour le Facteur XI — traiter les logs comme des flux d&rsquo;événements, écrire vers stdout, laisser l&rsquo;environnement gérer la collecte et le routage.</p>
<p>L&rsquo;application avait stdout. Promtail lisait stdout. Qu&rsquo;est-ce qui pouvait mal tourner.</p>
<h2 id="ce-que-fingers_crossed-emporte-avec-lui">Ce que fingers_crossed emporte avec lui</h2>
<p>En production, le bloc <code>when@prod</code> remplaçait le simple handler <code>stream</code> par quelque chose de plus sophistiqué :</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">when@prod</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">monolog</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">handlers</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">main</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">type</span>: <span style="color:#ae81ff">fingers_crossed</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">action_level</span>: <span style="color:#ae81ff">error</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">handler</span>: <span style="color:#ae81ff">main_group</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">excluded_http_codes</span>: [<span style="color:#ae81ff">404</span>]
</span></span></code></pre></div><p>La ligne <code>excluded_http_codes: [404]</code> est elle-même révélatrice : sans elle, chaque 404 d&rsquo;un scanner ou d&rsquo;un crawler déclenche un flush complet du buffer, déversant des mégaoctets de logs debug pour des URLs malformées. Quelqu&rsquo;un avait déjà appris ça à ses dépens.</p>
<p><code>fingers_crossed</code> est un pattern Monolog bien connu. L&rsquo;idée est élégante : ne pas noyer les logs de production dans le bruit debug, mais si quelque chose tourne mal, retrouver rétrospectivement ce qui s&rsquo;est passé avant l&rsquo;erreur. Le handler bufferise chaque entrée de log en mémoire. Au moment où il voit une <code>error</code>, il flush le buffer entier vers le handler imbriqué — en donnant le contexte complet qui a précédé la défaillance.</p>
<p>Le problème, c&rsquo;est ce qui se passe quand la défaillance n&rsquo;est pas une erreur loguée. C&rsquo;est un OOM kill. Un SIGKILL de l&rsquo;orchestrateur. Un segfault. Un process qui arrête de répondre et est tué de force.</p>
<p>Dans ces cas, <code>fingers_crossed</code> n&rsquo;atteint jamais son <code>action_level</code>. Le buffer existe, plein des cinq dernières minutes d&rsquo;activité, et il disparaît avec le process. Les logs étaient là. Ils étaient en mémoire. Ils sont morts avant d&rsquo;atteindre stdout.</p>
<p>Le Facteur IX du twelve-factor parle de disposabilité : les processus doivent démarrer vite et s&rsquo;arrêter proprement. Sur un arrêt normal (SIGTERM), un processus bien élevé finit son travail en cours et quitte. Mais les crashes ne sont pas des arrêts propres, et les buffers mémoire ne sont pas résistants aux crashes. Le service était disposable au sens où on pouvait le redémarrer ; il ne l&rsquo;était pas au sens où sa sortie était transparente.</p>
<h2 id="les-fichiers-que-personne-ne-lisait">Les fichiers que personne ne lisait</h2>
<p>Il y avait un deuxième problème, plus silencieux mais tout aussi persistant.</p>
<p>Chaque service avait un handler <code>main_group</code> qui routait les logs vers deux destinations en parallèle :</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">main_group</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">group</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">members</span>: [<span style="color:#ae81ff">main_file, stdout]</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">main_file</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;%kernel.logs_dir%/%kernel.environment%.log&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#34;monolog.formatter.logstash&#34;</span>
</span></span></code></pre></div><p><code>var/log/prod.log</code> était écrit sur chaque service, dans chaque environnement, y compris en production. Le même contenu qui allait vers stdout allait aussi vers un fichier à l&rsquo;intérieur du container. Le fichier grossissait sans rotation. Le fichier n&rsquo;était pas accessible à Promtail (qui lisait depuis la socket Docker, pas depuis le filesystem du container). Le fichier consommait de l&rsquo;espace disque. Personne ne le lisait.</p>
<p>Le channel audit était pire :</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">audit_file</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;%kernel.logs_dir%/audit.log&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#39;monolog.formatter.line&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">audit</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">group</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">members</span>: [<span style="color:#ae81ff">audit_file, stderr]</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">channels</span>: [<span style="color:#e6db74">&#39;audit&#39;</span>]
</span></span></code></pre></div><p>Les logs d&rsquo;audit allaient vers <code>stderr</code> (visible par Promtail) et vers <code>audit.log</code> (invisible à Promtail). Le format dans le fichier était une ligne brute, pas le JSON structuré qu&rsquo;attendait Promtail. En pratique, la piste d&rsquo;audit existait à deux endroits : l&rsquo;une requêtable, l&rsquo;autre enfouie dans un répertoire de container qui ne survivait que le temps du container.</p>
<h2 id="ce-que-le-facteur-xi-demande-vraiment">Ce que le Facteur XI demande vraiment</h2>
<p>Le onzième facteur est direct là-dessus : une application ne doit pas se soucier du routage ou du stockage de son flux de sortie. Elle écrit vers stdout. Tout le reste est le job de l&rsquo;environnement.</p>
<p>Ça veut dire pas de handlers de fichiers en production. Pas en backup. Pas pour les pistes d&rsquo;audit. Pas &ldquo;au cas où&rdquo;. Du moment qu&rsquo;une application se met à gérer des fichiers, elle prend en charge la rotation, la rétention, l&rsquo;espace disque, et l&rsquo;accessibilité — rien de tout ça n&rsquo;appartient à l&rsquo;intérieur d&rsquo;un container.</p>
<p>La correction pour les handlers de fichiers est directe. Dans <code>when@prod</code>, supprimer chaque handler <code>*_file</code> et chaque group qui en inclut un. Le channel audit reçoit le même traitement : stderr uniquement, JSON structuré, pas de fichier :</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">when@prod</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">monolog</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">handlers</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">stdout</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;php://stdout&#34;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#75715e"># défaut &#34;warning&#34; — configurable par déploiement via variable d&#39;env pour du debug ciblé</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">level</span>: <span style="color:#e6db74">&#34;%env(default:default_log_level:MONOLOG_LEVEL__DEFAULT)%&#34;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#39;monolog.formatter.logstash&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">stderr</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;php://stderr&#34;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">level</span>: <span style="color:#ae81ff">error</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#39;monolog.formatter.logstash&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">main</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">type</span>: <span style="color:#ae81ff">group</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">members</span>: [<span style="color:#ae81ff">stdout]</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">channels</span>: [<span style="color:#e6db74">&#39;!event&#39;</span>, <span style="color:#e6db74">&#39;!http_client&#39;</span>, <span style="color:#e6db74">&#39;!doctrine&#39;</span>, <span style="color:#e6db74">&#39;!deprecation&#39;</span>, <span style="color:#e6db74">&#39;!audit&#39;</span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">audit</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;php://stderr&#34;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">level</span>: <span style="color:#ae81ff">debug</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#39;monolog.formatter.logstash&#39;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">channels</span>: [<span style="color:#e6db74">&#39;audit&#39;</span>]
</span></span></code></pre></div><p>stdout pour le channel principal. stderr pour les erreurs et l&rsquo;audit. Rien d&rsquo;autre. Promtail récupère les deux via la socket Docker. Le container n&rsquo;écrit rien sur disque. Et les logs d&rsquo;audit sont maintenant du JSON structuré, requêtable dans Loki avec tout le reste.</p>
<h2 id="la-question-plus-dure-sur-fingers_crossed">La question plus dure sur fingers_crossed</h2>
<p>Les handlers de fichiers, c&rsquo;était simple. <code>fingers_crossed</code> est plus nuancé.</p>
<p>Le pattern résout un vrai problème : dans un service de production actif, tout logger en debug crée du bruit et des coûts. <code>fingers_crossed</code> permet de capturer le contexte sans le payer sauf si quelque chose tourne vraiment mal. C&rsquo;est un compromis raisonnable quand le mode de défaillance contre lequel on protège est une erreur applicative (une exception, une 500, une requête lente).</p>
<p>Ce n&rsquo;est pas un compromis raisonnable quand le mode de défaillance est un crash de process. Et dans un environnement Kubernetes, les crashes de process arrivent : évictions OOM, échecs de liveness probe, pression sur les nodes. Exactement les cas où on a le plus besoin des logs.</p>
<p>Une approche : garder <code>fingers_crossed</code> mais réduire la taille du buffer. Par défaut il garde tout depuis le dernier reset. Mettre <code>buffer_size: 50</code> plafonne l&rsquo;usage mémoire, ce qui limite aussi ce qui se perd lors d&rsquo;un crash. On n&rsquo;aura pas le contexte complet, mais on aura les cinquante dernières entrées. Cette voie réduit le périmètre de perte sans supprimer la cause : l&rsquo;opacité dépend toujours d&rsquo;un seuil d&rsquo;erreur qui peut ne jamais se déclencher.</p>
<p>Une autre approche : accepter que les logs debug soient coûteux et monter le niveau par défaut en production. Alors on n&rsquo;a plus besoin de <code>fingers_crossed</code> du tout — si info et au-dessus vont directement vers stdout, rien n&rsquo;est jamais bufferisé.</p>
<p>L&rsquo;approche retenue : supprimer <code>fingers_crossed</code>, monter le niveau par défaut à <code>warning</code>, garder un override debug disponible via variable d&rsquo;env pour les investigations ciblées. Les logs qui comptent apparaissent immédiatement. Ceux qui ne comptent pas ne sont jamais écrits. Rien n&rsquo;est bufferisé.</p>
<h2 id="les-crashes-ne-flushent-pas">Les crashes ne flushent pas</h2>
<p>Le Facteur XI et le Facteur IX se rejoignent au même point : un process qui meurt en plein milieu d&rsquo;une requête. <a href="/2026/05/16/the-cache-that-was-lying-to-us/">un autre article de cette série</a>
 décrivait l&rsquo;illusion d&rsquo;un service qui fonctionnait parfaitement sur un pod mais se comportait silencieusement mal sur deux. C&rsquo;est la même illusion, un niveau au-dessus : un service qui semblait logger correctement, jusqu&rsquo;au moment où il en avait le plus besoin.</p>
<p>La règle pour Monolog en production est sans appel : si ça n&rsquo;atteint pas stdout ou stderr avant que le process quitte, ça n&rsquo;existe pas. Un handler de fichier à l&rsquo;intérieur d&rsquo;un container est invisible pour le collecteur de logs et meurt avec le pod. Un buffer <code>fingers_crossed</code> est invisible pour le collecteur de logs et meurt avec le process.</p>
<p>La production tend à créer les conditions où on a le plus besoin des logs — pression OOM, défaillances en cascade, mauvais déploiements — et c&rsquo;est exactement les conditions où ces deux patterns échouent simultanément. Écrire vers stdout, adopter un niveau par défaut qui ne nécessite pas de bufferisation, et rendre l&rsquo;override disponible pour quand on en a vraiment besoin. Les logs seront là. Ils n&rsquo;attendront pas un seuil d&rsquo;erreur qui ne se déclenche jamais.</p>
]]></content:encoded></item><item><title>Ce qui survit au build</title><link>https://guillaumedelre.github.io/fr/2026/05/14/ce-qui-survit-au-build/</link><pubDate>Thu, 14 May 2026 15:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/05/14/ce-qui-survit-au-build/</guid><description>Part 2 of 8 in &amp;quot;Symfony vers le Cloud : Douze Facteurs, Treize Services&amp;quot;: Comment des fichiers .env committés alimentent un build qui grave les credentials dans les layers Docker — et ce qu&amp;#39;il faut pour vider le fichier jusqu&amp;#39;à quatre lignes.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>À un moment de l&rsquo;audit de migration cloud, quelqu&rsquo;un a lancé ça :</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-bash" data-lang="bash"><span style="display:flex;"><span>docker run --rm &lt;image&gt; php -r <span style="color:#e6db74">&#34;var_dump(require &#39;.env.local.php&#39;);&#34;</span>
</span></span></code></pre></div><p>La sortie montrait tout ce que <code>composer dump-env prod</code> avait compilé dans l&rsquo;image au moment du build. Ce qui voulait dire tout ce qui se trouvait dans le fichier <code>.env</code> quand l&rsquo;image avait été construite. Ce qui voulait dire, entre autres, ça :</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">INFLUXDB_INIT_ADMIN_TOKEN=&lt;influxdb-admin-token&gt;
GF_SECURITY_ADMIN_USER=admin
GF_SECURITY_ADMIN_PASSWORD=admin123
BLACKFIRE_CLIENT_ID=&lt;blackfire-client-id&gt;
BLACKFIRE_CLIENT_TOKEN=&lt;blackfire-client-token&gt;
BLACKFIRE_SERVER_ID=&lt;blackfire-server-id&gt;
BLACKFIRE_SERVER_TOKEN=&lt;blackfire-server-token&gt;
NGROK_AUTHTOKEN=replace-me-optionnal
</code></pre><p>Vingt-cinq variables au total. Chaque credential accumulé dans le <code>.env</code> racine sur trois ans, désormais permanent dans un layer d&rsquo;image.</p>
<h2 id="comment-dump-env-fonctionne">Comment <code>dump-env</code> fonctionne</h2>
<p><code>composer dump-env prod</code> est une optimisation Symfony légitime. Au lieu de parser les fichiers <code>.env</code> à chaque requête, le runtime charge un tableau PHP pré-compilé depuis <code>.env.local.php</code>. Plus rapide et plus simple.</p>
<p>Le problème, c&rsquo;est ce qu&rsquo;il lit. Le Dockerfile copie le dépôt dans l&rsquo;image avec <code>COPY . ./</code>, <code>.env</code> inclus. Ensuite <code>dump-env prod</code> lit ce fichier et compile chaque variable dans <code>.env.local.php</code>. L&rsquo;image est livrée avec une capture figée des credentials qui se trouvaient dans <code>.env</code> au moment du build.</p>
<p>Les layers Docker sont des archives immuables. Même si une étape ultérieure supprimait <code>.env</code> du système de fichiers du container, le layer qui le contient existerait toujours dans l&rsquo;image. <code>docker save &lt;image&gt;</code> produit une archive tar de chaque layer ; extraire un fichier spécifique de n&rsquo;importe quel point de l&rsquo;historique de build est une opération simple. Les credentials sont invisibles à l&rsquo;exécution. Ils ne sont pas partis.</p>
<p>Le <a href="https://12factor.net/build-release-run" target="_blank" rel="noopener noreferrer">Facteur V</a>
 est explicite là-dessus : un artefact de build doit être agnostique à l&rsquo;environnement, la config arrivant à l&rsquo;étape de release depuis l&rsquo;extérieur. Dès que des credentials sont compilés dedans, l&rsquo;image n&rsquo;est plus portable. On ne peut plus la promouvoir entre environnements. On builde deux fois en espérant que le deuxième se comporte comme le premier.</p>
<h2 id="comment-vingt-cinq-variables-saccumulent">Comment vingt-cinq variables s&rsquo;accumulent</h2>
<p>Avant de voir comment on a réparé ça, il vaut la peine de comprendre comment on en est arrivé là.</p>
<p>Les tokens <code>BLACKFIRE_*</code> sont le cas facile à comprendre. Un membre de l&rsquo;équipe configure le profiling, a besoin de partager la configuration, et le dépôt est déjà ouvert à tout le monde. Une ligne dans <code>.env</code> est la voie de moindre résistance. Les credentials InfluxDB et Grafana suivent la même logique — outillage partagé, dépôt partagé, un commit.</p>
<p>Puis il y a les variables qui révèlent une autre dérive. Dans certains <code>.env</code> de services :</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">APP__RATINGS__SERIALS=&#39;{&#34;marque1&#34;:{&#34;fr&#34;:&#34;12345&#34;},...}&#39;  # ~40 lignes de JSON
APP__YOUTUBE__CREDENTIALS=&#39;{&#34;marque1&#34;:{&#34;client_id&#34;:&#34;xxx&#34;,&#34;refresh_token&#34;:&#34;yyy&#34;},...}&#39;
</code></pre><p>Des numéros de série pour la mesure d&rsquo;audience. Des refresh tokens YouTube par marque. Ce ne sont pas des secrets au sens des tokens Blackfire. Ce sont des données métier — le genre de valeurs qui varient entre marques et environnements, que quelqu&rsquo;un a décidé de versionner dans <code>.env</code> parce qu&rsquo;elles se comportaient comme de la configuration et que <code>.env</code> était l&rsquo;endroit où vivait la configuration.</p>
<p>Vingt-cinq variables, c&rsquo;est la somme de décisions incrémentales, dont aucune ne semblait fausse isolément. Le problème est structurel : quand <code>.env</code> est la seule réponse disponible, tout finit par y ressembler.</p>
<h2 id="où-les-choses-appartiennent-vraiment">Où les choses appartiennent vraiment</h2>
<p>Vider le fichier exigeait de répondre à une question pour chaque variable : <em>où est-ce que ça appartient vraiment ?</em></p>
<p>Les réponses ont révélé trois catégories que l&rsquo;équipe n&rsquo;avait jamais explicitement nommées :</p>
<p><strong>La config statique</strong> vit dans le code. Règles métier, logique de routing, fichiers de paramètres Symfony — tout ce qui ne varie pas entre les déploiements. Un changement exige un rebuild. Les blocs JSON de numéros de série se sont révélés ne pas être de la config statique du tout : ils étaient interrogés depuis un service Config dédié à l&rsquo;exécution. Ils n&rsquo;avaient rien à faire dans un fichier.</p>
<p><strong>La config environnementale</strong> varie entre les déploiements : hostnames, chaînes de connexion, credentials de services tiers. C&rsquo;est ce que le <a href="https://12factor.net/config" target="_blank" rel="noopener noreferrer">Facteur III</a>
 désigne par &ldquo;config dans les variables d&rsquo;environnement&rdquo; — de vraies variables au niveau OS, injectées à l&rsquo;exécution, jamais des fichiers qui voyagent avec le code. Dans Kubernetes, c&rsquo;est un ConfigMap pour les valeurs non sensibles et un Kubernetes Secret pour les credentials. Le choix retenu pour les secrets a été SOPS — les credentials sont chiffrés et committés dans git, plutôt que stockés dans un coffre-fort externe comme Azure Key Vault ou HashiCorp Vault. Un coffre-fort échange la simplicité contre l&rsquo;auditabilité : rotation automatique, logs d&rsquo;audit centralisés, accès via workload identity sans clé à protéger. SOPS échange ces capacités contre un modèle opérationnel plus simple — pas de service externe à interroger au déploiement, les secrets transitent par le processus de review normal du code, l&rsquo;historique git fait office de piste d&rsquo;audit. Les contreparties acceptées sont la rotation manuelle et la responsabilité de protéger la clé de déchiffrement elle-même. Pour la taille de l&rsquo;équipe, le compromis était délibéré.</p>
<p><strong>La config dynamique</strong> change sans déploiement : paramètres éditoriaux, seuils par marque, configuration de modération de contenu. Elle appartient à une base de données, gérée via le service Config de l&rsquo;application. Une partie de ce qui s&rsquo;était accumulé dans les <code>.env</code> de services était cette catégorie depuis le début, passant pour des valeurs par défaut statiques parce qu&rsquo;elle changeait assez rarement pour que personne ne le remarque.</p>
<p>Une fois les catégories nommées, les variables se sont triées. Le <code>.env</code> racine est arrivé à quatre lignes :</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">DOMAIN=platform.127.0.0.1.sslip.io
XDEBUG_MODE=off
SERVER_NAME=:80
APP_ENV=dev
</code></pre><p>Des valeurs par défaut sûres. Rien de sensible. <code>dump-env prod</code> compile maintenant des chaînes vides ; les vraies valeurs arrivent à l&rsquo;exécution depuis Kubernetes.</p>
<h2 id="limage-postgresql">L&rsquo;image PostgreSQL</h2>
<p>L&rsquo;image PostgreSQL utilisée en CI a un mot de passe codé en dur :</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">FROM</span> <span style="color:#e6db74">postgres:15</span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">ENV</span> POSTGRES_PASSWORD<span style="color:#f92672">=</span>admin123
</span></span></code></pre></div><p>Ça ressemble au même problème. Ce n&rsquo;en est pas un, parce que le modèle de menace est différent. La base CI est éphémère — elle existe le temps d&rsquo;un run de pipeline, ne contient pas de vraies données, tourne dans un réseau isolé. Un mot de passe codé en dur sur une base de test jetable est un risque acceptable, pas une entorse à la règle.</p>
<p>En production, la question ne se pose pas : la plateforme utilise Azure Flexible Server, un service PostgreSQL managé. Il n&rsquo;y a pas d&rsquo;image Docker. Les credentials arrivent via injection dans les charts Helm, sans jamais toucher un layer.</p>
<h2 id="ce-qui-survit-au-build-maintenant">Ce qui survit au build maintenant</h2>
<p>L&rsquo;image qui part en production contient maintenant une garantie : <code>var_dump(require '.env.local.php')</code> ne retourne que des chaînes vides et des valeurs par défaut sûres. Les credentials ne sont pas là parce qu&rsquo;ils n&rsquo;y ont jamais été mis — ils arrivent à l&rsquo;exécution, depuis l&rsquo;extérieur.</p>
<p>C&rsquo;est la frontière de responsabilité que <code>dump-env</code> avait silencieusement effacée : l&rsquo;image est l&rsquo;application, le runtime est l&rsquo;environnement. Ils ne devraient pas connaître les secrets de l&rsquo;autre.</p>
]]></content:encoded></item><item><title>Le fantôme du runner CI</title><link>https://guillaumedelre.github.io/fr/2026/05/14/le-fant%C3%B4me-du-runner-ci/</link><pubDate>Thu, 14 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/05/14/le-fant%C3%B4me-du-runner-ci/</guid><description>Part 1 of 8 in &amp;quot;Symfony vers le Cloud : Douze Facteurs, Treize Services&amp;quot;: Comment un chemin pointant vers un runner CI dans la config de production a révélé une dette de stockage — et comment les lazy adapters Flysystem l&amp;#39;ont résolue.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">APP__COLD_STORAGE__FILESYSTEM_PATH=&#34;/home/jenkins-slave/share_media/media&#34;
APP__COLD_STORAGE__FILESYSTEM_PATH_CACHE=&#34;/home/jenkins-slave/share_media/media/cache&#34;
APP__COLD_STORAGE__RAW_IMAGE_PATH=&#34;/home/jenkins-slave/share_media/media_raw&#34;
APP__SHARE_STORAGE__FILESYSTEM_PATH=&#34;/home/jenkins-slave/share_storage&#34;
</code></pre><p>Ces lignes se trouvaient dans le <code>.env</code> de production du service media. Pas le staging. Pas un override local. La production, committée dans le dépôt, lue à chaque démarrage.</p>
<p>Les chemins se terminent là où on s&rsquo;y attendrait : <code>/media</code>, <code>/share_storage</code>. Ils commencent ailleurs : <code>/home/jenkins-slave</code>, le répertoire home d&rsquo;un runner CI issu d&rsquo;une ancienne installation Jenkins.</p>
<h2 id="comment-le-home-dun-runner-atterrit-dans-la-config-de-production">Comment le home d&rsquo;un runner atterrit dans la config de production</h2>
<p>La plateforme avait grandi depuis une seule machine. Un serveur faisait tout tourner — l&rsquo;application, le runner CI, la base de données, le stockage de fichiers. Les fichiers transitaient entre l&rsquo;app et le système CI via NFS : un répertoire monté sur le même hôte, accessible aux containers comme au runner.</p>
<p>Le chemin <code>/home/jenkins-slave/share_media</code> était là où le partage NFS atterrissait sur cette machine. Quand l&rsquo;équipe a migré vers Docker Compose, les containers ont hérité du montage NFS. Le chemin est entré dans le <code>.env</code> parce que l&rsquo;application devait savoir où trouver les fichiers. Personne ne l&rsquo;a changé parce que ça marchait. Le montage était toujours là. Le chemin était valide. L&rsquo;application démarrait. Les fichiers apparaissaient où ils devaient.</p>
<p>Trois ans plus tard, personne n&rsquo;y pensait plus du tout. C&rsquo;était juste comme ça que le chemin media était configuré.</p>
<h2 id="ce-que-kubectl-apply-a-trouvé">Ce que kubectl apply a trouvé</h2>
<p>Le premier <code>kubectl apply</code> du service media s&rsquo;est terminé avec un pod bloqué en CrashLoopBackOff. Le container démarrait. L&rsquo;entrypoint tournait. L&rsquo;application essayait d&rsquo;accéder à <code>/home/jenkins-slave/share_media/media</code>. Fichier ou répertoire inexistant. Pas de montage NFS. Pas de runner.</p>
<p>Le chemin ne documentait pas une décision de design. Il documentait la machine qui tournait par hasard au moment où le <code>.env</code> avait été écrit.</p>
<p>C&rsquo;est exactement le problème que le <a href="https://12factor.net/backing-services" target="_blank" rel="noopener noreferrer">Facteur IV</a>
 de l&rsquo;application twelve-factor décrit. Les backing services — stockage, files, bases de données — doivent être des ressources attachées, configurées via URL ou chaîne de connexion, interchangeables entre environnements sans toucher au code. Un chemin de fichier sur un hôte partagé n&rsquo;est pas un backing service. C&rsquo;est une hypothèse physique sur la machine. Quand la machine change, l&rsquo;hypothèse lâche.</p>
<h2 id="le-chemin-était-le-symptôme">Le chemin était le symptôme</h2>
<p>La première étape évidente était de supprimer la référence au runner :</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">APP__COLD_STORAGE__FILESYSTEM_PATH=&#34;/share_media/media&#34;
APP__SHARE_STORAGE__FILESYSTEM_PATH=&#34;/share_storage&#34;
</code></pre><p>Plus propre. Plus de références CI dans une config de production. Toujours incorrect. L&rsquo;application supposait encore un système de fichiers POSIX — soit un volume monté, soit un répertoire sur le nœud. Dans Kubernetes, un volume partagé entre plusieurs pods nécessite un PersistentVolumeClaim en mode <code>ReadWriteMany</code>. La plupart des fournisseurs de stockage ne le supportent pas. Ceux qui le font ont tendance à être lents et coûteux. Et même là où ça fonctionne, on a juste remplacé une hypothèse sur le système de fichiers par une autre.</p>
<p>Renommer le chemin gagnait du temps. Ça ne réglait pas le problème.</p>
<p>Le problème, c&rsquo;est qu&rsquo;environ douze téraoctets d&rsquo;images — originaux et déclinaisons pré-générées dans différents formats — pour plusieurs marques éditoriales — étaient traités comme un répertoire. Un répertoire ne se monte pas proprement sur plusieurs pods. Un backing service, si.</p>
<h2 id="flysystem-comme-forme-de-la-solution">Flysystem comme forme de la solution</h2>
<p>Le service media avait déjà Flysystem de configuré. Trois adaptateurs concrets — système de fichiers local, AWS S3, Azure Blob — et un adaptateur lazy par-dessus :</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/flysystem.yaml</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">flysystem</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">storages</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">media.storage.local</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">adapter</span>: <span style="color:#e6db74">&#39;local&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">options</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">directory</span>: <span style="color:#e6db74">&#34;/&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">media.storage.aws</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">adapter</span>: <span style="color:#e6db74">&#39;aws&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">options</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">client</span>: <span style="color:#e6db74">&#39;aws_client_service&#39;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">bucket</span>: <span style="color:#e6db74">&#39;media&#39;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">streamReads</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">media.storage</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">adapter</span>: <span style="color:#e6db74">&#39;lazy&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">options</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">source</span>: <span style="color:#e6db74">&#39;%env(APP__FLYSYSTEM_MEDIA_STORAGE)%&#39;</span>
</span></span></code></pre></div><p>Tout le code de l&rsquo;application dépend de <code>media.storage</code>. Il ne sait pas si les fichiers vivent sur le système de fichiers ou dans un bucket cloud. Une variable d&rsquo;environnement détermine quel backend est actif :</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">APP__FLYSYSTEM_MEDIA_STORAGE=media.storage.aws   # production
APP__FLYSYSTEM_MEDIA_STORAGE=media.storage.local  # fallback local toujours disponible
</code></pre><p>Le chemin est parti. L&rsquo;hypothèse sur le système de fichiers est partie. Ce qui reste, c&rsquo;est un nom de service — une ressource attachée au sens twelve-factor, configurable sans rebuilder l&rsquo;image.</p>
<p>Le même pattern s&rsquo;étend au cache de vignettes. <a href="https://github.com/liip/LiipImagineBundle" target="_blank" rel="noopener noreferrer">LiipImagine</a>
 génère des images redimensionnées à la demande ; les originaux et le cache généré passent par des adaptateurs Flysystem séparé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-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">liip_imagine</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">loaders</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">flysystem</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">filesystem_service</span>: <span style="color:#e6db74">&#39;media.storage&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">default_cache</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">flysystem</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">filesystem_service</span>: <span style="color:#e6db74">&#39;media.cache.storage&#39;</span>
</span></span></code></pre></div><p>Deux variables d&rsquo;environnement, deux buckets. Toute la chaîne — recevoir l&rsquo;upload, stocker l&rsquo;original, générer la vignette, la mettre en cache — est portable vers le cloud sans toucher une ligne de PHP.</p>
<p>Ce que l&rsquo;article ne couvre pas, c&rsquo;est le déplacement des données. Le lazy adapter change une variable d&rsquo;environnement. Faire passer douze téraoctets d&rsquo;un montage NFS vers un bucket S3, c&rsquo;est un autre projet — une fenêtre de migration, une double-écriture pendant le cutover, une vérification qu&rsquo;il ne manque rien.</p>
<h2 id="ce-que-minio-rend-possible-en-ci">Ce que Minio rend possible en CI</h2>
<p>La production utilise S3. Le développement local utilise <a href="https://min.io/" target="_blank" rel="noopener noreferrer">Minio</a>
, un stockage objet compatible S3 qui tourne dans un container Docker. L&rsquo;adaptateur AWS parle à Minio en local et à S3 en production. L&rsquo;application ne voit pas la différence :</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv"># local/CI
APP__FLYSYSTEM_MEDIA_STORAGE=media.storage.aws
APP__MINIO_ENDPOINT=http://minio:9000
APP__MINIO_ACCESS_KEY=minioadmin
APP__MINIO_SECRET_KEY=minioadmin
</code></pre><p>Le même code, le même adaptateur, un endpoint différent. Pas de mock, pas de chemins de test spéciaux, pas de branches conditionnelles par environnement.</p>
<p>Mais la configuration CI va un cran plus loin. L&rsquo;image Minio utilisée dans le pipeline n&rsquo;est pas l&rsquo;image officielle upstream — c&rsquo;est une image custom buildée avec des fixtures de test préchargées :</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">FROM</span> <span style="color:#e6db74">minio/minio:latest</span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">COPY</span> tests/fixtures/ /fixtures_media/<span style="color:#960050;background-color:#1e0010">
</span></span></span></code></pre></div><p>Chaque run CI démarre avec une instance Minio qui contient déjà les données attendues par la suite de tests. Pas de script de setup, pas de commande de seed, pas d&rsquo;étape &ldquo;attendre le chargement des fixtures&rdquo; avant que les tests commencent. L&rsquo;état initial de l&rsquo;environnement de test fait partie de l&rsquo;artefact de build.</p>
<p>Le <a href="https://12factor.net/build-release-run" target="_blank" rel="noopener noreferrer">Facteur V</a>
 appliqué à l&rsquo;infrastructure de test : l&rsquo;état de l&rsquo;environnement est buildé, versionné, immuable. Le pipeline CI construit l&rsquo;image Minio depuis la même source et au même commit que l&rsquo;image applicative. Les fixtures de test et le code qui les exploite sont toujours synchronisés.</p>
<h2 id="le-compromis-s3-honnêtement">Le compromis S3, honnêtement</h2>
<p>S3 introduit un coût de latence que le stockage local n&rsquo;a pas. Les premières données d&rsquo;un fichier prennent 10 à 30 millisecondes à arriver depuis S3 — c&rsquo;est la latence first-byte documentée du service, pas une mesure sur ce trafic spécifique.</p>
<p>À 300 requêtes par seconde, le raisonnement pour accepter ce compromis était le suivant : la majorité des lectures touche des vignettes déjà générées dans le cache S3, pas les fichiers originaux. Une image fraîchement uploadée paie la pénalité du cold miss une fois, à la première demande de vignette. Tout ce qui suit est un cache hit. Savoir si la latence de queue sous charge réelle confirmait ce raisonnement nécessitait des tests de charge suivis séparément — la décision d&rsquo;architecture et la validation étaient découplées.</p>
<p>Le compromis a été accepté : comportement prévisible sur plusieurs pods, pas de problèmes d&rsquo;état partagé, une couche de stockage qui scale sans coordination. L&rsquo;histoire complète des mesures appartient au rapport de tests de performance, pas ici.</p>
<h2 id="le-fantôme-sen-va">Le fantôme s&rsquo;en va</h2>
<p>Le chemin <code>/home/jenkins-slave</code> n&rsquo;apparaît plus dans la configuration. Mais ce à quoi il pointait était un couplage qui précédait Docker, précédait les microservices, précédait n&rsquo;importe quelle conversation sur la migration cloud. Le runner CI et l&rsquo;application de production partageaient un système de fichiers parce qu&rsquo;ils vivaient sur la même machine. Personne ne l&rsquo;avait conçu comme ça. Ça s&rsquo;était accumulé.</p>
<p>Une erreur <code>kubectl apply</code> sur un chemin qui n&rsquo;aurait pas dû exister a forcé la question : pourquoi cette application suppose-t-elle qu&rsquo;un runner CI spécifique est présent sur l&rsquo;hôte ? La réponse était &ldquo;parce que ça a toujours été comme ça.&rdquo; Ce n&rsquo;est pas une raison. C&rsquo;est une histoire.</p>
<p>Renommer le chemin était un correctif en carton. L&rsquo;adaptateur lazy de Flysystem était la vraie réponse — pas parce qu&rsquo;il est plus élégant, mais parce qu&rsquo;il fait du backend de stockage une décision qui appartient à l&rsquo;environnement, pas à l&rsquo;application. Le container démarre, lit une variable, se connecte à ce qui est à l&rsquo;autre bout. Il ne sait pas si c&rsquo;est un bucket dans un datacenter ou un container sur un laptop.</p>
<p>Le répertoire home du runner a disparu de la config. Ce qui l&rsquo;a remplacé, c&rsquo;est un nom de service. C&rsquo;est la différence.</p>
]]></content:encoded></item></channel></rss>