<?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/tags/minio/</link><description>Recent content in Minio on Guillaume Delré</description><generator>Hugo</generator><language>en</language><lastBuildDate>Thu, 14 May 2026 10:00:00 +0000</lastBuildDate><atom:link href="https://guillaumedelre.github.io/tags/minio/index.xml" rel="self" type="application/rss+xml"/><item><title>The Ghost of the CI Runner</title><link>https://guillaumedelre.github.io/2026/05/14/the-ghost-of-the-ci-runner/</link><pubDate>Thu, 14 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/14/the-ghost-of-the-ci-runner/</guid><description>Part 1 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: How a CI runner&amp;#39;s home directory in production config revealed a storage architecture problem — and how Flysystem lazy adapters fixed it.</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>These lines were in the production <code>.env</code> of the media service. Not staging. Not a local override. Production, committed to the repository, read on every startup.</p>
<p>The paths end where you&rsquo;d expect: <code>/media</code>, <code>/share_storage</code>. They start somewhere more surprising: <code>/home/jenkins-slave</code>, the home directory of a CI runner from an old Jenkins setup.</p>
<h2 id="how-a-runners-home-directory-ends-up-in-production-config">How a runner&rsquo;s home directory ends up in production config</h2>
<p>The platform had grown from a single machine. One server ran everything — the application, the CI runner, the database, the file storage. Files moved between the app and the CI system via NFS: a directory mounted on the same host, accessible to both the containers and the runner.</p>
<p>The path <code>/home/jenkins-slave/share_media</code> was where the NFS share landed on that machine. When the team migrated to Docker Compose, the containers inherited the NFS mount. The path made it into the <code>.env</code> because the application needed to know where to find files. Nobody changed it because it worked. The mount was still there. The path was valid. The application started. Files appeared where they should.</p>
<p>Three years later, nobody thought about it at all. It was just how the media path was configured.</p>
<h2 id="what-kubectl-apply-found">What kubectl apply found</h2>
<p>The first <code>kubectl apply</code> for the media service ended with a pod stuck in CrashLoopBackOff. The container started. The entrypoint ran. The application tried to access <code>/home/jenkins-slave/share_media/media</code>. No such file or directory. No NFS mount. No runner.</p>
<p>The path didn&rsquo;t document a design decision. It documented the machine that happened to be running at the time the <code>.env</code> was written.</p>
<p>This is what <a href="https://12factor.net/backing-services" target="_blank" rel="noopener noreferrer">Factor IV</a>
 of the twelve-factor app is warning against. Backing services — storage, queues, databases — should be attached resources, configured via URL or connection string, interchangeable between environments without code changes. A filesystem path on a shared host is not a backing service. It&rsquo;s a physical assumption about the machine. When the machine changes, the assumption fails.</p>
<h2 id="the-path-was-the-symptom">The path was the symptom</h2>
<p>The obvious first step was removing the runner reference:</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>Cleaner. No more CI references in a production config. Still not right. The application still assumed a POSIX filesystem — either a volume mount or a directory on the node. In Kubernetes, a volume shared between multiple pods requires a <code>ReadWriteMany</code> PersistentVolumeClaim. Most storage providers don&rsquo;t support it. Those that do tend to be slow and expensive. And even where it works, you&rsquo;ve replaced one shared filesystem assumption with another.</p>
<p>Renaming the path bought time. It didn&rsquo;t fix the problem.</p>
<p>The problem was that roughly twelve terabytes of images — originals and pre-generated derivatives in multiple formats — from multiple editorial brands — were treated as a directory. A directory can&rsquo;t be mounted cleanly across pods. A backing service can.</p>
<h2 id="flysystem-as-the-shape-of-the-solution">Flysystem as the shape of the solution</h2>
<p>The media service already had a Flysystem dependency. Three concrete adapters — local filesystem, AWS S3, Azure Blob — and one lazy adapter sitting on top:</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>All application code depends on <code>media.storage</code>. It doesn&rsquo;t know whether files live on the filesystem or in a cloud bucket. One environment variable determines which backend is active:</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  # local fallback still available
</code></pre><p>The path is gone. The filesystem assumption is gone. What remains is a service name — an attached resource in the twelve-factor sense, configurable without rebuilding the image.</p>
<p>The same pattern extends to the thumbnail cache. <a href="https://github.com/liip/LiipImagineBundle" target="_blank" rel="noopener noreferrer">LiipImagine</a>
 generates resized images on demand; both the source originals and the generated cache go through separate Flysystem adapters:</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>Two environment variables, two buckets. The full pipeline — receive upload, store original, generate thumbnail, cache it — is cloud-portable without touching a line of PHP.</p>
