<?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>Minio on Guillaume Delré</title><link>https://guillaumedelre.github.io/fr/tags/minio/</link><description>Recent content in Minio on Guillaume Delré</description><generator>Hugo</generator><language>fr-FR</language><lastBuildDate>Thu, 14 May 2026 10:00:00 +0000</lastBuildDate><atom:link href="https://guillaumedelre.github.io/fr/tags/minio/index.xml" rel="self" type="application/rss+xml"/><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>