<?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 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>