<p>What this doesn&rsquo;t cover is moving the data. The lazy adapter changes one environment variable. Getting twelve terabytes from an NFS mount into an S3 bucket is a different project — a migration window, double-write during cutover, verification that nothing was missed.</p>
<h2 id="what-minio-makes-possible-in-ci">What Minio makes possible in CI</h2>
<p>Production uses S3. Local development uses <a href="https://min.io/" target="_blank" rel="noopener noreferrer">Minio</a>
, an S3-compatible object store that runs in a Docker container. The AWS adapter talks to Minio locally and to S3 in production. The application doesn&rsquo;t notice the difference:</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>The same code, the same adapter, a different endpoint. No mocking, no special test paths, no environment-specific branches.</p>
<p>But the CI configuration goes one step further. The Minio image used in the pipeline isn&rsquo;t the standard upstream one — it&rsquo;s a custom image built with test fixtures preloaded:</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>Every CI run starts with a Minio instance that already contains the data the test suite expects. No setup script, no seed command, no &ldquo;wait for fixtures to load&rdquo; step before tests begin. The initial state of the test environment is part of the build artifact.</p>
<p><a href="https://12factor.net/build-release-run" target="_blank" rel="noopener noreferrer">Factor V</a>
 applied to test infrastructure: the environment state is built, versioned, and immutable. The CI pipeline builds the Minio image from the same source and at the same commit as the application image. The test fixtures and the code that exercises them are always in sync.</p>
<h2 id="the-s3-tradeoff-honestly">The S3 tradeoff, honestly</h2>
<p>S3 introduces a latency cost that local storage doesn&rsquo;t have. The first bytes of a file take 10 to 30 milliseconds to arrive from S3 — that&rsquo;s the documented first-byte latency for the service, not a measurement on this specific workload.</p>
<p>At 300 requests per second, the reasoning for accepting it was this: most reads hit already-generated thumbnails in the S3-backed cache, not the original files. A freshly uploaded image pays the cold-miss penalty once, on the first thumbnail request. Everything after that is a cache hit. Whether the actual tail latency under load bore that reasoning out required performance testing that was tracked separately — the architecture decision and the validation were decoupled.</p>
<p>The tradeoff was accepted: predictable behavior across multiple pods, no shared-state problems, a storage layer that scales without coordination. The full measurement story belongs in the load test report, not here.</p>
<h2 id="the-ghost-leaves">The ghost leaves</h2>
<p>The path <code>/home/jenkins-slave</code> no longer appears in the configuration. But what it pointed to was a coupling that predated Docker, predated microservices, predated any conversation about cloud migration. The CI runner and the production application shared a filesystem because they lived on the same machine. Nobody designed it that way. It accumulated.</p>
<p>A <code>kubectl apply</code> error on a path that shouldn&rsquo;t have existed forced the question: why does this application assume a specific CI runner is present on the host? The answer was &ldquo;because it always has.&rdquo; That&rsquo;s not a reason. It&rsquo;s a history.</p>
<p>Renaming the path was a paper fix. Flysystem&rsquo;s lazy adapter was the actual answer — not because it&rsquo;s more elegant, but because it makes the storage backend a decision that belongs to the environment, not to the application. The container starts, reads one variable, connects to whatever is at the other end. It doesn&rsquo;t know whether that&rsquo;s a bucket in a data center or a container on a laptop.</p>
<p>The runner&rsquo;s home directory is gone from the config. What replaced it is a service name. That&rsquo;s the difference.</p>
]]></content:encoded></item></channel></rss>