<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Michał Iżewski</title>
    <description>The latest articles on DEV Community by Michał Iżewski (@michal-izewski).</description>
    <link>https://dev.to/michal-izewski</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3963415%2Fbbc5ab11-645a-42d2-8355-261da0e9acad.jpg</url>
      <title>DEV Community: Michał Iżewski</title>
      <link>https://dev.to/michal-izewski</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/michal-izewski"/>
    <language>en</language>
    <item>
      <title>The Health Check That Watched the Wrong Thing</title>
      <dc:creator>Michał Iżewski</dc:creator>
      <pubDate>Sun, 16 Aug 2026 13:34:36 +0000</pubDate>
      <link>https://dev.to/michal-izewski/the-health-check-that-watched-the-wrong-thing-26db</link>
      <guid>https://dev.to/michal-izewski/the-health-check-that-watched-the-wrong-thing-26db</guid>
      <description>&lt;p&gt;The last post ended on a promise: background workers fall into the same correlated-failure trap as request services, from a different angle. This is that angle, and it starts one level earlier: a health check that reports green while the work has stopped.&lt;/p&gt;

&lt;p&gt;I floated a heartbeat file for a queue worker, a small marker the worker rewrites as it goes, with a watchdog checking how stale it is. The reply came back fast: "just put a &lt;code&gt;/health&lt;/code&gt; on it." Fair reflex. It's what every request service does, what every tutorial shows, what every load balancer expects. It's also the one thing that hides the failure I was trying to catch. An HTTP &lt;code&gt;/health&lt;/code&gt; on a worker will return 200 all day while the worker sits there doing nothing.&lt;/p&gt;

&lt;p&gt;Process is up, actual work has stopped. That gap in between is what the whole post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the HTTP reflex comes from
&lt;/h2&gt;

&lt;p&gt;"Health check means an HTTP endpoint" is baked in for a good reason. Every service that handles requests exposes one, every orchestrator polls one, every tutorial you've read wires up &lt;code&gt;GET /health&lt;/code&gt; returning 200. For a request handler that's not just convention, it's correct. The reflex only misfires when you carry it, unexamined, onto a process that doesn't handle requests at all. Same shape as the session library from the last series entry: a default that's right in the context it was built for, and surprising the moment you step outside that context. Nobody was wrong to reach for it. It's answering a question the worker doesn't ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure the green endpoint can't see
&lt;/h2&gt;

&lt;p&gt;Picture the worker. It runs a loop: pull a job off the queue, do the work, pull the next one. To satisfy the "just add &lt;code&gt;/health&lt;/code&gt;" reflex, you bolt an HTTP listener onto it, and that listener answers independently of the loop, from its own thread or process or whatever your runtime uses.&lt;/p&gt;

&lt;p&gt;Now the loop wedges. A job deadlocks, the connection pool is exhausted and every task is stuck behind one that never returns, a lock is held by a thread that's gone. Timeouts help, and most libraries ship with them, but a default one isn't a tuned one, so the loop can sit far longer than the work really needs before anything gives up. The work stops. The listener doesn't: it keeps accepting connections and returning 200, because answering a trivial GET has nothing to do with whether the loop is moving.&lt;/p&gt;

&lt;p&gt;So the orchestrator sees green. The queue behind the worker climbs. And the exact failure you put the health check there to catch, a worker that's alive but stuck, is the one failure the check is structurally blind to. The endpoint is answering exactly the question it was asked, and that question was about the HTTP server, not the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof of life isn't proof of work
&lt;/h2&gt;

&lt;p&gt;The obvious patch is to make the check smarter. The HTTP &lt;code&gt;/health&lt;/code&gt; only proves the listener answers, fine, so have it also confirm the worker process is actually running: check the PID, ask the supervisor, &lt;code&gt;systemctl is-active&lt;/code&gt;. Not hard to wire up. And still the wrong signal, because a dead process is often not the failure you're actually worried about. Far more often the process is up and the loop is parked on a job that will never return.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signal has to come from the work
&lt;/h2&gt;

&lt;p&gt;The reason the HTTP reflex is right for a request service and wrong for a worker is the same fact read two ways.&lt;/p&gt;

&lt;p&gt;In a request service, handling the request &lt;em&gt;is&lt;/em&gt; the work. When the health check hits an endpoint and gets a response, the act of responding is itself proof that the request path is alive: routing, framework, handler, all of it just ran. The check can't easily lie, because doing the thing it checks is how it passes.&lt;/p&gt;

&lt;p&gt;A worker breaks that coupling. The work is the loop, and an HTTP listener bolted on beside it answers independently of the loop, so by default the check proves the listener is up, not that the loop is moving. Nothing stops you from wiring the two together: have the loop update a shared timestamp and let &lt;code&gt;/health&lt;/code&gt; return 503 when that timestamp goes stale. Do that and the endpoint is honest again. HTTP can carry a real signal; it only has to originate in the loop, something only the loop can refresh as it makes progress, and stop the moment the loop stops. Where you expose it, a file, a metric your monitoring already scrapes, an HTTP route, a socket, matters less than whether it's tied to the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  A heartbeat the loop writes, a watchdog that reads it
&lt;/h2&gt;

&lt;p&gt;The simplest thing with that property: the loop writes a small marker after it does real work, and a separate watchdog checks how stale the marker is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# inside the worker loop&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;job&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;reserve_job&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;        &lt;span class="c"&gt;# blocks until there is work&lt;/span&gt;
  process &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;touch&lt;/span&gt; /run/worker.alive     &lt;span class="c"&gt;# heartbeat: proof the loop advanced&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A file under &lt;code&gt;/run&lt;/code&gt; or &lt;code&gt;/dev/shm&lt;/code&gt; carries real weight here: the marker means something precisely because only the loop can refresh it. A separate listener can't fake it, because it never touches the loop.&lt;/p&gt;

&lt;p&gt;The watchdog runs on cron, out of process, and does one thing. If the marker is older than a threshold, mark the instance unhealthy and let the platform replace it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# watchdog on cron: replace the instance if the loop has gone quiet&lt;/span&gt;
&lt;span class="nv"&gt;threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; max_job_seconds &lt;span class="o"&gt;+&lt;/span&gt; slack &lt;span class="k"&gt;))&lt;/span&gt;       &lt;span class="c"&gt;# longest healthy job, plus margin&lt;/span&gt;
&lt;span class="nv"&gt;age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %Y /run/worker.alive&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt; age &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; threshold &lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;mark_instance_unhealthy                      &lt;span class="c"&gt;# let the platform replace it&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the naive version bites. Look at where the &lt;code&gt;touch&lt;/code&gt; sits: after &lt;code&gt;process&lt;/code&gt;. The marker only advances when a job &lt;em&gt;completes&lt;/em&gt;. Now think about an empty queue. &lt;code&gt;reserve_job&lt;/code&gt; blocks, nothing arrives, the loop never reaches the &lt;code&gt;touch&lt;/code&gt;, and to the watchdog that's indistinguishable from a loop wedged on a stuck job. Both stop refreshing the file. A worker that's perfectly healthy and simply out of work gets cycled for no reason. This assumes &lt;code&gt;reserve_job&lt;/code&gt; returns on a timeout: it long-polls for a few seconds, then comes back empty. If instead it blocks forever on an empty queue, the loop stalls there, the marker goes stale, and even the fixed heartbeat won't save an idle worker from being killed. The poll has to be able to time out for any of this to hold.&lt;/p&gt;

&lt;p&gt;So the marker should prove one thing: the loop is still turning. Move the heartbeat so it fires on every iteration, idle poll included, and pick the threshold above your longest legitimate job so a slow-but-healthy job isn't read as a wedge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;job&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;reserve_job&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;                 &lt;span class="c"&gt;# blocks up to a poll timeout, then returns&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; process &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;touch&lt;/span&gt; /run/worker.alive              &lt;span class="c"&gt;# proof the loop turned, job or no job&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fixes the single-threaded loop. But most real workers aren't single-threaded, and that's the next assumption to pull out. They run a pool, a handful of threads or goroutines or async tasks pulling jobs in parallel, and one &lt;code&gt;touch&lt;/code&gt; from a shared spot lies about all of them. Say ten workers share the process and one wedges on a deadlocked query while the other nine keep churning through small jobs. The marker stays fresh, the instance stays green, and you've quietly lost ten percent of your throughput with a connection stuck open the whole time. That's a grey failure, and a central heartbeat is exactly blind to it, for the same reason the HTTP check was: it's measuring something beside the work, not the work. The signal has to come from all of them at once. Each one refreshes its own timestamp and the watchdog reads the oldest live one, or the pool supervisor checks its children before it touches the file.&lt;/p&gt;

&lt;p&gt;Whether the loop is doing &lt;em&gt;useful&lt;/em&gt; work (draining a queue that's actually backing up) is a different question, and liveness is the wrong place to answer it. That belongs to queue-depth monitoring, one level up. Keep the two apart, or you'll wire queue depth into liveness and rebuild the exact correlated-failure trap from the last post, this time on your workers. And even a healthy heartbeat only proves the loop turns, not that the turning gets anything done, which is a separate failure this post doesn't close.&lt;/p&gt;

&lt;p&gt;A few more details bite. First, startup: a worker that just booted hasn't turned the loop yet, so the marker is missing on a healthy instance. Seed it at boot, or give the watchdog a grace window that covers boot plus the first poll cycle before it's allowed to judge a fresh instance. Otherwise every deploy shoots its own new workers. And the threshold cuts both ways: it has to clear your longest honest job, so if that job runs forty-five minutes, a genuinely wedged worker also sits forty-five minutes before the watchdog notices. Long jobs and fast recovery pull against each other here, and the way out isn't a bigger threshold, it's the job itself reporting progress as it runs, a different signal living one level up.&lt;/p&gt;

&lt;p&gt;Second, restart versus replace. The tempting fix when the watchdog fires is to bounce the worker in place through the process supervisor: SIGTERM, wait, then SIGKILL if it's still there. The pragmatic move is to skip the ceremony and just rotate the whole instance. It's simpler and clears more, a wedged socket, a leaked handle, memory corrupted into a corner, all gone with the box. What it won't clear is a cause that travels with the work: a job that wedges whatever picks it up, or a shared dependency that's still down. But those don't survive an in-place restart either, so there's nothing to weigh. Rotating is slower, so wire the infra to bring the replacement up before it pulls the wedged one out, unless you like explaining capacity dips.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why put HTTP on a worker at all
&lt;/h2&gt;

&lt;p&gt;Once the signal is a file the loop touches, a separate HTTP endpoint mostly earns its keep on a request service, where the listener is already there and a &lt;code&gt;/health&lt;/code&gt; route is free. A worker usually has no listener at all, so bolting one on means a server, a port, and sometimes a second container in the pod whose whole job is to answer 200. If the file already carries the signal, that's a lot of machinery to expose the same fact.&lt;/p&gt;

&lt;p&gt;On Kubernetes you can skip the listener entirely. The worker doesn't sit behind a Service, so there's no traffic to gate with a readiness probe, which leaves liveness as the whole question here (the last post pulled those two apart). Nothing routes to an HTTP port anyway, and the liveness probe reads the marker directly with an &lt;code&gt;exec&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# probe runs with no shell, so wrap the test yourself&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$((&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$(date&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+%s)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$(stat&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-c&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;%Y&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/run/worker.alive)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;))&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-lt&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;60&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;]"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
&lt;span class="c1"&gt;# slow first turn? use a startupProbe, not a fat initialDelaySeconds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two catches with the &lt;code&gt;exec&lt;/code&gt; approach. First, the image has to carry &lt;code&gt;sh&lt;/code&gt;, &lt;code&gt;stat&lt;/code&gt; and &lt;code&gt;date&lt;/code&gt;, and carry the flags the command expects, which a distroless image won't and a busybox one bends, so a probe that passes on your laptop can misfire on Alpine. Verify it against the real base image, or ship a tiny static helper that does the check and exits non-zero. Second, &lt;code&gt;exec&lt;/code&gt; isn't free: every probe forks three processes, so ten seconds a pod across a big fleet is a steady drip of spawns and kubelet overhead.&lt;/p&gt;

&lt;p&gt;There's a way around both, and it's where the signal being a metric pays off. If the loop already exports its heartbeat as a metric your monitoring collects, whatever you run, an alarm defined on that metric, going stale past a threshold, can fire the reaction from outside the pod: no forks, no shell, no &lt;code&gt;exec&lt;/code&gt; on the box at all. The shift is that this detects and alerts rather than restarts, the alarm pages someone or triggers automation, where a liveness probe would have the kubelet kill the pod on the spot. So it catches the wedge but doesn't reset it by itself. Pair it with a probe if you want the automatic restart, or run it alone where a human or a separate automation closes the loop. A file with &lt;code&gt;exec&lt;/code&gt; is simplest and self-contained, an in-process handler on a shared timestamp skips the fork tax, and a metric moves the check off the box entirely at the cost of that directness.&lt;/p&gt;

&lt;p&gt;On EC2 with an ASG the file wins more easily, because a watchdog already runs on the box reading that same marker. A cron job comparing the marker's age to a threshold is enough; systemd has a native watchdog that does the same thing if you'd rather not run cron. Either way, an HTTP endpoint on top buys nothing the watchdog doesn't already have. I haven't run workers on ECS myself, so I'll leave its specifics alone rather than guess, but the split is the same wherever you land: the signal comes from the loop, not the transport.&lt;/p&gt;

&lt;h2&gt;
  
  
  Long-running processes don't get an honest signal for free
&lt;/h2&gt;

&lt;p&gt;The worker is one case of a wider shape. Crons, queue consumers, stream processors, anything long-running that isn't already sitting behind something that checks it, none of them get an honest health signal for free the way a request handler does. And the failure follows a similar pattern every time: a green endpoint, a running process, an exit code of zero, standing in for work that quietly stopped. The transport says alive. The work says nothing, because nobody wired the work to speak.&lt;/p&gt;

&lt;p&gt;The fix generalizes as cleanly as the trap does. Put the signal somewhere only real work can emit it, and when it stops arriving in the window you expect, rotate the process. Whether the loop is doing anything &lt;em&gt;useful&lt;/em&gt; beyond staying alive, draining a backlog, actually committing rows, is a second signal living one level up, and worth building separately. Get the first one right and a stuck worker stops looking healthy. Get both and you can see it slow down before it stops.&lt;/p&gt;

&lt;p&gt;A worker wedged on a dead job used to sail through its health check and pile work up behind a cheerful 200. Wire the signal to the loop, and it trips the check the moment it stops moving, which is the moment you wanted to know.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Two things this leaves open, both about the same blind spot. A worker can turn its loop forever without making progress, and it can hang precisely because it's beating on a dependency that needs room to recover. Same root: a signal that says "alive" over a process doing no useful work. Which of those bites you first decides what's worth building next.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>sre</category>
      <category>monitoring</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>The Health Check That Caused the Outage</title>
      <dc:creator>Michał Iżewski</dc:creator>
      <pubDate>Tue, 21 Jul 2026 19:43:44 +0000</pubDate>
      <link>https://dev.to/michal-izewski/the-health-check-that-caused-the-outage-329b</link>
      <guid>https://dev.to/michal-izewski/the-health-check-that-caused-the-outage-329b</guid>
      <description>&lt;p&gt;Your database dropped out for a few minutes. Your health check turned that into a full outage.&lt;/p&gt;

&lt;p&gt;That's not a hypothetical situation I made up for effect. It happened to us twice, in two different shapes. Here's the first.&lt;/p&gt;

&lt;p&gt;We had a health check that reached DocumentDB. One day a Security Group rollout didn't go to plan, and the app could no longer reach DocumentDB. It sat behind maybe a tenth of our endpoints, so the damage should've been small.&lt;/p&gt;

&lt;p&gt;It wasn't. The processes never crashed, the app was alive on every instance. But the health check couldn't reach DocumentDB, so it flagged every instance as unhealthy at the same moment, and the platform started cycling the whole HTTP tier out, faster than the replacements could come back. The workers kept running with nothing to do, because the thing that fed them had gone dark. Took us a while to trace it back to one Security Group change. A dependency that maybe a tenth of the product needed had taken down all of it.&lt;/p&gt;

&lt;p&gt;Putting that database check in the health endpoint feels responsible, and that's why so many teams do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The assumption every orchestrator makes
&lt;/h2&gt;

&lt;p&gt;Start with what a health check is for. The load balancer, the autoscaling group, the Kubernetes control plane: all of them run the same loop. Find the unhealthy instance, take it out of rotation, put a healthy one in. Route around the broken thing.&lt;/p&gt;

&lt;p&gt;That loop has a hidden assumption baked in. It assumes failures are independent. And it works because when one instance breaks, the others are fine, so there's somewhere to send traffic and something to replace the dead node with.&lt;/p&gt;

&lt;p&gt;A deep health check breaks that assumption. The moment your check queries a dependency that every instance shares, you've tied the health of all of them to one external thing. When that thing goes down, every instance fails its check in the same second. The machinery built to route around one broken instance now has nothing healthy to route to. It was never designed for correlated failure, and you created correlated failure on purpose.&lt;/p&gt;

&lt;p&gt;That's the core of it. The rest of this post is what it looks like on EC2, ECS and EKS, and the way out, which turns out to be one principle applied a little differently each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Liveness vs readiness: the same mistake, two outcomes
&lt;/h2&gt;

&lt;p&gt;Liveness and readiness are the two jobs a health check can have: is the process alive, and should it get traffic right now. Kubernetes splits them into separate probes; most other setups blur them into one.&lt;/p&gt;

&lt;p&gt;Where you wire the deep check decides how badly it hurts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On readiness&lt;/strong&gt;, all instances go NotReady together when the dependency blips. The load balancer sees zero healthy targets. Now even requests that never touched the database get nothing back, because there's no instance left in rotation to serve them. A local problem becomes a total one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On liveness&lt;/strong&gt;, it's worse. The orchestrator doesn't only stop sending traffic. It decides the app is dead and starts killing and restarting processes, because a dependency is slow. So at the exact moment the system is fragile, you add cold starts, fresh connection storms, and more pressure on the dependency that was already struggling. You're feeding the fire you were trying to put out.&lt;/p&gt;

&lt;p&gt;Liveness answers one question: is this process alive and able to serve a trivial request. It doesn't touch business dependencies. If the process is alive, liveness keeps succeeding and the orchestrator leaves it running. Whether traffic is routed to it is a separate, readiness concern. Endpoints that actually need the database return 503 from the app's error handler, per request, when the database is down. That 503 comes from your code, not from the infrastructure pulling the whole instance out of service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same trap on EC2 + ASG + ALB
&lt;/h2&gt;

&lt;p&gt;Most writing on this assumes Kubernetes. The trap is the same on plain EC2, slower and more expensive, and both of our incidents ran there: behind an ALB, with an Auto Scaling Group.&lt;/p&gt;

&lt;p&gt;The setup is ordinary. The ALB target group runs a health check against an endpoint. The ASG has &lt;code&gt;health check type = ELB&lt;/code&gt;, so it replaces any instance the load balancer marks unhealthy. Reasonable, until the checked endpoint reaches a shared dependency. Then one downstream failure fails every target at once, the ASG marks the whole fleet unhealthy, and it does what you configured: terminate and replace.&lt;/p&gt;

&lt;p&gt;Here's where it turns into a loop that's hard to break from the inside. When the ASG launches a replacement, CodeDeploy runs the deployment lifecycle on that fresh instance before it enters service, validation hook included. If that validation hook also reaches the dependency, it fails too, because the dependency is still down. So the replacement never passes validation and never joins the fleet. The old instances are cycled out by the health check, and the new ones can't get in past the deploy gate. The fleet can't even rebuild itself with the artifact it already has, because the thing failing is the dependency check, not the code. That's the rotation the DocumentDB outage put us in. You don't recover until the dependency does.&lt;/p&gt;

&lt;p&gt;An ASG doesn't understand any of this. It reasons about a single number: desired capacity minus current capacity. It sees a missing instance and launches one, with no idea that every instance is failing for the same reason. So it keeps throwing fresh machines at a wall, each one hitting it in the same spot.&lt;/p&gt;

&lt;p&gt;One AWS detail matters here, because the earlier framing is too simple for the ALB specifically. An ALB doesn't black-hole traffic the moment every target is unhealthy. It fails open: with no healthy target in a group, it routes to all of them anyway, on the assumption that the check is the thing at fault. So on EC2 the lost capacity isn't the ALB refusing to route. It's the ASG terminating the instances the ALB flagged, and the failed replacements never coming up behind them.&lt;/p&gt;

&lt;p&gt;The shallow version is undramatic. The ALB check hits a lightweight endpoint that touches no external dependencies, the ASG replaces an instance only when the shallow check says that instance itself is unhealthy, and the maintenance policy launches a replacement before terminating the old instance, so capacity never dips.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ALB target group: shallow check, nothing downstream behind it&lt;/span&gt;
&lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lb_target_group"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;health_check&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/health"&lt;/span&gt;   &lt;span class="c1"&gt;# process-alive only, never the database&lt;/span&gt;
    &lt;span class="n"&gt;matcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;deregistration_delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;   &lt;span class="c1"&gt;# drain in-flight requests on the way out&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# ASG: replace when the shallow check fails, launch before terminating&lt;/span&gt;
&lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_autoscaling_group"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;health_check_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ELB"&lt;/span&gt;

  &lt;span class="n"&gt;instance_maintenance_policy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;min_healthy_percentage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
    &lt;span class="n"&gt;max_healthy_percentage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;110&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of it helps if the endpoint behind &lt;code&gt;path = "/health"&lt;/code&gt; reaches a shared dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second incident: when more instances made it worse
&lt;/h2&gt;

&lt;p&gt;Underneath, this outage and the DocumentDB one were the same: a connectivity problem that automation escalated into a fleet-wide one, both rooted in infra config. What made this one harder to climb out of was an autoscaler that took the cascade and pulled it tighter. It's the same machinery as the earlier replacement loop, pointed the other way: instead of swapping instances out, it kept piling new ones on.&lt;/p&gt;

&lt;p&gt;We'd been scaling up to chew through a big backlog, and the capacity got ahead of what the database could take. As I remember it, enough workers came up to use up the database's connection limit, and RDS stopped accepting new connections. From there it was a feedback loop.&lt;/p&gt;

&lt;p&gt;You might expect a stalled database to leave the app idle, blocked on I/O, with the CPU relaxed. It went the other way, and the scaling rule fired on CPU. I was on the sidelines for this one, so I'm reconstructing the compute side, but the shape is familiar: the box wasn't waiting quietly, it was working hard at failing. Workers piled up blocked on connections that never came, every failed request threw and was logged, and a log-shipping agent churned through the flood. None of it is useful work, but all of it burns CPU, and to a scaling rule a pinned CPU looks like demand.&lt;/p&gt;

&lt;p&gt;So it scaled out, the worst thing it could do. Every new instance opened its own connections on boot, so the moment a slot freed up a rising instance grabbed it before anything stable could. The fleet climbed to its ceiling and kept rotating, and none of it helped, because the bottleneck was the database, not the compute.&lt;/p&gt;

&lt;p&gt;The fix had to go where the bottleneck was, at the dependency: more connections, a bigger instance, the usual capacity work. A connection proxy would've softened it too, by pooling connections instead of letting every instance open its own, but softening is not removing. A proxy still fronts a finite database, and not every dependency has one. The cleaner answer is to stop generating the pile-on.&lt;/p&gt;

&lt;p&gt;What stuck with me was the shape of it. More instances is the standard answer to load. Here, more instances kicked us in the face. The autoscaler assumed every new instance would help. But every new instance made the starvation worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  ECS sits in the middle
&lt;/h2&gt;

&lt;p&gt;ECS lands between the manual EC2 setup and full Kubernetes, and the trap shows up there too, under different names.&lt;/p&gt;

&lt;p&gt;ECS gives you two health signals. The container health check, the &lt;code&gt;HEALTHCHECK&lt;/code&gt; in your task definition, behaves like liveness: fail it and ECS replaces the task. The target group health check, run by the ALB, behaves like readiness: fail it and the task is deregistered from traffic, and replaced. Put a dependency check in either one and you get the same cascade as everywhere else, with ECS rescheduling the whole set of tasks against a dependency that's still down.&lt;/p&gt;

&lt;p&gt;The one ECS-specific knob here is &lt;code&gt;healthCheckGracePeriodSeconds&lt;/code&gt;, which gives a freshly started task a window before the ALB health check can mark it unhealthy. Notice what it does and doesn't cover: it buys time for a slow boot, nothing more. It says nothing about whether a downstream is healthy. Those are two different problems, and ECS has no separate signal for the second one, so the workaround later in this post applies here too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kubernetes splits the one signal into three
&lt;/h2&gt;

&lt;p&gt;I'm still getting to know Kubernetes. I come from the EC2 and ASG side, where you wire rotation and replacement together by hand. What struck me while learning EKS is that the platform separates three questions EC2 forces you to answer with a single signal: is this alive, should it get traffic, is it still starting.&lt;/p&gt;

&lt;p&gt;Each question maps to a different action. Kubernetes restarts a container that fails liveness. It holds traffic off a pod that hasn't passed readiness. It waits through a slow boot with the startup probe instead of killing the pod for being slow. A rolling update never sends traffic to a pod until it reports ready.&lt;/p&gt;

&lt;p&gt;It's easy to overstate the startup probe. It covers slow startup and nothing else. None of the three probes was built to express dependency health. Each answers one thing about the instance itself: liveness, the process is alive; readiness, this pod can take traffic; startup, the boot has finished. People do wire a downstream check into readiness, and sometimes that's right, but it's something you add on top. What the platform gives you by default is those three concerns kept separate. That's the real difference from EC2, where there's one signal, so everything gets crammed into it, which is also why the deep-check mistake is so easy to make there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Readiness is about useful work
&lt;/h2&gt;

&lt;p&gt;Readiness has a real job, and it's not checking dependencies. It gates traffic during rolling deploys and graceful drain. A pod starts, the liveness check returns 200 right away because the process is up, but the caches are cold and the connection pools are empty, so the first requests would be slow or fail. Readiness holds traffic off until the pod is warmed up, and during shutdown it lets you drain in-flight requests before the process exits. It's about this instance's own state, not the health of anything downstream.&lt;/p&gt;

&lt;p&gt;Which is why the common correction misses. "Don't check the database in liveness, check it in readiness instead" just moves the deep check. On a shared dependency the correlated failure comes along for the ride: the dependency blips, every instance reports not-ready at once, and the load balancer is back to zero healthy targets, with all the operational mess that brings.&lt;/p&gt;

&lt;p&gt;There's at least one shape where a readiness check on a dependency is the right call. Picture a small, single-purpose service: it takes a message and writes it to a queue, and that's all it does. If the queue is gone, the service has no useful work left. It can do nothing except return 503, and every one of those failed requests is another connection thrown at a dependency that's trying to recover. A healthy-but-useless instance hammering an unhealthy dependency can be the very thing that keeps it from coming back. Here, dropping the instance from rotation simply helps. There's no partial availability to protect, because every request needs the one thing that's down, and pulling out of routing takes pressure off the dependency while it recovers.&lt;/p&gt;

&lt;p&gt;The distinction is footprint. A readiness check on a dependency is right when that dependency is the service's whole reason to exist. It becomes the outage from the start of this post the moment the dependency sits behind only part of a larger app and gets treated as a prerequisite for all of it. So readiness answers "can I do useful work," not "is my shared downstream alive."&lt;/p&gt;

&lt;h2&gt;
  
  
  When the platform gives you one signal
&lt;/h2&gt;

&lt;p&gt;What do you do when you can't express "alive but degraded" at the instance level, because there's one health signal and it's binary. Maybe you're on EC2 with a single ALB check, maybe your platform doesn't separate the probes the way EKS does. The move is to push the dependency awareness down one level, from the instance to the request.&lt;/p&gt;

&lt;p&gt;Keep &lt;code&gt;/health&lt;/code&gt; shallow. It returns 200 if the process can serve a trivial request, and that signal alone decides rotation and replacement. Then let each endpoint decide for itself. An endpoint that needs the database tries the database and returns 503 if it's down. An endpoint that doesn't need the database keeps returning 200 and keeps serving. The blip degrades only the endpoints that depend on the broken thing. The rest stay up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqq8141mcz96cz6acn3lk.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqq8141mcz96cz6acn3lk.webp" alt="One dependency down. Partial availability." width="799" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This has a name worth using: partial availability. The goal is to keep serving the requests that don't need the dependency, not to pretend it's healthy. If 20% of your requests depend on the failed component, fail those 20% and keep the other 80% working. A shallow check plus per-request errors gets you that. A deep check gets you zero percent, because it takes the whole fleet down over a dependency that most requests never touch.&lt;/p&gt;

&lt;p&gt;One honest limit of the per-request approach: it still reaches for the dependency on every request. Each call tries, waits, and fails, so a struggling dependency keeps getting poked while it recovers. The usual tool for cutting that off is the circuit breaker: past a failure threshold it stops trying, returns the error at once, and probes in the background until the dependency is back. That's a post in itself, so I'll leave it here as a name to look up.&lt;/p&gt;

&lt;p&gt;The real fix is keeping shared dependencies out of the signal that drives replacement and routing. A shallow &lt;code&gt;/health&lt;/code&gt; is usually the simplest way to do that. There's a blunter fallback when you can't change the check itself: set &lt;code&gt;health check type = EC2&lt;/code&gt;, so the ASG replaces only on instance-level failure and a failing ALB check can no longer trigger termination. It breaks the churn loop, but treat it as a last resort. The ELB check is what catches a process that's running but wedged: a deadlocked app, a server returning stale 500s, something alive but no longer answering. Drop to &lt;code&gt;type = EC2&lt;/code&gt; and you lose that, and have to rebuild it with a watchdog of your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correlated failure is the pattern
&lt;/h2&gt;

&lt;p&gt;Most of us design the happy path well. The question that gets asked too rarely is "what happens when a dependency gets slow or goes away." A health check is one of the few places where that question has a default answer already baked in, and the default that feels most responsible, prove the whole stack works on every check, is the one that converts a small dependency blip into a system-wide outage.&lt;/p&gt;

&lt;p&gt;The pattern runs through every layer. Health checks, load balancing, auto-replacement, auto-scaling: all of it assumes failures are roughly independent, and that adding or replacing capacity helps. The machinery routes around the broken instance because the other instances are fine. Tie every instance to the same shared dependency and that assumption is gone. One slow downstream takes the whole fleet together, and the system built to save you either has nothing left to route to, or it scales straight into the bottleneck and makes recovery impossible.&lt;/p&gt;

&lt;p&gt;The checks earn their keep by staying simple. Liveness: is this process alive. Readiness: can this instance do useful work yet. Neither asks whether a shared downstream is healthy, because that's a per-request question. Answer it per request, and a broken dependency costs you the requests that needed it. Answer it at the instance level, and it costs you everything.&lt;/p&gt;

&lt;p&gt;Get the distinction right and the same blip stays contained: only the endpoints that needed the broken dependency fail, the rest keep serving. No fleet-wide rotation, no autoscaler digging the hole deeper, no recovery fought by your own automation. The machinery built to save you finally does it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Background workers run into the same correlated-failure pattern from another direction: an HTTP health endpoint can happily report a live process while the work loop itself is dead. That's its own post.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>sre</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>The Session ID That Wouldn't Stop Changing</title>
      <dc:creator>Michał Iżewski</dc:creator>
      <pubDate>Tue, 07 Jul 2026 06:48:12 +0000</pubDate>
      <link>https://dev.to/michal-izewski/the-session-id-that-wouldnt-stop-changing-4fdo</link>
      <guid>https://dev.to/michal-izewski/the-session-id-that-wouldnt-stop-changing-4fdo</guid>
      <description>&lt;p&gt;I was implementing a feature where the session container would track a &lt;code&gt;lastActivity&lt;/code&gt; timestamp, updated on every authenticated request. Standard stuff. I wrote it, tested it locally with curl, and noticed something odd: I kept getting a new &lt;code&gt;Set-Cookie&lt;/code&gt; header value on every response. Not occasionally. On every single one. A week later I was sending a pull request to &lt;a href="https://github.com/mezzio/mezzio-session-cache" rel="noopener noreferrer"&gt;&lt;code&gt;mezzio/mezzio-session-cache&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup: Two Backends, One Session
&lt;/h2&gt;

&lt;p&gt;Our system had a constraint: two backend applications, written in different languages, sharing a single user session. One was the main PHP/Mezzio app. The other was a service in a different stack that needed to read from, and update the &lt;code&gt;lastActivity&lt;/code&gt; timestamp on, the same session container.&lt;/p&gt;

&lt;p&gt;There are a few ways to make polyglot session sharing work. We landed on a shared cache backend (Redis) with a well-defined session structure. Both apps could read and write through their own libraries, as long as they agreed on the storage format and the cookie name. The session ID was the contract.&lt;/p&gt;

&lt;p&gt;That contract is the part that quietly broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Missing Escape Hatch
&lt;/h2&gt;

&lt;p&gt;My first instinct was the usual list of suspects. Was something calling &lt;code&gt;regenerateId()&lt;/code&gt; in a middleware I didn't know about? Was there a logout being triggered somehow? Was a misconfigured cache layer evicting and recreating sessions?&lt;/p&gt;

&lt;p&gt;After a bit of digging through the call stack, I ended up in the library itself. And there it was: &lt;code&gt;CacheSessionPersistence&lt;/code&gt; was regenerating the session ID &lt;em&gt;whenever the session data changed&lt;/em&gt;. Not on login. Not on privilege escalation. On &lt;em&gt;every write&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That's when the real question hit me: why on earth would a library do that by default?&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading Code Before Changing It
&lt;/h2&gt;

&lt;p&gt;When you find behavior that surprises you in someone else's code, the wrong move is to immediately label it broken. The right move is to assume the maintainers had a reason, and find out what it was.&lt;/p&gt;

&lt;p&gt;The reason, in this case, is &lt;strong&gt;session fixation&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Session fixation is a class of attack where an attacker tricks a victim into using a session ID the attacker already knows. If the session ID never changes after authentication or privilege changes, the attacker can hijack that session and act as the victim. The standard defense is to regenerate the session ID at any point where the trust level of the session changes: at minimum on login, ideally at any meaningful state transition.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Mezzio's library went further: it regenerated on &lt;em&gt;every&lt;/em&gt; data change. Defense-in-depth taken to its logical extreme. The cost (a few extra cookie writes) is invisible to most apps. The benefit (making session fixation nearly impossible to exploit through normal usage) is real. For a single-application setup, you'd never notice the trade-off. For anything distributed, it changes the session ID from a stable contract into an ephemeral token that rotates with every write.&lt;/p&gt;

&lt;p&gt;For our architecture, that was catastrophic. Both backends depended on the session ID remaining stable. The moment our PHP app updated a &lt;code&gt;lastActivity&lt;/code&gt; timestamp, the library regenerated the ID on the spot. The other backend, still holding the previous ID, was now pointing at a session that no longer existed. A classic stale reference problem, except the staleness wasn't caused by TTL or eviction. It was caused by the library deliberately rotating identifiers under us. The contract broke.&lt;/p&gt;

&lt;p&gt;I wasn't alone in hitting this. Months before my PR, &lt;a href="https://github.com/mezzio/mezzio-session-cache/issues/43" rel="noopener noreferrer"&gt;an issue&lt;/a&gt; described the same underlying problem from a different angle: users losing their sessions when a response failed to reach the client, because the old session ID had already been destroyed the moment the session was persisted. Two production scenarios, one missing escape hatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Decision: Where Does the Fix Belong?
&lt;/h2&gt;

&lt;p&gt;Three paths on the table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Patch around it in our app.&lt;/strong&gt; Avoid writing to the session in certain code paths, or build a parallel storage mechanism that bypasses the library. Every developer on the team would need to remember the rule. Invisible discipline. The worst kind of debt.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fork the library.&lt;/strong&gt; Maintain our own version with regeneration disabled. Now I own a fork of a security-sensitive library. Every upstream release becomes a merge conflict review. Every security advisory becomes a question of "does that affect our fork too?"&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Contribute upstream.&lt;/strong&gt; Add an opt-in to disable auto-regeneration, keep the secure default, let maintainers decide if it's worth merging. Rejection risk is real. Review iterations are real. But the change either lands and stops being my problem, or it doesn't and I'm back to one of the other two paths anyway.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It wasn't a hard call. Patching around it costs the team forever. Forking costs me forever. Contributing upstream costs me a week, with non-zero rejection risk. The expected value of the week was lower than the certain cost of the other two options, by a wide margin.&lt;/p&gt;

&lt;p&gt;What tipped it was the security angle. A fork of a session library is the kind of thing that would haunt me in three years when someone reports a CVE and I have to figure out whether our fork is affected, whether the upstream patch applies cleanly, and whether I still remember why we forked in the first place. Patching around the library has the same problem in a different shape: someone new joins the team, doesn't know we patched around the library, writes to the session in what looks like a perfectly reasonable place, and session sync breaks in production. It's happened to me before, on the receiving end. I wasn't going to set that up for someone else.&lt;/p&gt;

&lt;p&gt;Only upstream made sense long-term.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Cost:&lt;/strong&gt; A week of focused work, plus review iterations spread over the week. Real rejection risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Win:&lt;/strong&gt; A library-level escape hatch maintained by the project, not by us. Zero ongoing maintenance cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the Change
&lt;/h2&gt;

&lt;p&gt;The shape of the contribution was deliberately small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A new &lt;code&gt;auto_regenerate&lt;/code&gt; config option, defaulting to &lt;code&gt;true&lt;/code&gt; (no behavior change for existing users).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A constructor argument on &lt;code&gt;CacheSessionPersistence&lt;/code&gt; that threaded the option through.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The factory updated to read the option from config.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Tests covering both the default behavior and the new opt-out path.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Psalm baseline updated to reflect the new types.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;End result, from a user's perspective, looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/autoload/session.global.php&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'mezzio-session-cache'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'cookie_name'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'auto_regenerate'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// opt out of regeneration on every write&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Config-level rather than a runtime toggle, because flipping session ID regeneration mid-request is the kind of thing that gets you into trouble you didn't ask for.&lt;/p&gt;

&lt;p&gt;That last point about Psalm matters. When you write code in someone else's project, you're not just meeting a functional bar, you're meeting their &lt;em&gt;quality&lt;/em&gt; bar. Mezzio uses Psalm at a strict level. The PR wasn't done when the feature worked; it was done when every static analysis check passed at the level the maintainers expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Review Process Was the Real Test
&lt;/h2&gt;

&lt;p&gt;The PR went through a proper review with two maintainers from the project. The feedback was sharp and useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Tests were requested specifically for the &lt;code&gt;false&lt;/code&gt; case. Fair. I'd tested the default but not the new branch as thoroughly as I should have.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A type-casting suggestion on the factory: &lt;code&gt;(bool) $autoRegenerate&lt;/code&gt; to remove a Psalm baseline entry and prevent a class of common errors.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One small inline tweak suggested via GitHub's review UI.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The standard was what stood out. Every comment was the kind of thing a careful engineer would say in your own team's review. No gatekeeping, no theater. Just "this needs to be a little more robust, here's why."&lt;/p&gt;

&lt;p&gt;I addressed every point, pushed updated commits, and the PR was merged into the &lt;code&gt;1.13.x&lt;/code&gt; branch. The feature shipped in version &lt;code&gt;1.13.0&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Looking Back
&lt;/h2&gt;

&lt;p&gt;The bug wasn't in our code. The fix didn't belong in our code either. The work was upstream, so I went upstream.&lt;/p&gt;

&lt;p&gt;Two things stuck with me from this PR. First: when a library has a default that surprises you, that default is part of the API: not a bug, not laziness, but a deliberate choice the maintainers made for users who haven't thought about the edge case. Working with that choice rather than against it is the difference between contributing and forking.&lt;/p&gt;

&lt;p&gt;Second: turning off &lt;code&gt;auto_regenerate&lt;/code&gt; doesn't &lt;em&gt;remove&lt;/em&gt; the session fixation defense. It moves it, from the library into the application. That's a fair trade when you've already got the rest of the controls in place (regeneration on login, on privilege escalation, on logout). It's a security regression when you don't.&lt;/p&gt;

&lt;p&gt;The rest is the general pattern for working with other people's code: read more than you write, take time to understand the maintainers' position, don't fork what you can fix upstream. This PR just made them concrete.&lt;/p&gt;




&lt;p&gt;The PR discussed here is &lt;a href="https://github.com/mezzio/mezzio-session-cache/pull/51" rel="noopener noreferrer"&gt;mezzio/mezzio-session-cache#51&lt;/a&gt;, merged into the &lt;code&gt;1.13.x&lt;/code&gt; branch in May 2024. The &lt;code&gt;auto_regenerate&lt;/code&gt; option shipped in version &lt;code&gt;1.13.0&lt;/code&gt; and remains in the library today. A follow-up contribution from the same project, &lt;a href="https://github.com/mezzio/mezzio-session-cache/pull/52" rel="noopener noreferrer"&gt;#52&lt;/a&gt;, implementing &lt;code&gt;InitializePersistenceIdInterface&lt;/code&gt;, shipped in &lt;code&gt;1.14.0&lt;/code&gt; a month later. The library has continued on its &lt;code&gt;1.x&lt;/code&gt; line; the latest release at the time of writing is &lt;code&gt;1.17.0&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I Cut Our CI/CD Pipeline from 35 to 5 Minutes</title>
      <dc:creator>Michał Iżewski</dc:creator>
      <pubDate>Mon, 01 Jun 2026 21:48:55 +0000</pubDate>
      <link>https://dev.to/michal-izewski/how-i-cut-our-cicd-pipeline-from-35-to-5-minutes-5anm</link>
      <guid>https://dev.to/michal-izewski/how-i-cut-our-cicd-pipeline-from-35-to-5-minutes-5anm</guid>
      <description>&lt;p&gt;You push a critical hotfix, switch branches—and your CI is still running 30 minutes later. In my current project, our backend pipeline ran across a massive monorepo (~7,000 unit, ~600 integration, ~150 API E2E tests, ~200MB &lt;code&gt;vendor&lt;/code&gt;) — and it bloated to a painful 35+ minutes.&lt;/p&gt;

&lt;p&gt;It was killing productivity. While the team was frustrated, no one had the time to dive into the runner infrastructure. My roots are in Linux sysadmin work, and I’ve always carried that 'tinkerer' DNA into my software engineering. I don't stop at "it works" — I want to understand the full execution path and how it interacts with the OS and the hardware.&lt;/p&gt;

&lt;p&gt;Driven by that urge to look under the hood, I decided against throwing more AWS resources at the problem. Instead, I dug into what was actually slowing things down. This isn’t a full how-to. It’s a practical look at what actually moved the needle — and what didn’t — when we cut our pipeline down to ~5 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Bypassing AWS EBS Limits with RAM Disks (&lt;code&gt;tmpfs&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;Initially, I suspected our test databases were the main I/O bottleneck. But a quick look at our EC2 metrics revealed the truth: the CPU was barely breaking a sweat, while disk I/O was completely maxed out. The real killer? Logs and temporary file processing. We deliberately run our CI tests in &lt;code&gt;debug&lt;/code&gt; mode so developers get a full stack trace instantly upon failure.&lt;/p&gt;

&lt;p&gt;Writing massive debug logs and processing large volumes of temporary files on EBS under concurrent load was grinding the runners to a halt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; I mounted both the MySQL data directories and our application's temporary storage directly to RAM disks. To squeeze every last drop of performance, I also bypassed MySQL's data safety mechanisms (since we don't care about data loss if a CI container crashes) and added a &lt;code&gt;size&lt;/code&gt; limit to avoid OOM kills by the kernel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.ci.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql:8.0&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--innodb_flush_log_at_trx_commit=0 --sync_binlog=0&lt;/span&gt;
    &lt;span class="na"&gt;tmpfs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/lib/mysql:rw,noexec,nosuid,size=1G&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app:test&lt;/span&gt;
    &lt;span class="na"&gt;tmpfs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/app/var/logs:rw,size=500M&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/app/var/cache:rw,size=500M&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Caveat:&lt;/strong&gt; This works perfectly as long as your runners are not oversubscribed. &lt;code&gt;tmpfs&lt;/code&gt; shifts the bottleneck from I/O to memory capacity. We did hit OOM issues early on when running too many concurrent jobs — proper memory limits per container turned out to be mandatory. Otherwise, you’re just trading slow pipelines for unstable, randomly crashing ones.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What surprised me the most was how skewed the gains were —&lt;/em&gt; &lt;code&gt;tmpfs&lt;/code&gt; &lt;em&gt;alone accounted for the majority of the speedup. This wasn’t an incremental improvement — it was the turning point that made the rest of the optimizations actually matter.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Trade-off:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lost:&lt;/strong&gt; Durability (which we don't care about in CI).
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gained:&lt;/strong&gt; Massive I/O speed for logging and DB operations.
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. The Artifact Trade-Off and S3 Caching
&lt;/h2&gt;

&lt;p&gt;GitLab’s default &lt;code&gt;GIT_STRATEGY: clone&lt;/code&gt; forces every concurrent job to clone the entire repository. In a massive polyglot monorepo — where pulling the code means downloading other unrelated microservices, frontend apps, and heavy assets just to run backend tests — this is brutal.&lt;/p&gt;

&lt;p&gt;I refactored the pipeline so only the first setup job fetches the code (using &lt;code&gt;GIT_DEPTH: 1&lt;/code&gt;). Here is where I made a conscious architectural decision: I configured the job to install PHP dependencies and package the exact state (&lt;code&gt;vendor/&lt;/code&gt;) into a GitLab artifact.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Wait, isn't passing&lt;/em&gt; &lt;code&gt;vendor&lt;/code&gt; &lt;em&gt;as an artifact an anti-pattern?&lt;/em&gt; In massive Node/PHP projects, yes. The artifact can weigh 1GB and zipping/unzipping it takes longer than a fresh install. However, in our specific context, our vendor footprint was manageable. This is a classic case where not going strictly by the book is the better choice. Building a full Docker image just for CI dependencies would have added build time and maintenance overhead without solving the real bottleneck. Combined with migrating our GitLab cache and artifacts to a dedicated AWS S3 bucket, pulling the vendor artifact with high bandwidth and low latency was significantly faster than hitting external package registries across 10 concurrent jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pro-tip: The Authoritative Classmap.&lt;/strong&gt; Instead of a standard install, I combined dependency resolution with autoloader optimization. By using &lt;code&gt;--classmap-authoritative&lt;/code&gt;, I forced Composer to generate a static class map and stop any further filesystem lookups for missing classes. In a large-scale application, this eliminates thousands of redundant I/O operations during test execution.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .gitlab-ci.yml&lt;/span&gt;
&lt;span class="na"&gt;setup_and_build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
  &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;GIT_DEPTH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;composer.lock&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vendor/&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# One command to rule them all: install + authoritative classmap&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;composer install --prefer-dist --no-progress --classmap-authoritative&lt;/span&gt;
  &lt;span class="na"&gt;artifacts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vendor/&lt;/span&gt;
    &lt;span class="na"&gt;expire_in&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1 hour&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Downstream jobs (like parallel test suites or static analysis) no longer touch Git or external registries. They just download the optimized artifact from the build job and execute immediately. In practice, this removed an entire class of network-bound variability from the pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Trade-off:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lost:&lt;/strong&gt; Clean, from-scratch dependency isolation per job.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gained:&lt;/strong&gt; Bypassing network bottlenecks and redundant Git/Composer operations.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. The Parallelization Trap (And Why Paratest Failed Us)
&lt;/h2&gt;

&lt;p&gt;A common piece of advice for slow PHP pipelines is: &lt;em&gt;"Just use Paratest to run it in parallel!"&lt;/em&gt; We tried. We shaved off a few seconds from our unit tests, but integration tests crashed and burned.&lt;/p&gt;

&lt;p&gt;Why? Because parallel execution exposes the sins of legacy test architecture. &lt;strong&gt;Parallelization isn’t a performance optimization—it’s an architectural test.&lt;/strong&gt; Our integration tests lacked proper state isolation and collided over database records. True parallelization requires architectural changes: wrapping tests in database transactions that rollback automatically, or dynamically provisioning isolated database schemas per test thread. &lt;strong&gt;If your tests rely on shared state, parallelization doesn’t make them faster — it just makes them fail faster.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Honorable Mention: Watch Your Docker Limits
&lt;/h2&gt;

&lt;p&gt;As we sped things up and ran more tests concurrently using our RAM disks, we hit a bizarre wall. It manifested as intermittent &lt;code&gt;EMFILE&lt;/code&gt; ("Too many open files") errors during peak parallel load.&lt;/p&gt;

&lt;p&gt;Our Docker daemon on the self-hosted AWS runners was still using the default file descriptor limit (&lt;code&gt;ulimit -n 1024&lt;/code&gt;). Instead of waiting for an external DevOps team to manually edit the &lt;code&gt;daemon.json&lt;/code&gt; on the EC2 instances, I implemented the fix directly via Infrastructure as Code in our compose file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.ci.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# ... image and tmpfs config ...&lt;/span&gt;
    &lt;span class="na"&gt;ulimits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;nofile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;soft&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;65536&lt;/span&gt;
        &lt;span class="na"&gt;hard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;65536&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was a good reminder: once you remove one bottleneck, the next one is usually just beneath it — often at the OS level.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. OPcache in CLI and the JIT Dilemma
&lt;/h2&gt;

&lt;p&gt;Usually, OPcache is something you tune for your web servers and completely ignore in CLI environments. But since we already had our blazing-fast &lt;code&gt;tmpfs&lt;/code&gt; RAM disks set up, I decided to run an experiment.&lt;/p&gt;

&lt;p&gt;We run three different test suites sequentially within a single CI job. I configured PHP to dump its OPcache directly into the RAM disk (&lt;code&gt;opcache.file_cache=/tmp/.ci-opcache&lt;/code&gt;) and enabled it for CLI. The result? The first suite primed the cache, giving the second and third suites a significant cold-start boost.&lt;/p&gt;

&lt;p&gt;But here is where it gets interesting: the JIT trade-off.&lt;/p&gt;

&lt;p&gt;Naturally, I tried enabling PHP 8's JIT compiler. In our environment, this significantly reduced the benefit of the OPcache file cache. While standard opcodes can still be persisted to the file cache, PHP's JIT-compiled machine code cannot. To be strict with facts: the JIT buffer lives entirely in shared memory and is tightly coupled to the runtime environment. Unlike opcodes, the actual machine code is not dumped to the OPcache file cache, so it has to be regenerated on each run.&lt;/p&gt;

&lt;p&gt;Since CLI runs spawn separate processes, the file cache becomes the only practical way to reuse compiled opcodes across test processes. JIT effectively neutralized this advantage — the machine code still has to be regenerated in every process. Our integration and API E2E tests are heavily I/O-bound rather than CPU-bound, so the instant cold start from the RAM-backed file cache delivered a much bigger win than JIT ever could.&lt;/p&gt;

&lt;p&gt;However, you can't just turn on OPcache in a massive project and call it a day. You have to fine-tune it, keeping in mind that with a RAM disk, you are paying with RAM twice (once for the active OPcache memory, and once for the storage in &lt;code&gt;/tmp/.ci-opcache&lt;/code&gt;). Here is our configuration. &lt;em&gt;(Note: I'm showing this in&lt;/em&gt; &lt;code&gt;php.ini&lt;/code&gt; &lt;em&gt;format for readability, but in our pipeline, we just passed these as&lt;/em&gt; &lt;code&gt;-d&lt;/code&gt; &lt;em&gt;flags directly to the PHP CLI command).&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# Custom php.ini for test jobs
&lt;/span&gt;&lt;span class="py"&gt;opcache.enable&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;opcache.enable_cli&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;opcache.memory_consumption&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;256&lt;/span&gt;
&lt;span class="py"&gt;opcache.interned_strings_buffer&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;64&lt;/span&gt;
&lt;span class="py"&gt;opcache.max_accelerated_files&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;60000&lt;/span&gt;
&lt;span class="py"&gt;opcache.validate_timestamps&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;
&lt;span class="py"&gt;opcache.enable_file_override&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;opcache.save_comments&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;opcache.file_update_protection&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;
&lt;span class="py"&gt;opcache.jit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;off&lt;/span&gt;
&lt;span class="py"&gt;opcache.jit_buffer_size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why these specific values?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Capacity (&lt;/strong&gt;&lt;code&gt;max_accelerated_files=60000&lt;/code&gt; &lt;strong&gt;&amp;amp;&lt;/strong&gt; &lt;code&gt;interned_strings_buffer=64&lt;/code&gt;&lt;strong&gt;):&lt;/strong&gt; Big projects mean tons of files, especially in &lt;code&gt;vendor/&lt;/code&gt;. If you have 40k files, you have 40k namespaces and class names. You need enough buffer space to hold that string map. PHP internally adjusts this value to a nearby prime number, so instead of chasing an exact value, I opted for a safe upper bound.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Memory (&lt;/strong&gt;&lt;code&gt;memory_consumption=256&lt;/code&gt;&lt;strong&gt;):&lt;/strong&gt; I didn't just pull this number out of thin air. I calculated it by checking the actual size of the &lt;code&gt;/tmp/.ci-opcache&lt;/code&gt; dump for our project, adding the buffer size, and leaving a safe headroom. Don't just set this to some massive number randomly, or you will exhaust your runner's memory fast.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Killing I/O (&lt;/strong&gt;&lt;code&gt;validate_timestamps=0&lt;/code&gt;&lt;strong&gt;):&lt;/strong&gt; This is crucial. In CI, your code is idempotent. It doesn't change during the run. Disabling timestamp validation stops PHP from wasting I/O to check if files were modified.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instant caching (&lt;/strong&gt;&lt;code&gt;file_update_protection=0&lt;/code&gt;&lt;strong&gt;):&lt;/strong&gt; This disables the safety delay for caching new files. We want our fresh CI files cached instantly without any grace periods - this is just a precaution, even though files extracted from the artifact are likely older than the default 2-second threshold.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The "Sanity Check" Moment: The Relative Path Trap&lt;/strong&gt; During the final rollout, we hit a few &lt;code&gt;No such file or directory&lt;/code&gt; errors. My first thought? "It must be the OPcache configuration." The reality? The optimization simply exposed a technical debt in our test bootstraps. A recent directory shift combined with hardcoded relative paths (&lt;code&gt;../../../../&lt;/code&gt;) meant our tests were looking for files in the system root.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; High-performance optimizations like &lt;code&gt;enable_file_override=1&lt;/code&gt; and &lt;code&gt;classmap-authoritative&lt;/code&gt; require strict path discipline — ideally absolute paths. Fixing our paths resolved the issue in our case, but it’s worth noting that &lt;code&gt;enable_file_override&lt;/code&gt; allows OPcache to short-circuit filesystem lookups and rely entirely on its internal cache. This behavior can conflict with tools like Composer, which expect standard checks (like &lt;code&gt;file_exists()&lt;/code&gt;) to reflect the real filesystem state, not OPcache’s internal lookup table. If you run into unexplained filesystem issues, this setting should be one of the first things to revisit.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Side note on static analysis:&lt;/em&gt; I also tested OPcache and JIT on our PHPStan jobs. The gains were basically within the margin of error. PHPStan 2.x is already incredibly well-optimized, and since we reuse the PHPStan result cache via GitLab artifacts anyway, it was already flying. A good reminder that not every tool needs low-level engine tweaks if it already does its own caching well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Trade-off:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lost:&lt;/strong&gt; JIT CPU optimizations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gained:&lt;/strong&gt; Instant cold-starts across multiple test suites via a RAM-backed file cache.
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Removing Cruft &amp;amp; The Infamous &lt;code&gt;sleep(1)&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Lastly, I audited the code itself. We removed operations that only made sense for local development (like seeding default developer accounts).&lt;/p&gt;

&lt;p&gt;More importantly, I found a hardcoded &lt;code&gt;sleep(1)&lt;/code&gt; in a PHP class - a legacy band-aid for a concurrency issue. In production, a 1-second delay in a queue-consuming worker might be invisible. But in the test suite, running across 60 iterations with mocked data? That's 60 seconds of pure, wasted waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Cutting a pipeline from 35+ to around 5 minutes rarely comes from a single silver bullet. It’s about combining infrastructure pragmatism (&lt;code&gt;tmpfs&lt;/code&gt;, smart caching, bypassing OS limits) with a deep audit of what your application is actually doing under the hood.&lt;/p&gt;

&lt;p&gt;But the real business value? Throughput and infrastructure costs. Since our GitLab runners are self-hosted on AWS EC2, we pay for instance uptime, not serverless compute seconds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pipeline Time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;35 min&lt;/td&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pipelines / Hour (Per Node)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.7&lt;/td&gt;
&lt;td&gt;12.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Node Capacity Increase&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~700%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This allowed us to drastically scale down the total number of EC2 instances required to handle the engineering team's daily load, completely eliminating CI queue times during peak hours.&lt;/p&gt;

&lt;p&gt;The biggest takeaway? CI performance problems are rarely about compute — they’re about eliminating unnecessary I/O, redundant work, and the hidden bottlenecks in your stack.&lt;/p&gt;

&lt;p&gt;The final result? A lower AWS bill, a snappy 5-minute feedback loop, and a much happier engineering team.&lt;/p&gt;

</description>
      <category>php</category>
      <category>cicd</category>
      <category>performance</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
