<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Performance Tuning | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/performance-tuning/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/performance-tuning/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sun, 18 Oct 2026 06:03:01 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Performance Tuning | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/performance-tuning/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Dockerfile Layer Caching: Why Every Build Reinstalls Everything</title>
		<link>https://john-nessime.com/blog/technical-guides/dockerfile-layer-caching/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 18 Oct 2026 06:03:01 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Build Cache]]></category>
		<category><![CDATA[BuildKit]]></category>
		<category><![CDATA[Cache Mounts]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Deployment]]></category>
		<category><![CDATA[Dockerfile]]></category>
		<category><![CDATA[GitHub Actions]]></category>
		<category><![CDATA[Layer Caching]]></category>
		<category><![CDATA[Multi-Stage Builds]]></category>
		<category><![CDATA[Performance Tuning]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=409</guid>

					<description><![CDATA[<p>Your Dockerfile copies the lockfile before the source, and CI still reinstalls every dependency on every commit. That gap between a correctly ordered Dockerfile and a build that behaves as if it isn't comes down to five distinct failure families: ordering traps, a build context that poisons checksums, a cache that never reaches CI, cache mounts that vanish with the runner, and multi-stage export modes. Here is how invalidation actually works and how to prove which one is costing you.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/dockerfile-layer-caching/">Dockerfile Layer Caching: Why Every Build Reinstalls Everything</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The pipeline log scrolls past and there it is again. The dependency install step, running in full, on a commit that touched one template file. Nobody edited the lockfile. The Dockerfile copies the lockfile before the source, exactly the way every guide says to. And it still reinstalls everything, every single time.</p>



<p class="wp-block-paragraph">That gap, between a Dockerfile that looks correctly ordered and a build that behaves as if it isn&#8217;t, is where most Dockerfile layer caching problems live. The ordering advice everyone repeats is real, but it&#8217;s maybe a third of the story. The rest is about what actually invalidates a layer, where the cache physically lives, and whether the machine running your build can reach it at all.</p>



<p class="wp-block-paragraph">So this is organized by failure family rather than by feature. Ordering that looks right and isn&#8217;t. A build context that quietly poisons your checksums. A cache that exists but is unreachable from CI. Cache mounts that promise more than they deliver. Multi-stage layouts and the <code>COPY --link</code> argument. Then how to prove which one you&#8217;re hitting instead of guessing.</p>



<h2 class="wp-block-heading">How the builder actually decides to reuse a layer</h2>



<p class="wp-block-paragraph">Two different rules are running, and mixing them up causes half the confusion.</p>



<p class="wp-block-paragraph">For most instructions, the builder compares the instruction string against the cached layer. That is the entire check. It does not look inside the container to see what changed. <code>RUN apt-get update</code> matches a cached layer whenever the text matches, regardless of what the package mirrors are serving today.</p>



<p class="wp-block-paragraph">For <code>COPY</code>, <code>ADD</code>, and <code>RUN</code> instructions that use a bind mount, the builder computes a cache checksum from the metadata of the files involved. Modification time is deliberately excluded, which is why a fresh <code>git clone</code> on a CI runner doesn&#8217;t invalidate every copy by itself.</p>



<p class="wp-block-paragraph">Then the rule that ties it together: once a layer misses, every layer after it rebuilds. No skipping ahead, no partial reuse. One bad instruction near the top costs you the whole tail of the file.</p>



<p class="wp-block-paragraph">Three consequences catch people out. A <code>RUN</code> line that installs OS packages caches indefinitely, so rebuilding months later reproduces the original package versions and &#8220;just rebuild the image&#8221; is not a security response on its own. Build arguments do invalidate the cache, so an <code>ARG</code> carrying a commit SHA near the top of the file is expensive. And build secrets do <em>not</em>: their contents are excluded from the checksum, though their IDs and mount paths are part of it.</p>



<p class="wp-block-paragraph">One more that bites teams chasing reproducible builds. <code>WORKDIR</code> respects the <code>SOURCE_DATE_EPOCH</code> build argument. Set it to a git commit timestamp for provenance and you invalidate <code>WORKDIR</code> and everything below it on every single commit. Pin it to a fixed value if you want deterministic timestamps without paying that.</p>



<h2 class="wp-block-heading">Failure family one: ordering that looks right</h2>



<p class="wp-block-paragraph">The canonical fix is to split the copy so dependency installation sits above the source code. Cheap to write, and it works:</p>



<pre class="wp-block-code"><code># syntax=docker/dockerfile:1
FROM node:22-bookworm-slim
WORKDIR /app

# Manifest and lockfile only. Changes rarely.
COPY package.json package-lock.json ./
RUN npm ci

# Source. Changes constantly. Everything below rebuilds, and that is fine.
COPY . .
RUN npm run build</code></pre>



<p class="wp-block-paragraph">What breaks this in real repositories is usually not the copy order. It&#8217;s something above it that nobody thinks of as a layer.</p>



<ul class="wp-block-list">
<li><strong>An <code>ARG</code> near the top.</strong> A build arg whose value changes per build invalidates everything that follows. Declare args as low in the file as the build allows.</li>



<li><strong>Labels carrying commit metadata.</strong> <code>LABEL</code> lines that embed a revision are cheap to run but they are still layers. Put them at the very bottom.</li>



<li><strong>Wildcard copies that pick up more than you think.</strong> <code>COPY package*.json ./</code> looks tidy until a generated file starts matching the glob. Name the files explicitly.</li>



<li><strong>Monorepo manifests scattered across packages.</strong> If installing needs twelve manifest files from twelve directories, copy them individually or accept you&#8217;re copying a subtree and lean hard on <code>.dockerignore</code>.</li>
</ul>



<p class="wp-block-paragraph">The general test: walk the file top to bottom and ask, for each line, &#8220;what makes this change?&#8221; Anything answering &#8220;every commit&#8221; belongs below anything expensive.</p>



<h2 class="wp-block-heading">Failure family two: the build context poisons the checksum</h2>



<p class="wp-block-paragraph">When you run <code>COPY . .</code>, the checksum covers everything in the context that isn&#8217;t excluded. That means <code>.git</code>, <code>node_modules</code>, coverage output, local <code>.env</code> files, and whatever your last test run left behind. A colleague running the test suite before pushing is enough to change the checksum and rebuild the tail of your image.</p>



<p class="wp-block-paragraph">A <code>.dockerignore</code> file in the root of the context fixes it, and the rules apply to subdirectories too:</p>



<pre class="wp-block-code"><code>.git
node_modules
coverage
dist
*.log
.env
.env.*
Dockerfile
.dockerignore</code></pre>



<p class="wp-block-paragraph">This pays twice. Smaller contexts transfer faster to the builder, and unrelated local files stop ending up inside your published image. If you have ever found a <code>.git</code> directory or a stray credentials file in a production image, this is the line of defense that was missing.</p>



<p class="wp-block-paragraph">There&#8217;s a second option when the source is only needed to produce an artifact and shouldn&#8217;t live in the image at all. A bind mount makes the context available for the duration of one <code>RUN</code> without adding it to any layer:</p>



<pre class="wp-block-code"><code># syntax=docker/dockerfile:1
FROM golang:1-bookworm
WORKDIR /build
RUN --mount=type=bind,target=. go build -o /app/server ./cmd/server</code></pre>



<p class="wp-block-paragraph">Two rules keep this from surprising you. The mount is read-only by default, so the output has to be written outside the mount target, which is why <code>/app/server</code> sits outside <code>/build</code>. And mounted files are not persisted into the final image or the build cache. Only what the <code>RUN</code> produced survives.</p>



<h2 class="wp-block-heading">Failure family three: Dockerfile layer caching that never reaches CI</h2>



<p class="wp-block-paragraph">This is the invisible one, and it&#8217;s behind most &#8220;my Dockerfile is ordered correctly and CI is still slow&#8221; tickets.</p>



<p class="wp-block-paragraph">The build cache is internal to the builder instance. Not to your account, not to your registry. To the builder. Switch builders and the cache doesn&#8217;t follow. Start a fresh ephemeral runner and there is no cache at all, so every instruction runs from scratch no matter how beautifully the file is ordered. Nothing warns you. The build just takes as long as a cold build, because it is one.</p>



<p class="wp-block-paragraph">The fix is an external cache. <code>--cache-to</code> exports the cache somewhere durable, <code>--cache-from</code> imports it at the start of the next build.</p>



<pre class="wp-block-code"><code>docker buildx build 
  --cache-from type=registry,ref=registry.example.com/app:buildcache 
  --cache-to type=registry,ref=registry.example.com/app:buildcache,mode=max 
  --tag registry.example.com/app:latest 
  --push .</code></pre>



<p class="wp-block-paragraph">Four details decide whether this works or quietly does nothing:</p>



<ul class="wp-block-list">
<li><strong><code>mode=max</code> versus the default.</strong> The default exports only the layers of the resulting image. In a multi-stage build the expensive work happens in a stage that never ships, so the default exports almost nothing useful. <code>mode=max</code> includes intermediate stages. It costs storage and upload time, and it is usually the right trade.</li>



<li><strong>Use a separate ref for the cache.</strong> A different tag or repository, anything that isn&#8217;t the tag you push the image to.</li>



<li><strong>Registry compatibility.</strong> The registry cache defaults to writing an image index and not every registry accepts one, with Amazon ECR the usual casualty. Setting <code>image-manifest=true</code> alongside <code>oci-mediatypes=true</code> writes a single image manifest instead.</li>



<li><strong>The default driver is limited.</strong> The plain <code>docker</code> driver supports the inline, local, registry and GitHub Actions backends only when the containerd image store is enabled. Otherwise create a builder with the docker-container driver first.</li>
</ul>



<p class="wp-block-paragraph">On GitHub Actions the equivalent lives in the build action:</p>



<pre class="wp-block-code"><code>- uses: docker/setup-buildx-action@v4

- uses: docker/build-push-action@v7
  with:
    push: true
    tags: ghcr.io/acme/app:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max</code></pre>



<p class="wp-block-paragraph">The GitHub Actions backend is convenient because it needs no credentials, but it draws from a per-repository storage budget shared with every other cache in that repo, and eviction is silent. A build that was fast last week can go cold with nothing changed in your Dockerfile. When that starts happening, move to a registry backend, or to an S3 or Azure Blob backend if you&#8217;d rather pay object storage rates than registry rates.</p>



<h2 class="wp-block-heading">Failure family four: cache mounts that don&#8217;t survive the job</h2>



<p class="wp-block-paragraph">Layer caching is all or nothing per instruction. Add one dependency, the install layer misses, and you re-download the four hundred packages you already had. Cache mounts fix exactly that: a directory that persists across builds even when the layer around it rebuilds.</p>



<pre class="wp-block-code"><code># syntax=docker/dockerfile:1
FROM python:3-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip 
    pip install -r requirements.txt</code></pre>



<p class="wp-block-paragraph">Point the target at whatever directory that tool uses for its own cache. Go wants its module and build caches, Rust the Cargo directories, .NET the NuGet package folder. Apt is the one needing care, because it expects exclusive access to its data:</p>



<pre class="wp-block-code"><code>RUN --mount=type=cache,target=/var/cache/apt,sharing=locked 
    --mount=type=cache,target=/var/lib/apt,sharing=locked 
    apt-get update &amp;&amp; apt-get install --no-install-recommends -y gcc</code></pre>



<p class="wp-block-paragraph">Without <code>sharing=locked</code>, parallel builds write to the same apt cache simultaneously and you get an inconsistent package database, intermittently, which is a genuinely unpleasant thing to debug.</p>



<p class="wp-block-paragraph">Now the part that isn&#8217;t in most tutorials. <strong>Cache mounts live on the builder&#8217;s local storage and they are not exported by <code>--cache-to</code>.</strong> The external cache carries layers. It does not carry cache mount contents. On a fleet of ephemeral CI runners, every cache mount you add is a no-op: a fresh runner starts with an empty mount, fills it during the build, then throws the machine away.</p>



<p class="wp-block-paragraph">They&#8217;re still worth adding for local development, where they pay for themselves immediately. Making them pay off in CI needs a builder with persistent storage behind it: a self-hosted runner that keeps its builder between jobs, a long-lived buildkitd on a VPS from somewhere like Contabo or InterServer reached over the remote driver, the Kubernetes driver with a volume attached to the builder pods, or a managed build service such as Docker Build Cloud or Depot that holds persistent cache volumes for you.</p>



<p class="wp-block-paragraph">One hard rule regardless: never put something the image needs into a cache mount. Installing into <code>/app/node_modules</code> behind a cache mount produces an image that works on your machine and has no dependencies in production, because the mount contents are discarded when the instruction finishes. Cache the package manager&#8217;s download directory, not its output.</p>



<h2 class="wp-block-heading">Failure family five: multi-stage builds and the COPY &#8211;link argument</h2>



<p class="wp-block-paragraph">Multi-stage builds help caching for a reason that has nothing to do with image size. They let you put expensive, rarely-changing work in a stage whose inputs are stable, and copy only the artifact forward.</p>



<pre class="wp-block-code"><code># syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]</code></pre>



<p class="wp-block-paragraph">The catch wastes an afternoon if you hit it cold: export cache in the default mode and the <code>deps</code> stage isn&#8217;t in the export, because it isn&#8217;t part of the final image. Multi-stage plus external cache means <code>mode=max</code>, or the whole structure buys you nothing in CI.</p>



<p class="wp-block-paragraph">Then there&#8217;s <code>COPY --link</code>, which comes up in every thread on this topic and deserves an honest treatment rather than a recommendation. The case for it is real: a linked copy creates an independent snapshot layer that doesn&#8217;t depend on the filesystem beneath it, then merges on top. The layer can be reused with <code>--cache-from</code> even when earlier instructions in the same stage changed, and it enables rebasing an image onto an updated base without re-running the build.</p>



<p class="wp-block-paragraph">The case against is also real, and it&#8217;s mostly semantics you didn&#8217;t ask to change. Linked layers start from an empty filesystem, so a destination that resolved through a symlink created in an earlier layer no longer behaves the same, and ambiguous destinations resolve differently. Use explicit trailing slashes for directories. There are also reports of cache accounting misbehaving with linked layers, and a long-standing open report that the cache behavior doesn&#8217;t match the announcement.</p>



<p class="wp-block-paragraph">Where I land: worth trying on artifact copies into a final stage, where destinations are plain directories and the win is largest. I wouldn&#8217;t blanket-apply it across a repository on the strength of a blog post, mine included. Measure a warm rebuild before and after. If the numbers don&#8217;t move, you&#8217;ve added a semantic footgun for nothing.</p>



<h2 class="wp-block-heading">Troubleshooting: proving which layer missed and why</h2>



<p class="wp-block-paragraph">Stop guessing. The build output tells you, if you ask for the plain progress format:</p>



<pre class="wp-block-code"><code>docker buildx build --progress=plain --tag app:dev .</code></pre>



<ol class="wp-block-list">
<li><strong>Find the first step not marked CACHED.</strong> Everything after it is guaranteed to rebuild, so everything after it is noise. There is exactly one problem and it is that step.</li>



<li><strong>If it&#8217;s a COPY or ADD, the inputs changed.</strong> List what the context actually contains, compare against <code>.dockerignore</code>, and look for generated files or lockfiles being rewritten by a pre-build script.</li>



<li><strong>If it&#8217;s a RUN, something above it changed.</strong> Scroll up. A build arg, a label, a base image tag that moved, or <code>SOURCE_DATE_EPOCH</code> set from a commit timestamp.</li>



<li><strong>If the earliest steps miss in CI but not locally,</strong> the import didn&#8217;t happen. Check the log for the cache manifest being imported. A missing or unreadable cache ref usually isn&#8217;t a hard failure, the build just proceeds cold.</li>



<li><strong>If it&#8217;s intermittent, suspect eviction.</strong> Garbage collection or a cache storage budget removed what you were relying on.</li>
</ol>



<p class="wp-block-paragraph">Two commands earn their place here. The first shows what the builder is currently holding, the second clears it so you can measure a genuine cold build:</p>



<pre class="wp-block-code"><code>docker buildx du
docker buildx prune --all</code></pre>



<p class="wp-block-paragraph">And when you want one stage to run fresh while keeping the rest cached, target it by name instead of nuking everything:</p>



<pre class="wp-block-code"><code>docker build --no-cache-filter deps --tag app:dev .</code></pre>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li><strong>Assuming the Dockerfile is the whole problem.</strong> If CI has no external cache configured, no amount of reordering helps. Check that first.</li>



<li><strong>Adding cache mounts to fix CI.</strong> Great locally, invisible in the pipeline unless the builder persists.</li>



<li><strong>Exporting cache in the default mode with a multi-stage build.</strong> The expensive stage isn&#8217;t in the export. This is the most common reason external caching looks configured and doesn&#8217;t help.</li>



<li><strong>Squashing everything into one giant RUN.</strong> Fewer layers is a size optimization, not a caching one. Collapsing distinct steps means any change reruns all of them.</li>



<li><strong>Treating a cached rebuild as a patched rebuild.</strong> A cached package install gives you the versions from the original build. If you rebuilt for a CVE, you need to break that cache on purpose.</li>



<li><strong>Using <code>latest</code> as a base tag.</strong> The digest moves under you, invalidating everything below unpredictably and making builds non-reproducible at the same time.</li>
</ul>



<h2 class="wp-block-heading">Best practices worth the effort</h2>



<ul class="wp-block-list">
<li>Order instructions by how often their inputs change, not by what feels logical. Stable and expensive at the top, volatile and cheap at the bottom.</li>



<li>Write the <code>.dockerignore</code> before the Dockerfile. Thirty seconds of work that prevents both cache churn and accidental secret inclusion.</li>



<li>Configure an external cache in CI on day one, with <code>mode=max</code> if the build has more than one stage.</li>



<li>Add cache mounts for every package manager in the build. One line each, and the difference between &#8220;added one dependency&#8221; and &#8220;re-downloaded the world&#8221;.</li>



<li>Track warm build duration as a metric rather than a vibe. A Grafana panel showing build time per branch turns &#8220;the build feels slow&#8221; into something you can point at.</li>



<li>Keep a scheduled no-cache build in the pipeline. It proves the Dockerfile still works from scratch and picks up base image updates that cached builds silently skip.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Why does my Docker build ignore the cache even though nothing changed?</h3>



<p class="wp-block-paragraph">Usually because something did change and it wasn&#8217;t the file you were watching. Build with <code>--progress=plain</code> and find the first step not marked CACHED. If it&#8217;s a COPY, a gap in <code>.dockerignore</code> is letting local artifacts into the context. If it&#8217;s early in the file, check build args, labels carrying commit metadata, or a base image tag that moved. In CI, the usual answer is that there was no cache to hit at all.</p>



<h3 class="wp-block-heading">Do fewer layers make Dockerfile layer caching better?</h3>



<p class="wp-block-paragraph">No, and the advice is often applied backwards. Combining commands into one <code>RUN</code> reduces image size and stops intermediate junk being committed into separate layers. It also means any change reruns all of it. Combine steps that genuinely belong together, like an update-and-install pair, and keep independent work in separate instructions.</p>



<h3 class="wp-block-heading">Do cache mounts work in GitHub Actions?</h3>



<p class="wp-block-paragraph">Not usefully on the default hosted runners. Cache mounts live on the builder&#8217;s local storage and are not exported by <code>--cache-to</code>, so an ephemeral runner starts with an empty mount every time. You need a builder with persistent storage: a self-hosted runner keeping its builder between jobs, a remote buildkitd on your own host, the Kubernetes driver with a volume, or a managed build service.</p>



<h3 class="wp-block-heading">Registry cache backend or the GitHub Actions one?</h3>



<p class="wp-block-paragraph">Start with the GitHub Actions backend because it needs no credentials and no extra infrastructure. Move to a registry backend when you outgrow the repository cache budget, when you want the cache shared across repositories or with local developer builds, or when silent evictions make build times unpredictable. Registry caching costs a round trip and some storage in exchange for being yours.</p>



<h3 class="wp-block-heading">Does changing a build secret invalidate the cache?</h3>



<p class="wp-block-paragraph">No. Secret contents are deliberately excluded from the cache checksum, so rotating a token leaves every layer valid. The secret&#8217;s ID and mount path do participate, so renaming one invalidates. If a changed secret needs to force a rebuild, pair it with a build argument you also change, since build args do invalidate.</p>



<h3 class="wp-block-heading">Why does a cached image still contain old, vulnerable packages?</h3>



<p class="wp-block-paragraph">Because a <code>RUN</code> instruction is matched on its text, not on what it would produce today. The package install layer stays valid indefinitely, so rebuilding without breaking that cache reproduces the original package set. Force it with <code>--no-cache</code>, target the relevant stage with <code>--no-cache-filter</code>, or move the base image tag so the layers below invalidate.</p>



<h3 class="wp-block-heading">Is COPY &#8211;link safe to use everywhere?</h3>



<p class="wp-block-paragraph">Not everywhere. It changes destination semantics because linked layers start from an empty filesystem, so anything relying on symlinks created by earlier layers can break and ambiguous destinations resolve differently. It&#8217;s most defensible on artifact copies into a final stage with explicit directory paths. Try it, measure a warm rebuild, keep it only if the numbers justify the change.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">Dockerfile layer caching fails in two categories that need completely different fixes. Either a layer is being invalidated when it shouldn&#8217;t be, which is a Dockerfile and build context problem, or the cache was never there to begin with, which is a builder and pipeline problem. Reordering instructions solves the first and does nothing at all for the second.</p>



<p class="wp-block-paragraph">So before you spend an afternoon rearranging COPY lines, run one build with plain progress output and find the first step that isn&#8217;t CACHED. If it&#8217;s step one, your problem isn&#8217;t the Dockerfile. It&#8217;s that the machine running the build has never seen your cache and nobody told it where to look.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a second pair of eyes on your build pipeline?</h2>



<p class="wp-block-paragraph">Slow container builds are usually three or four small problems stacked, not one big one, and they unpick quickly once you can see the whole pipeline. I help teams with:</p>



<ul class="wp-block-list">
<li>Auditing a Dockerfile end to end for invalidation traps, oversized build contexts and stages that never cache</li>



<li>Setting up external cache backends in GitHub Actions, GitLab CI or Jenkins, including registry and object storage backends and the awkward ECR manifest cases</li>



<li>Standing up a persistent remote buildkitd on your own infrastructure so cache mounts actually pay off in CI</li>



<li>Restructuring multi-stage builds so the expensive stages are cacheable and the runtime image stays small</li>



<li>Adding build duration and cache hit visibility to your existing dashboards so regressions get caught early</li>



<li>Separating &#8220;rebuild for a code change&#8221; from &#8220;rebuild for a security patch&#8221; so cached layers stop hiding unpatched packages</li>
</ul>



<p class="wp-block-paragraph">Send me your Dockerfile and a build log with <code>--progress=plain</code> output, and I&#8217;ll tell you which step is costing you and why.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/dockerfile-layer-caching/">Dockerfile Layer Caching: Why Every Build Reinstalls Everything</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Low Free Memory on Linux Isn&#8217;t a Problem. Here&#8217;s What Actually Is.</title>
		<link>https://john-nessime.com/blog/system-administration/low-free-memory-on-linux/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 11 Oct 2026 06:18:09 +0000</pubDate>
				<category><![CDATA[Linux]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[cgroups]]></category>
		<category><![CDATA[Kernel Tuning]]></category>
		<category><![CDATA[MemAvailable]]></category>
		<category><![CDATA[Memory Management]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[OOM Killer]]></category>
		<category><![CDATA[Page Cache]]></category>
		<category><![CDATA[Performance Tuning]]></category>
		<category><![CDATA[Pressure Stall Information]]></category>
		<category><![CDATA[Swap]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[vmstat]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=432</guid>

					<description><![CDATA[<p>A screenshot of free -h showing 200 MB free triggers more unnecessary change requests than almost any other output in server operations. Here is what the free command is really reporting, why the available column is the one that matters, and the four signals that actually tell you a machine is running out of memory: available, swap rate, pressure stall information and the OOM log.</p>
<p>The post <a href="https://john-nessime.com/blog/system-administration/low-free-memory-on-linux/">Low Free Memory on Linux Isn&#8217;t a Problem. Here&#8217;s What Actually Is.</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket came in flagged as urgent. A screenshot was attached: <code>free -h</code> on a database server, showing a little over 200 MB in the <strong>free</strong> column on a machine with 16 GB of RAM. Somebody had already drafted a change request to double the instance size before anyone looked at the rest of the output.</p>



<p class="wp-block-paragraph">The same output showed roughly 11 GB in the <strong>available</strong> column. Nothing was wrong, and nothing had been wrong for months. The only thing broken was the alert that fired.</p>



<p class="wp-block-paragraph">Low free memory on Linux is one of the most reliably misread signals in server operations. It looks like an emergency, it triggers scaling decisions, and it is almost always noise. This post covers what <code>free</code> is really telling you, which four signals actually indicate memory trouble, the one situation where a low free number genuinely matters, and what to put in your alert rules instead.</p>



<h2 class="wp-block-heading">What the free command is actually reporting</h2>



<p class="wp-block-paragraph"><code>free</code> does not measure anything. It parses <code>/proc/meminfo</code> and prints a handful of those fields in columns. Every number it shows comes straight from the kernel and is readable directly if you want to skip the formatting.</p>



<p class="wp-block-paragraph">A typical reading on a busy server looks roughly like this:</p>



<pre class="wp-block-code"><code>$ free -h
               total        used        free      shared  buff/cache   available
Mem:            15Gi       3.4Gi       231Mi       128Mi        12Gi        11Gi
Swap:          2.0Gi          0B       2.0Gi</code></pre>



<p class="wp-block-paragraph">Read that left to right and the story is boring. Applications hold a few gigabytes. The kernel filled almost everything else with page cache and buffers, because every file that gets read or written passes through memory on the way, and directory metadata lands in reclaimable slab. What is left over, genuinely untouched, is the free column.</p>



<p class="wp-block-paragraph">That last part is the bit people miss. <strong>Free memory is not a reserve. It is waste.</strong> A page holding a cached copy of a file your application reads every few seconds is doing useful work. A page sitting empty is doing nothing, and the kernel converts it into cache the moment it has a reason to. A server that has been up for a week and still shows plenty of free memory is a server that is barely touching its disk.</p>



<p class="wp-block-paragraph">If you want the raw source rather than the formatted view:</p>



<pre class="wp-block-code"><code>$ grep -E '^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SReclaimable|SwapTotal|SwapFree|Dirty):' /proc/meminfo</code></pre>



<p class="wp-block-paragraph">Two options are worth knowing. <code>free -w</code> switches to wide mode and splits buffers and cache into separate columns, which helps when attributing memory to a specific I/O pattern. <code>free -h -s 5</code> reprints every five seconds, a crude but effective trend view while you reproduce a problem.</p>



<h2 class="wp-block-heading">Signal one: available memory, not free memory</h2>



<p class="wp-block-paragraph">The <strong>available</strong> column maps to <code>MemAvailable</code> in <code>/proc/meminfo</code>, and it is the kernel&#8217;s own estimate of how much memory a new workload could allocate without pushing the system into swap. It has been present since kernel 3.14, so every distribution you are likely to be running exposes it.</p>



<p class="wp-block-paragraph">What matters is that it is <em>not</em> free plus cache. That naive sum is what people calculated before <code>MemAvailable</code> existed, and it overstates the truth. The kernel starts from free pages, subtracts the reserves it refuses to dip below, then adds back the portion of file-backed page cache and reclaimable slab it believes it could actually release. Both additions are capped, because some cache is pinned and some reclaimable slab objects are in use.</p>



<p class="wp-block-paragraph">The kernel documentation is explicit that this is an estimate whose accuracy varies from system to system, so treat it as a strong hint rather than an exact figure. It is still far better than anything you can compute yourself, and it is the number your dashboards should be built on.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">If you change one thing after reading this: point your memory alerts at available, not at free. Everything else in this post is refinement on top of that.</p>
</blockquote>



<h2 class="wp-block-heading">Signal two: swap activity, not swap usage</h2>



<p class="wp-block-paragraph">The second most common false alarm is swap. Someone sees a few hundred megabytes of swap in use and concludes the machine is thrashing. It usually isn&#8217;t.</p>



<p class="wp-block-paragraph">Swap <em>usage</em> is a stock: pages moved to disk at some point, possibly during a backup weeks ago, and never needed since. Leaving them there is correct, and pulling them back to satisfy a dashboard would evict something useful. Swap <em>activity</em> is a flow, and that is the part that hurts, because a page fault serviced from disk is orders of magnitude slower than one serviced from RAM.</p>



<p class="wp-block-paragraph"><code>vmstat</code> gives you the flow. The <code>si</code> and <code>so</code> columns are swap-in and swap-out, in blocks per second over the sampling interval:</p>



<pre class="wp-block-code"><code>$ vmstat 5 6</code></pre>



<p class="wp-block-paragraph">Discard the first row. <code>vmstat</code> reports averages since boot on its first line, which is almost never what you want. Read the rows after that. If <code>si</code> and <code>so</code> are both consistently non-zero, the machine is simultaneously reading pages back to serve current work and writing pages out to make room for new allocations. That is thrashing, and it is a real signal with no ambiguity attached.</p>



<p class="wp-block-paragraph">A single non-zero sample is not that. Brief swap activity during a log rotation or a backup window is normal. Sustained activity across several consecutive samples is what to escalate.</p>



<h2 class="wp-block-heading">Signal three: memory pressure, which is the one that catches things early</h2>



<p class="wp-block-paragraph">Pressure Stall Information is the signal I reach for first now, and it is badly underused. It has been in the kernel since 4.20 and it answers a question none of the other tools answer directly: <em>how much time are my processes losing because memory is contended?</em></p>



<pre class="wp-block-code"><code>$ cat /proc/pressure/memory
some avg10=0.00 avg60=0.00 avg300=0.00 total=0
full avg10=0.00 avg60=0.00 avg300=0.00 total=0</code></pre>



<p class="wp-block-paragraph">The <code>some</code> line is the share of wall-clock time in which at least one task was stalled waiting on memory. The <code>full</code> line is the share in which every non-idle task was stalled, meaning nothing useful happened at all. The averages cover ten seconds, one minute and five minutes, and <code>total</code> is a cumulative counter in microseconds that updates continuously, so it catches a two-second stall the running averages smooth away entirely.</p>



<p class="wp-block-paragraph">Here is why this beats a percentage threshold. Utilization tells you how much of a resource is consumed; pressure tells you whether that consumption is costing you anything. A machine can sit at high utilization for months with zero pressure, which means the sizing is fine. It can also sit at moderate utilization while <code>full</code> climbs, which means reclaim is working hard and latency is already suffering with no threshold crossed.</p>



<p class="wp-block-paragraph">With cgroup v2 mounted, the same data exists per cgroup in each directory&#8217;s <code>memory.pressure</code> file, so you can attribute pressure to a specific service rather than the whole machine. Prometheus node_exporter collects the system-level PSI counters, and if you already ship metrics to something like Grafana Cloud these graph well beside your latency panels.</p>



<h2 class="wp-block-heading">Signal four: the OOM log, which is the only unambiguous one</h2>



<p class="wp-block-paragraph">When memory really does run out, the kernel writes a record. It does not do this quietly and it does not do it conditionally. If there is no OOM record, no process was killed for memory, full stop.</p>



<pre class="wp-block-code"><code>$ sudo journalctl -k --since "24 hours ago" | grep -i "oom"
$ sudo dmesg -T | grep -i "killed process"</code></pre>



<p class="wp-block-paragraph">The kill line names the victim and reports its memory breakdown at the moment of death: total virtual size, anonymous resident set, file-backed resident set, shared memory resident set, and the <code>oom_score_adj</code> that was in effect. The line above it, where a process &#8220;invoked oom-killer&#8221;, tells you which allocation triggered the whole event, and that process is frequently not the one that got killed.</p>



<p class="wp-block-paragraph">That distinction is the most useful thing in the log. The kernel picks a victim by badness score, driven largely by how much memory a process holds relative to what is available to it, adjusted by <code>oom_score_adj</code>. A slow leak in a small helper can push the machine over the edge and get your database killed instead, because the database is simply the largest thing running. Read both lines before deciding who the culprit is.</p>



<p class="wp-block-paragraph">If you have a service that should survive at almost any cost, systemd exposes <code>OOMScoreAdjust=</code> in the unit file, which persists across restarts in a way that writing to <code>/proc/&lt;pid&gt;/oom_score_adj</code> does not. Use it sparingly. Protecting a process that is itself leaking just moves the crash somewhere less convenient.</p>



<h2 class="wp-block-heading">When low free memory on Linux really is the problem</h2>



<p class="wp-block-paragraph">Everything above assumes you are looking at a whole machine. Inside a container, the accounting changes, and this is the failure mode that is invisible until it isn&#8217;t.</p>



<p class="wp-block-paragraph">The cgroup memory controller charges page cache to the cgroup that caused it. Clean, evictable, perfectly reclaimable cache still counts toward <code>memory.current</code>, and that is what gets compared against <code>memory.max</code>. A container reading a large file has just charged that file to its own budget.</p>



<pre class="wp-block-code"><code>$ cat /sys/fs/cgroup/memory.current
$ cat /sys/fs/cgroup/memory.max
$ grep -E '^(anon|file|slab_reclaimable) ' /sys/fs/cgroup/memory.stat</code></pre>



<p class="wp-block-paragraph">The split in <code>memory.stat</code> is what you actually want. <code>anon</code> is memory your process genuinely holds; <code>file</code> is page cache charged to you that the kernel can take back under pressure. A container at its limit on mostly <code>file</code> is in a very different position from one at its limit on mostly <code>anon</code>.</p>



<p class="wp-block-paragraph">Two things follow from this that catch people out repeatedly:</p>



<ul class="wp-block-list">
<li><strong>Reclaim is not instant.</strong> Under heavy pressure the reclaim path itself runs slowly, so a container can be OOM-killed while still holding clean page cache that was theoretically available.</li>

<li><strong>Running <code>free</code> inside a container lies to you.</strong> Unless something virtualizes <code>/proc</code>, you are reading the host&#8217;s <code>/proc/meminfo</code>, which knows nothing about your limit. The cgroup files are the only truth here.</li>
</ul>



<p class="wp-block-paragraph">cgroup v2 also has <code>memory.high</code>, a soft limit. Crossing it triggers reclaim and throttling rather than a kill, which turns a hard failure into a slow one. That is usually the better trade, and it is worth setting deliberately rather than leaving it at its default.</p>



<h2 class="wp-block-heading">What to alert on</h2>



<p class="wp-block-paragraph">The alert on the ticket at the top of this post was watching the wrong number. Here is what a memory alert set looks like when it watches the right ones:</p>



<ul class="wp-block-list">
<li><strong>Available memory below a percentage of total, sustained.</strong> Ten percent for ten minutes is a reasonable starting shape. A single thirty-second dip is noise and should never page anyone.</li>

<li><strong>Sustained swap-in and swap-out together.</strong> Rate of change on the swap counters, not the absolute amount of swap in use.</li>

<li><strong>Memory PSI rising.</strong> The <code>some</code> line moving off the floor is your early warning. The <code>full</code> line moving at all is your late one.</li>

<li><strong>Any OOM kill, ever.</strong> This one is binary and should always generate a ticket, even if the service recovered on its own.</li>

<li><strong>Per-container memory events.</strong> The cgroup <code>memory.events</code> file counts limit hits, and a rising count is a limit that needs revisiting.</li>
</ul>



<p class="wp-block-paragraph">Notice that free memory does not appear anywhere in that list. There is no threshold on the free column that produces a useful alert, because the number is a byproduct of the kernel&#8217;s caching behaviour rather than a measure of anything you care about.</p>



<h2 class="wp-block-heading">Troubleshooting: which tool answers which question</h2>



<p class="wp-block-paragraph">When a memory question lands on you, the order you check things in matters more than the tools.</p>



<ol class="wp-block-list">
<li><strong>Did anything actually die?</strong> Check the kernel log for OOM records first. If there are none, this is a performance question and not a capacity failure, which changes everything about what you do next.</li>

<li><strong>Is the machine stalling?</strong> Read <code>/proc/pressure/memory</code>. Zero pressure with low free memory means you are done. Go close the ticket.</li>

<li><strong>Is it swapping right now?</strong> Run <code>vmstat 5</code> for half a minute and watch <code>si</code> and <code>so</code>. Ignore the first row.</li>

<li><strong>Who is holding the memory?</strong> <code>ps aux --sort=-%mem | head -15</code> gets the shortlist. Resident set size double-counts shared pages, so summing the column overstates the total.</li>

<li><strong>Is it the kernel rather than a process?</strong> If the process list does not account for the usage, check <code>slabtop</code> and <code>SUnreclaim</code> in <code>/proc/meminfo</code>. Unreclaimable slab growth is a different problem, usually a driver, filesystem or network workload creating enormous numbers of kernel objects.</li>

<li><strong>Is it growing?</strong> A leak is a slope, not a level, and one reading tells you nothing. This is the whole argument for having the graph before you need it.</li>
</ol>



<p class="wp-block-paragraph">One more that saves time on crowded boxes: resident set size is the wrong measure of what a process really costs, because shared libraries are counted against every process that maps them. Proportional set size divides shared pages among the processes sharing them, and tools reporting PSS give a far more honest answer when you are deciding what to move off a busy VPS.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li><strong>Dropping caches to make the number look better.</strong> Writing to <code>/proc/sys/vm/drop_caches</code> discards clean page cache and reclaimable slab. The kernel documentation says plainly that using it outside testing or debugging is not recommended, because everything dropped has to be re-read from disk. You trade a nicer-looking <code>free</code> output for an I/O spike and worse latency. It is a benchmarking tool, not an operations tool.</li>

<li><strong>Setting <code>vm.swappiness</code> to zero as a reflex.</strong> It does not disable swap and it does not make the machine faster. It makes the kernel strongly prefer evicting page cache over swapping anonymous pages, which on a database host means throwing away exactly the cache you wanted. Change it for a measured reason, not by default.</li>

<li><strong>Running with no swap at all.</strong> Swap is not just an overflow area. Without it the kernel loses the option of paging out genuinely cold anonymous memory, which means the only lever left under pressure is evicting cache or killing something. A modest swap file gives reclaim somewhere to go.</li>

<li><strong>Summing RSS across processes and comparing it to total RAM.</strong> Shared pages are counted once per process. The sum will exceed reality, sometimes dramatically.</li>

<li><strong>Resizing the instance before finding the slope.</strong> More RAM buys time against a leak. It does not fix one, and the bill arrives every month afterwards.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Graph available memory, swap rate and memory PSI on one panel. Together they read as a story; individually each one misleads.</li>

<li>Keep the raw counters, not just derived percentages. A percentage of an instance size that has since changed cannot answer &#8220;was this happening last week&#8221;.</li>

<li>Set container limits from observed <code>anon</code> usage plus headroom, not a round number someone picked, and put <code>memory.high</code> below <code>memory.max</code> so pressure shows up as slowness before it shows up as a kill.</li>

<li>Alert on rate of change for anything that can leak. The steady climb is the signal; the level it reached is just when you happened to look.</li>

<li>Right-size deliberately. On providers such as Contabo or InterServer, where RAM is a meaningful share of the monthly bill, the gap between a real requirement and a panicked upgrade is money you keep every month.</li>

<li>Write the explanation down where your team will find it. Every organization rediscovers the free-versus-available confusion independently, usually at three in the morning.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Why is free memory so low on my Linux server?</h3>



<p class="wp-block-paragraph">Because the kernel used it for page cache and buffers, which is the intended behaviour. Any file read or written passes through memory, and the kernel keeps those pages in case they are needed again. It reclaims them automatically the moment an application needs the space. Check the available column instead.</p>



<h3 class="wp-block-heading">What is the difference between free and available in free -h?</h3>



<p class="wp-block-paragraph">Free is memory that is completely unused. Available is the kernel&#8217;s estimate of how much a new process could allocate without swapping: free memory plus the portion of cache and reclaimable slab it believes it could release. Available is almost always much larger, and it answers the question you are actually asking.</p>



<h3 class="wp-block-heading">Should I clear the page cache to free up memory?</h3>



<p class="wp-block-paragraph">No. The kernel reclaims cache automatically under pressure, and more selectively than a blanket drop. Writing to <code>drop_caches</code> forces everything to be re-read from disk, and the kernel documentation advises against using it outside testing or debugging. Doing it on a schedule is a strong hint something else is misconfigured.</p>



<h3 class="wp-block-heading">Is swap being used a bad sign?</h3>



<p class="wp-block-paragraph">Not by itself. Pages that were swapped out during a quiet period and never needed again are fine where they are. What matters is swap traffic: sustained non-zero <code>si</code> and <code>so</code> in <code>vmstat</code> at the same time means the machine is thrashing. Alert on the rate, not the amount.</p>



<h3 class="wp-block-heading">How do I tell if a process was killed for running out of memory?</h3>



<p class="wp-block-paragraph">Search the kernel log. <code>journalctl -k</code> filtered for &#8220;oom&#8221;, or <code>dmesg -T</code> filtered for &#8220;killed process&#8221;, shows a record naming the process, its PID and its memory breakdown at the time. The kernel always logs this, so if there is no record the process died for some other reason.</p>



<h3 class="wp-block-heading">Why does my container get OOM-killed when memory looks fine inside it?</h3>



<p class="wp-block-paragraph">Because <code>free</code> inside a container usually reads the host&#8217;s memory information, not the cgroup limit being enforced. Read <code>memory.current</code>, <code>memory.max</code> and <code>memory.stat</code> instead. Page cache charged to the container counts toward its limit, so a read-heavy workload can hit the ceiling without any process holding much anonymous memory.</p>



<h3 class="wp-block-heading">What is a good memory alert threshold?</h3>



<p class="wp-block-paragraph">Available memory below roughly ten percent of total, sustained for ten minutes, is a defensible starting point to tune against your own workload. Pair it with memory PSI and an unconditional alert on any OOM kill. A threshold on the free column, at any value, will page you for nothing.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Conclusion</h2>



<p class="wp-block-paragraph">Low free memory on Linux is the expected steady state of a healthy, working server. The kernel fills memory with cache because empty memory earns nothing, and hands it back the instant an application asks. The free column is a byproduct of that behaviour, not a health metric, and alerting on it produces exactly the kind of urgent ticket that wastes an afternoon and ends in a change request nobody needed.</p>



<p class="wp-block-paragraph">The one thing worth remembering: <strong>free tells you what is idle, available tells you what you can have, and pressure tells you whether any of it is costing you.</strong> Watch the second and third, log the OOM events, and read the cgroup files rather than <code>free</code> whenever a container is involved.</p>



<h2 class="wp-block-heading">Need help reading your memory signals?</h2>



<p class="wp-block-paragraph">Most of the memory work I get asked for is not &#8220;add more RAM&#8221;. It is working out whether the number someone is worried about means anything. I can help with:</p>



<ul class="wp-block-list">
<li>Rebuilding memory alerts around available memory, swap rate and PSI so they stop paging on normal cache behaviour</li>

<li>Diagnosing repeated OOM kills, including the cases where the process being killed is not the one causing the problem</li>

<li>Setting container and pod memory limits from observed anonymous usage rather than guesses, with a soft limit so pressure surfaces before a kill does</li>

<li>Separating a genuine leak from normal cache growth using retained metrics rather than a single reading</li>

<li>Right-sizing servers and instances honestly, up or down, based on what the workload actually needs</li>

<li>Building the dashboard that turns the next memory question into a five-minute conversation instead of an incident</li>
</ul>



<p class="wp-block-paragraph">If you have a <code>free -h</code> output, a <code>vmstat</code> capture or an OOM log line that is bothering you, send it over and I will tell you what it means.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/system-administration/low-free-memory-on-linux/">Low Free Memory on Linux Isn&#8217;t a Problem. Here&#8217;s What Actually Is.</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Plenty of CPU, Zero Connections: Debugging Connection Pool Exhaustion on AWS</title>
		<link>https://john-nessime.com/blog/cloud-computing/connection-pool-exhaustion-aws/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 08 Oct 2026 06:17:49 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Databases]]></category>
		<category><![CDATA[Site Reliability Engineering]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Amazon Aurora]]></category>
		<category><![CDATA[Amazon ECS]]></category>
		<category><![CDATA[Amazon RDS]]></category>
		<category><![CDATA[CloudWatch]]></category>
		<category><![CDATA[Connection Leaks]]></category>
		<category><![CDATA[Connection Pinning]]></category>
		<category><![CDATA[Connection Pooling]]></category>
		<category><![CDATA[HikariCP]]></category>
		<category><![CDATA[Idle in Transaction]]></category>
		<category><![CDATA[Incident Response]]></category>
		<category><![CDATA[max_connections]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Performance Tuning]]></category>
		<category><![CDATA[PgBouncer]]></category>
		<category><![CDATA[Pool Exhaustion]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[RDS Proxy]]></category>
		<category><![CDATA[SRE]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=1146</guid>

					<description><![CDATA[<p>Pool timeouts while RDS sits at low CPU? The database isn't the bottleneck. Here's how to tell whether your app pool, max_connections or RDS Proxy ran out, the SQL that proves it, and the fixes that survive the next deploy.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/connection-pool-exhaustion-aws/">Plenty of CPU, Zero Connections: Debugging Connection Pool Exhaustion on AWS</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The alert says p99 latency is climbing. The application logs are full of <code>Connection is not available, request timed out after 30000ms</code>. Someone opens the RDS console, looks at CPU sitting low, freeable memory looking healthy and <code>DatabaseConnections</code> nowhere near the limit, and says the obvious thing: &#8220;The database is fine.&#8221;</p>



<p class="wp-block-paragraph">They&#8217;re right. The database is fine. That&#8217;s exactly why this incident drags on for an hour instead of ten minutes.</p>



<p class="wp-block-paragraph">Connection pool exhaustion on AWS is rarely a capacity problem. It&#8217;s a queueing problem, and the queue forms in one of three places: inside your application&#8217;s pool, at the database&#8217;s connection limit, or inside a proxy sitting between the two. Each layer has its own limit, its own error message and its own fix. Resizing the instance fixes none of them reliably, and sometimes makes one worse.</p>



<p class="wp-block-paragraph">Below: how to tell which layer ran out, the SQL and metrics that prove it, and fixes that survive the next deploy. Examples use RDS and Aurora for PostgreSQL and MySQL, plus HikariCP, SQLAlchemy and node-postgres where their defaults matter.</p>



<h2 class="wp-block-heading">First question: which layer ran out?</h2>



<p class="wp-block-paragraph">Before touching anything, figure out where requests are waiting. The error text usually tells you, if you read it literally instead of pattern-matching on &#8220;connection&#8221;.</p>



<ul class="wp-block-list">
<li><strong>The application pool is empty.</strong> The error comes from your pool library, not the database. HikariCP says the connection is not available and the request timed out. SQLAlchemy raises a <code>QueuePool limit ... reached</code> timeout. The database never saw these requests at all.</li>



<li><strong>The database refused new connections.</strong> The error comes from the server. PostgreSQL returns a <code>FATAL</code> about too many clients or about the remaining connection slots being reserved (the exact wording varies by major version). MySQL returns error 1040, <code>Too many connections</code>.</li>



<li><strong>The proxy is holding the queue.</strong> With RDS Proxy in the path, clients connect fine but wait for a database connection to be borrowed, then time out after the proxy&#8217;s borrow timeout.</li>
</ul>



<p class="wp-block-paragraph">Then compare what the database is actually holding against its limit. On PostgreSQL:</p>



<pre class="wp-block-code"><code>SHOW max_connections;

SELECT count(*) AS client_connections
FROM pg_stat_activity
WHERE backend_type = 'client backend';</code></pre>



<p class="wp-block-paragraph">On MySQL, <code>Max_used_connections</code> is the high-water mark since the last restart, which is useful when the spike has already passed:</p>



<pre class="wp-block-code"><code>SHOW VARIABLES LIKE 'max_connections';
SHOW GLOBAL STATUS LIKE 'Threads_connected';
SHOW GLOBAL STATUS LIKE 'Max_used_connections';</code></pre>



<p class="wp-block-paragraph">If the app is throwing pool timeouts while the server is well under its limit, you&#8217;re in the first failure family. If the server is at the limit, it&#8217;s the second. That single comparison saves you from the most common wrong move, which is raising <code>max_connections</code> when the database was never the bottleneck.</p>



<h2 class="wp-block-heading">Pool exhaustion with an idle database: the one that hides</h2>



<p class="wp-block-paragraph">This is the failure that fools people, because every database graph looks healthy while the application is effectively down.</p>



<p class="wp-block-paragraph">A pool hands out a fixed number of connections per process. A request borrows one, and nobody else can use it until it&#8217;s returned. The pool doesn&#8217;t care whether the request is running SQL during that time. If your code opens a transaction, runs a query, then calls a payment API, uploads to S3 or sends an email before committing, the connection sits borrowed and idle for the whole round trip.</p>



<p class="wp-block-paragraph">Now let that third-party API slow down. Each request holds its connection longer, the pool drains, new requests queue for a connection, and they time out. The database sees a handful of sessions doing nothing. CPU drops, because less SQL is running than usual. The dashboard gets greener as the outage gets worse.</p>



<p class="wp-block-paragraph">So <strong>a slow dependency that has nothing to do with the database can exhaust your database pool</strong>, and database metrics will never show it.</p>



<h3 class="wp-block-heading">Prove it with pg_stat_activity</h3>



<p class="wp-block-paragraph">Group sessions by state and by who owns them. The <code>state</code> column is the evidence; <code>idle in transaction</code> means a client opened a transaction and then went quiet without committing.</p>



<pre class="wp-block-code"><code>SELECT state,
       application_name,
       count(*) AS sessions,
       max(now() - xact_start) AS oldest_transaction
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY state, application_name
ORDER BY sessions DESC;</code></pre>



<p class="wp-block-paragraph">Then list the worst offenders. The <code>query</code> column shows the last statement the session ran, not what it&#8217;s doing now, which is exactly what you want here: it tells you where in the code the transaction was left open.</p>



<pre class="wp-block-code"><code>SELECT pid,
       application_name,
       client_addr,
       now() - xact_start AS transaction_age,
       left(query, 80) AS last_statement
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start;</code></pre>



<p class="wp-block-paragraph">On MySQL, open transactions live in <code>information_schema.INNODB_TRX</code>. The thread ID joins back to the process list so you can see the host and user:</p>



<pre class="wp-block-code"><code>SELECT trx_mysql_thread_id, trx_started, trx_state
FROM information_schema.INNODB_TRX
ORDER BY trx_started;</code></pre>



<p class="wp-block-paragraph">One thing makes all of this much faster: set <code>application_name</code> per service in the connection string. Without it, every session looks the same and you&#8217;re guessing from IP addresses that change with every task restart.</p>



<h3 class="wp-block-heading">Leak or slow holder?</h3>



<p class="wp-block-paragraph">Two different bugs produce the same symptom. A <strong>leak</strong> is a code path that borrows a connection and never returns it, usually an exception path that skips the close. A <strong>slow holder</strong> returns the connection eventually but keeps it through non-database work.</p>



<p class="wp-block-paragraph">The tell is recovery. Leaks never recover without a restart; the pool ratchets down one connection at a time until it&#8217;s empty. Slow holders recover when the slow dependency recovers. If restarting the service &#8220;fixes&#8221; the incident every few days, suspect a leak.</p>



<p class="wp-block-paragraph">HikariCP has <code>leakDetectionThreshold</code>, which is off by default. Set it to a value longer than your slowest legitimate request, and it logs the stack trace of wherever a connection was borrowed and held past that threshold. That stack trace is usually the whole diagnosis. Other pools have their own hooks; if yours doesn&#8217;t, log borrow duration yourself around the checkout.</p>



<h3 class="wp-block-heading">Fixes that actually hold</h3>



<ol class="wp-block-list">
<li><strong>Stop holding connections across network calls.</strong> Do the external call first, then open the transaction, write, and commit. If you need the call&#8217;s result inside the transaction, restructure so the transaction is short. This is the real fix; everything else is a guardrail.</li>



<li><strong>Put a server-side ceiling on idle transactions.</strong> PostgreSQL&#8217;s <code>idle_in_transaction_session_timeout</code> terminates a session that sits idle inside a transaction past the limit. The app will see an error, and that&#8217;s the point: a loud failure beats a silent pool drain. You can set it in the parameter group or scope it to the application role.</li>



<li><strong>Fail fast on checkout.</strong> A web request that waits 30 seconds for a connection is worse than one that fails in two. Match the pool&#8217;s acquisition timeout to what the caller will tolerate.</li>
</ol>



<pre class="wp-block-code"><code>ALTER ROLE app_user SET idle_in_transaction_session_timeout = '60s';</code></pre>



<p class="wp-block-paragraph">The role-level setting applies to new sessions for that role, so existing pooled connections keep the old value until they&#8217;re recycled. Pick a limit comfortably above your longest legitimate pause, or you&#8217;ll kill batch jobs that were behaving.</p>



<p class="wp-block-paragraph">MySQL has no direct equivalent. <code>wait_timeout</code> closes idle sessions, but it&#8217;s a blunt setting that also affects pooled connections sitting legitimately idle, so tune it alongside your pool&#8217;s own idle and max-lifetime settings rather than instead of fixing the code.</p>



<p class="wp-block-paragraph">Watch the node-postgres default specifically. Its pool <code>connectionTimeoutMillis</code> defaults to 0, which means no timeout: when the pool is empty, requests wait forever. You don&#8217;t get an error to alert on; you get hung requests and upstream timeouts that point everywhere except the pool.</p>



<h2 class="wp-block-heading">Server slots run out while CPU idles</h2>



<p class="wp-block-paragraph">The second family is the one people expect: the database refuses connections. The surprise is that it happens at low CPU, and that the cause is arithmetic, not load.</p>



<h3 class="wp-block-heading">Pool multiplication</h3>



<p class="wp-block-paragraph">Every pool size is per process. The number the database actually faces is the product of everything that opens a pool:</p>



<pre class="wp-block-code"><code>worst case = pool size
           x worker processes per container
           x containers at the autoscaler's maximum
           x 2 during a rolling deploy (old and new running together)
           + cron jobs, migrations, BI tools, admin sessions</code></pre>



<p class="wp-block-paragraph">Take an illustrative case: a pool of 10, four worker processes per ECS task, 30 tasks at the scaling maximum. That&#8217;s 1,200 potential connections before a deploy briefly doubles it. Nobody chose that number. It emerged from three settings owned by three different people, and it only shows up on the day traffic and a deploy coincide.</p>



<p class="wp-block-paragraph">Use the autoscaler&#8217;s maximum, not today&#8217;s task count. Today&#8217;s count is what makes the math look safe.</p>



<h3 class="wp-block-heading">The limit moves when you resize</h3>



<p class="wp-block-paragraph">On RDS and Aurora, the default <code>max_connections</code> in the parameter group is a formula based on the instance class&#8217;s memory, not a fixed number. So a rightsizing exercise that drops the instance a size to save money also quietly lowers the connection ceiling. The CPU graph said it was safe. The connection budget didn&#8217;t get a vote.</p>



<p class="wp-block-paragraph">Don&#8217;t calculate the value by hand; the memory figure RDS uses excludes what its own processes consume. Run <code>SHOW max_connections</code> on the actual instance, and re-run it after every class change.</p>



<h3 class="wp-block-heading">Why raising max_connections is the wrong reflex</h3>



<p class="wp-block-paragraph">You can override <code>max_connections</code> in a custom parameter group, and sometimes a modest bump is fine. But it treats the symptom. Each PostgreSQL connection is a separate backend process with its own memory, and sorts or hashes inside it can each use up to <code>work_mem</code>. Thousands of connections all trying to work at once contend for the same CPU cores, and throughput often drops as concurrency rises past what the hardware can run in parallel.</p>



<p class="wp-block-paragraph">Also keep in mind that the last few slots are reserved for administrative roles. The application starts failing before the counter reaches the limit, and when it does, you may struggle to get your own session in to diagnose it. Keep headroom for humans.</p>



<h3 class="wp-block-heading">Budget connections on purpose</h3>



<ol class="wp-block-list">
<li>Read the real <code>max_connections</code> from the instance and subtract a margin for admin sessions, monitoring agents and migrations.</li>



<li>List every consumer: each service, its worker count, its scaling maximum, and its batch jobs.</li>



<li>Divide the budget and set each pool&#8217;s maximum from that, including the deploy overlap.</li>



<li>Cap the autoscaler maximum at something the database can serve. An autoscaler that can add tasks faster than the database can accept connections just converts a traffic spike into a connection storm.</li>



<li>If the budget can&#8217;t cover the fleet, add a pooler in front instead of more slots.</li>
</ol>



<p class="wp-block-paragraph">Smaller pools feel scary, but a connection is only useful while SQL is running. A small pool with short transactions usually beats a large pool with long ones.</p>



<h3 class="wp-block-heading">Zombie connections</h3>



<p class="wp-block-paragraph">Some slots belong to clients that are gone. A container killed without a clean shutdown, or a Lambda execution environment frozen between invocations, can leave server-side sessions open until something times them out. With Lambda, each concurrent environment holds its own connection, so cap concurrency and put a pooler in front; the <a href="https://john-nessime.com/aws/serverless-api/">serverless API</a> work I do starts exactly there.</p>



<p class="wp-block-paragraph">For long-lived services, set the pool&#8217;s idle timeout and max lifetime below any idle timeout on the network path, so connections are retired by the pool rather than cut silently in the middle.</p>



<h2 class="wp-block-heading">When the proxy becomes the queue</h2>



<p class="wp-block-paragraph">RDS Proxy is the managed answer to pool multiplication. Many client connections share a smaller set of database connections, and the proxy hands a database connection to a client per transaction. When that works, the server-side limit stops being your problem.</p>



<p class="wp-block-paragraph">It stops working in two ways. First, <strong>pinning</strong>: when a session changes state the proxy can&#8217;t safely share, such as setting a variable on PostgreSQL, the proxy ties that client to one database connection until the session ends. Enough pinned sessions and the proxy behaves like no proxy at all. Second, the <strong>borrow queue</strong>: when every database connection the proxy is allowed to open is busy, clients wait up to <code>ConnectionBorrowTimeout</code> (120 seconds by default) and then fail. Your application sees slow requests, not refused connections, and the database graphs look calm again.</p>



<p class="wp-block-paragraph">The proxy&#8217;s own CloudWatch metrics tell you which it is. <code>DatabaseConnectionsCurrentlySessionPinned</code> shows pinning. <code>DatabaseConnectionsCurrentlyBorrowed</code> against <code>DatabaseConnections</code> shows how close the proxy is to its share of the database. And <code>MaxConnectionsPercent</code> on the target group caps that share as a percentage of <code>max_connections</code>, so a conservative setting there creates the borrow queue by itself.</p>



<p class="wp-block-paragraph">The proxy is billed on the capacity of the database behind it (per vCPU-hour for provisioned instances, per ACU-hour for Aurora Serverless), so run your current rates through something like the <a href="https://john-nessime.com/tools/aws-cost-estimator/">AWS Cost Estimator</a> before assuming it&#8217;s free relief. PgBouncer in transaction mode on a small EC2 instance is the self-managed alternative: cheaper to run, but it&#8217;s one more thing to patch, monitor and make highly available.</p>



<h2 class="wp-block-heading">Metrics that warn you before connection pool exhaustion</h2>



<p class="wp-block-paragraph">Database CPU is the wrong alarm for this problem. By the time connection pressure shows up there, if it ever does, users already noticed. The signals that lead are on the client side:</p>



<ul class="wp-block-list">
<li><strong>Threads waiting for a connection.</strong> HikariCP exposes pending threads, active and idle counts, and acquisition time through its Micrometer integration. Sustained pending threads above zero is the earliest reliable warning you get.</li>



<li><strong>Connection acquisition time.</strong> Healthy pools hand out connections almost instantly. A rising acquisition time is the queue forming, long before timeouts start.</li>



<li><strong>Server connections as a ratio of the limit.</strong> Alarm on CloudWatch <code>DatabaseConnections</code> relative to the instance&#8217;s real <code>max_connections</code>, not an absolute number that goes stale after a resize.</li>



<li><strong>Oldest open transaction.</strong> A scheduled query against <code>pg_stat_activity</code> exporting the age of the oldest <code>xact_start</code> catches slow holders and leaks early.</li>
</ul>



<p class="wp-block-paragraph">Where these land matters less than having them next to each other. Grafana with a Prometheus scrape of the application&#8217;s pool metrics works well, as do Datadog or New Relic APM, which can show pool wait time inside the request trace. Sentry is useful for grouping the pool-timeout exceptions so you see the first one, not the thousandth. The goal is one screen where app pool state and database connection counts share a time axis. If you&#8217;d rather not build that, it&#8217;s the kind of thing covered under <a href="https://john-nessime.com/aws/monitoring-alerting/">AWS monitoring and alerting</a>.</p>



<h2 class="wp-block-heading">Troubleshooting by symptom</h2>



<ul class="wp-block-list">
<li><strong>Pool timeouts, database far below its limit, many idle-in-transaction sessions:</strong> slow holder or leak. Find the last statement in <code>pg_stat_activity</code> and trace the code path that follows it.</li>



<li><strong>Pool timeouts, few database sessions at all:</strong> the pool is too small for the worker count, or connections are borrowed and held without a transaction open. Check borrow duration, not the database.</li>



<li><strong>Refused connections only during deploys:</strong> the old and new versions overlap. Budget for the overlap or slow the rollout.</li>



<li><strong>Refused connections right after an instance class change:</strong> the formula-derived limit dropped. Check <code>SHOW max_connections</code>.</li>



<li><strong>Requests hang with no error at all:</strong> a pool with no acquisition timeout, such as node-postgres at its default. Set one.</li>



<li><strong>Everything was fixed by a restart, then came back days later:</strong> a leak. Turn on leak detection and wait for the stack trace.</li>



<li><strong>RDS Proxy in the path, slow requests, calm database:</strong> check pinned and borrowed connection metrics before anything else.</li>
</ul>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Scaling the instance up because &#8220;it&#8217;s the database&#8221;, when the database was idle the whole time.</li>



<li>Raising <code>max_connections</code> to absorb pool multiplication, then losing throughput to contention.</li>



<li>Making the pool bigger to fix pool timeouts caused by long transactions. It buys minutes and moves the failure to the server limit.</li>



<li>Sizing pools from today&#8217;s task count instead of the autoscaler&#8217;s maximum.</li>



<li>Adding RDS Proxy without checking pinning, and ending up with a paid queue instead of a pool.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Keep transactions short and never hold a connection across an external call.</li>



<li>Write the connection budget down, with every consumer, and revisit it when scaling limits or instance classes change.</li>



<li>Set an acquisition timeout that matches what callers will tolerate, and alert on pending threads, not on timeouts.</li>



<li>Use <code>idle_in_transaction_session_timeout</code> on PostgreSQL as a safety net, scoped to application roles.</li>
</ul>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">What causes connection pool exhaustion when database CPU is low?</h3>



<p class="wp-block-paragraph">Usually connections held while no SQL runs: long transactions, external API calls inside a transaction, or leaked connections that were never returned. The pool is empty, but the database is doing very little.</p>



<h3 class="wp-block-heading">Should I increase max_connections on RDS?</h3>



<p class="wp-block-paragraph">Only after confirming the server limit is actually the one being hit, and only modestly. If the cause is pool multiplication, budget the pools or add a pooler. More connections means more memory per backend and more contention, not more throughput.</p>



<h3 class="wp-block-heading">How big should my connection pool be?</h3>



<p class="wp-block-paragraph">Smaller than most people set it. Start from the connection budget divided across every process at maximum scale, then load test. A pool large enough to cover every thread usually just moves the queue from the application to the database.</p>



<h3 class="wp-block-heading">Does RDS Proxy fix pool exhaustion?</h3>



<p class="wp-block-paragraph">It fixes server-side slot exhaustion from many clients, as long as sessions aren&#8217;t pinned. It does nothing for an application pool drained by long transactions, because those transactions hold the proxy&#8217;s database connections just as firmly.</p>



<h3 class="wp-block-heading">How do I find leaked connections in PostgreSQL?</h3>



<p class="wp-block-paragraph">Look for sessions in <code>pg_stat_activity</code> that stay idle or idle in transaction for far longer than any request should, grouped by <code>application_name</code>. Then turn on your pool&#8217;s leak detection to get the stack trace of where the connection was borrowed.</p>



<h3 class="wp-block-heading">Does this happen on self-hosted databases too?</h3>



<p class="wp-block-paragraph">Exactly the same way. On PostgreSQL running on a VPS from providers like Hetzner, Contabo or InterServer, the pool math and idle transactions behave identically; the difference is that you run PgBouncer yourself instead of paying for RDS Proxy.</p>



<h2 class="wp-block-heading">Conclusion</h2>



<p class="wp-block-paragraph">Connection pool exhaustion on AWS almost never means the database is too small. It means a queue formed somewhere, and the fix depends entirely on where. Read the error literally, compare the database&#8217;s connection count against its real limit, and within minutes you&#8217;ll know if you&#8217;re dealing with a drained application pool, a server limit reached by arithmetic, or a proxy holding the line.</p>



<p class="wp-block-paragraph">If you remember one thing: a connection costs you for as long as it&#8217;s borrowed, not for as long as SQL runs. Shorten the borrow and most of these incidents stop happening.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help tracking down a connection problem on AWS?</h2>



<p class="wp-block-paragraph">This is work I do for teams running RDS, Aurora or self-managed PostgreSQL and MySQL. Typical engagements look like this:</p>



<ul class="wp-block-list">
<li>Working out which layer is exhausting during an incident, from pool logs, <code>pg_stat_activity</code> and CloudWatch.</li>



<li>Building a written connection budget across services, workers, autoscaling limits and deploy overlap.</li>



<li>Finding leaks and long-held connections in application code, with leak detection and transaction timeouts set sensibly.</li>



<li>Evaluating RDS Proxy or PgBouncer for your workload, including pinning and cost.</li>



<li>Dashboards and alerts that put application pool state and database connections on the same screen.</li>



<li>Checking what an instance resize will do to your connection limit before you make it.</li>
</ul>



<p class="wp-block-paragraph">Send me the pool timeout from your logs and the output of the <code>pg_stat_activity</code> query above, and I&#8217;ll tell you which layer ran out.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://john-nessime.com/projects/hosted-monitoring/">See hosted monitoring</a></div>



<div class="wp-block-button is-style-outline is-style-outline--1"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/connection-pool-exhaustion-aws/">Plenty of CPU, Zero Connections: Debugging Connection Pool Exhaustion on AWS</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Troubleshooting High I/O Wait Without Blaming the Disk</title>
		<link>https://john-nessime.com/blog/devops/troubleshooting-high-io-wait-linux/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 22 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[bpftrace]]></category>
		<category><![CDATA[cgroups]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Disk I/O]]></category>
		<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[iostat]]></category>
		<category><![CDATA[Kernel Tuning]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Performance Tuning]]></category>
		<category><![CDATA[Pressure Stall Information]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=248</guid>

					<description><![CDATA[<p>High I/O wait is a symptom, not a diagnosis. It rises when nothing is wrong and stays flat when something is badly wrong, and it only sees one layer of the storage stack. This is how to read iowait and PSI correctly, then work through the five failure families that actually produce storage stalls: device saturation, memory pressure, synchronous writes, throttling, and latency that lives above the block layer entirely.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/troubleshooting-high-io-wait-linux/">Troubleshooting High I/O Wait Without Blaming the Disk</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket says the app is slow. You SSH in, run <code>top</code>, and there it is: <code>wa</code> sitting at forty percent while user CPU barely moves. Obvious conclusion, the disk is dying. Somebody opens a ticket with the host, everyone waits, and the host replies that the volume looks healthy on their side.</p>



<p class="wp-block-paragraph">Sometimes the disk really is dying. More often it isn&#8217;t, and the number you&#8217;re staring at is not measuring what you think it&#8217;s measuring.</p>



<p class="wp-block-paragraph">This post covers troubleshooting high I/O wait the way I&#8217;d actually do it on a production box: what the metric means, why it lies in both directions, and how to work through the five failure families that produce it.</p>



<h2 class="wp-block-heading">What high I/O wait actually measures</h2>



<p class="wp-block-paragraph">Here&#8217;s the definition that matters: iowait is a <em>subcategory of idle time</em>. For a given CPU, it&#8217;s the time that CPU spent doing nothing while at least one task scheduled on it had an outstanding block I/O request.</p>



<p class="wp-block-paragraph">Two consequences fall out of that, and both will burn you. <strong>High iowait can mean nothing is wrong.</strong> If the box has spare CPU and one process is pulling a large file off disk, the CPU genuinely has nothing else to do. Copying a backup will push iowait toward 100 percent on an idle host, and that&#8217;s a working system, not a fault.</p>



<p class="wp-block-paragraph"><strong>Low iowait can hide a severe problem.</strong> This is the one that costs people days. If the CPU has other runnable work, the kernel context switches to it and that time gets counted as user or system, not iowait. A busy application server can be stalling badly on storage while <code>wa</code> reads two percent, because the CPU is never actually idle.</p>



<p class="wp-block-paragraph">Add the averaging problem on top. The <code>wa</code> figure in <code>top</code> is averaged across every core. One thread pinned to one core and blocked hard on disk shows up as roughly three percent on a 32-core machine. Press <code>1</code> in <code>top</code> to break out per-CPU lines before you conclude anything from the aggregate.</p>



<p class="wp-block-paragraph">So iowait is a hint, not a verdict. Treat it as the thing that starts the investigation, never the thing that ends it.</p>



<h2 class="wp-block-heading">Start with pressure, not percentages</h2>



<p class="wp-block-paragraph">Pressure Stall Information is the metric I reach for first, and it exists precisely because iowait is a poor proxy for pain. It answers the question you actually care about: how much time did tasks lose because they couldn&#8217;t get I/O?</p>



<pre class="wp-block-code"><code>cat /proc/pressure/io</code></pre>



<p class="wp-block-paragraph">You get two lines. <code>some</code> is the share of time at least one task was stalled waiting on I/O. <code>full</code> is the share of time <em>every</em> non-idle task was stalled at once, which is CPU capacity being burned for nothing. The <code>avg10</code>, <code>avg60</code> and <code>avg300</code> fields are percentages over the last 10, 60 and 300 seconds. <code>total</code> is a cumulative microsecond counter since boot, which is what you want for graphing because averages smooth away short spikes.</p>



<p class="wp-block-paragraph">The practical rule I use: a rising <code>full</code> line on <code>io</code> is a real user-visible problem almost every time. A high <code>some</code> line with a flat <code>full</code> line usually means one noisy job is waiting while everything else gets on with its life.</p>



<p class="wp-block-paragraph">If <code>/proc/pressure</code> doesn&#8217;t exist, the kernel was built without <code>CONFIG_PSI</code>, or it was built with PSI compiled in but disabled by default, in which case you need <code>psi=1</code> on the kernel command line. On cgroup v2 systems you also get per-cgroup pressure, which is how you narrow from &#8220;the host is stalling&#8221; to &#8220;this container is stalling&#8221; in one command:</p>



<pre class="wp-block-code"><code># Pressure for one systemd slice, useful on any modern distro
cat /sys/fs/cgroup/system.slice/io.pressure

# Pressure for every leaf cgroup that has it, sorted by nothing,
# just eyeball the full lines
find /sys/fs/cgroup -name io.pressure -exec sh -c 
  'printf "%s: " "$1"; sed -n 2p "$1"' _ {} ;</code></pre>



<p class="wp-block-paragraph">If you run Prometheus, node_exporter has exposed these since it gained a pressure collector. The counters are <code>node_pressure_io_waiting_seconds_total</code> for <code>some</code> and <code>node_pressure_io_stalled_seconds_total</code> for <code>full</code>. Rate those and you have a far better storage alert than any threshold on <code>%iowait</code> will ever be. Grafana Cloud and Netdata both chart PSI out of the box if you&#8217;d rather not build the panels yourself.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure family one: the device really is saturated</h2>



<p class="wp-block-paragraph">This is the case everyone assumes, so get it confirmed or eliminated quickly.</p>



<pre class="wp-block-code"><code>iostat -xy 1</code></pre>



<p class="wp-block-paragraph">The <code>-x</code> gives extended per-device stats. The <code>-y</code> matters more than people realise: without it, the first report is a since-boot average, and reading that first block is the single most common iostat mistake there is. It will tell you the disk has been fine all year, which is true and useless.</p>



<p class="wp-block-paragraph">Now the columns worth your attention:</p>



<ul class="wp-block-list">
<li><strong>r_await and w_await</strong>: average milliseconds a read or write took, queue time included. This is the closest thing to &#8220;how slow does storage feel&#8221;. Split reads from writes, because they fail very differently.</li>
<li><strong>aqu-sz</strong>: average queue depth. Requests either queued or in service. A depth that climbs while throughput stays flat is the signature of a genuinely saturated device.</li>
<li><strong>r/s, w/s, rkB/s, wkB/s</strong>: the actual workload. Compare against what the device or volume class is rated for.</li>
<li><strong>%util</strong>: treat with suspicion, see below.</li>
</ul>



<p class="wp-block-paragraph"><strong>The %util trap.</strong> That column counts the fraction of time at least one request was in flight. On a spinning disk that serviced one request at a time, it was a fair saturation signal. On NVMe and on cloud volumes that handle many requests in parallel, a device can sit at 100 percent while running at a fraction of its real capacity. Provisioning a bigger volume off the back of a 99 percent <code>%util</code> reading is a common and expensive way to change nothing. Use <code>aqu-sz</code> and the await columns instead. Similarly, <code>svctm</code> was deprecated and dropped from newer sysstat output because it was unreliable on parallel devices; ignore it if an old build still prints it.</p>



<p class="wp-block-paragraph">Once you know the device is busy, find the culprit. <code>pidstat -d 1</code> gives per-process read and write rates from the same sysstat package and needs nothing special enabled. <code>iotop -o</code> is friendlier, but on kernels from 5.14 onward per-task delay accounting is off by default, so the <code>SWAPIN</code> and <code>IO&gt;</code> columns come back empty until you turn it on:</p>



<pre class="wp-block-code"><code>sudo sysctl kernel.task_delayacct=1
sudo iotop -oPa
# turn it back off when you're done, it costs a little scheduler overhead
sudo sysctl kernel.task_delayacct=0</code></pre>



<p class="wp-block-paragraph">And check the boring things before the clever ones. <code>dmesg -T | grep -iE 'i/o error|reset|medium'</code> catches a failing drive or a flapping controller. <code>smartctl -a /dev/sda</code> catches reallocated sectors on hardware you own. If either of those turns something up, stop reading and replace the disk.</p>



<h2 class="wp-block-heading">Failure family two: memory pressure in an I/O costume</h2>



<p class="wp-block-paragraph">This is the failure family I&#8217;d check second, and it&#8217;s invisible if you only look at storage.</p>



<p class="wp-block-paragraph">When RAM gets tight, the kernel starts evicting page cache and swapping. Both generate disk traffic. The disk gets busy, iowait climbs, and every graph points at storage while the actual constraint is memory. Give the box more RAM and the &#8220;disk problem&#8221; disappears.</p>



<pre class="wp-block-code"><code># si and so are swap-in and swap-out in KB/s.
# Sustained non-zero values are your answer.
vmstat 1

# Are we thrashing writeback? Dirty pages waiting to be written.
grep -E 'Dirty|Writeback' /proc/meminfo

# The direct question
cat /proc/pressure/memory</code></pre>



<p class="wp-block-paragraph">If memory pressure and I/O pressure rise together, fix memory first. Everything else is downstream.</p>



<p class="wp-block-paragraph">There&#8217;s a related trap on the write side. The kernel buffers writes in page cache and flushes them in the background, governed by <code>vm.dirty_background_ratio</code> and <code>vm.dirty_ratio</code>. Cross the background threshold and flusher threads start writing. Cross the hard <code>vm.dirty_ratio</code> threshold and writing processes are forced to do writeback themselves, synchronously. That transition is brutal, and on a box with plenty of RAM and a modest disk the defaults let you accumulate gigabytes of dirty pages and then pay for all of them at once. Lowering the ratios makes writeback more frequent and less spiky, trading away a little write batching. Measure before you commit to it.</p>



<h2 class="wp-block-heading">Failure family three: small synchronous writes</h2>



<p class="wp-block-paragraph">Throughput can be trivial and the system still crawls. A workload doing thousands of tiny <code>fsync</code> calls a second is bound by round-trip latency, not bandwidth, and no amount of extra megabytes per second will help.</p>



<p class="wp-block-paragraph">Databases are the usual source, because durability has a cost and that cost is a flush. MySQL and MariaDB expose it through <code>innodb_flush_log_at_trx_commit</code>; PostgreSQL through <code>synchronous_commit</code>. Loosening either buys real throughput and trades away durability on power loss or crash. That can be a perfectly reasonable trade for a cache or an analytics replica, and a terrible one for anything holding orders or payments. Decide it explicitly, write down what you decided, and don&#8217;t let it be a thing somebody changed once at 2am.</p>



<p class="wp-block-paragraph">The other common source is logging. Application logs, access logs, container JSON logs and a database sharing one volume will starve each other. Splitting hot write paths onto separate devices is unglamorous and works.</p>



<h2 class="wp-block-heading">Failure family four: throttled, not saturated</h2>



<p class="wp-block-paragraph">A throttled device and a saturated device look almost identical from inside the guest: high await, growing queue, miserable latency. The difference is that no amount of tuning inside the box will fix a throttle. Three flavours are worth knowing.</p>



<ul class="wp-block-list">
<li><strong>Cloud volume limits.</strong> Managed block storage gives you a baseline of IOPS and throughput, sometimes with a burst allowance on top. Burn the burst and you drop to baseline, and everything that was fine in testing falls over in production. The tell is a latency cliff that arrives at a consistent point into a workload rather than tracking load smoothly. On AWS, watch the volume-level CloudWatch metrics such as <code>BurstBalance</code> on burstable volume types and <code>VolumeQueueLength</code>; other providers publish equivalents. The fix is a volume class change, not a sysctl.</li>
<li><strong>cgroup limits you set yourself.</strong> On cgroup v2, <code>io.max</code> caps a cgroup at a given bytes-per-second or IOPS figure, and <code>io.weight</code> shares bandwidth proportionally under contention. A limit that made sense when it was written can become the bottleneck a year later, and nothing in the container&#8217;s own metrics announces it.</li>
<li><strong>Neighbours on shared hardware.</strong> On oversubscribed VPS plans your storage is shared, and someone else&#8217;s backup job is your latency spike. This is where cheap and expensive hosting genuinely differ. Budget providers like Contabo and InterServer are fine value for a lot of workloads, but if storage latency is load-bearing for you, look for plans with dedicated NVMe rather than shared pools, and test before you migrate anything that matters.</li>
</ul>



<pre class="wp-block-code"><code># Per-device I/O accounting for a cgroup, including any throttling
cat /sys/fs/cgroup/system.slice/docker-&lt;id&gt;.scope/io.stat

# Is a cap set? An empty file means no limit on that cgroup.
cat /sys/fs/cgroup/system.slice/docker-&lt;id&gt;.scope/io.max</code></pre>



<p class="wp-block-paragraph">One more signal while you&#8217;re here. In <code>top</code>, the <code>st</code> column is steal time, meaning the hypervisor gave your vCPU&#8217;s slice to someone else. Steal and iowait rising together on a shared host is a strong hint that the problem is the neighbourhood, not your configuration.</p>



<h2 class="wp-block-heading">Failure family five: the latency isn&#8217;t in the block layer</h2>



<p class="wp-block-paragraph">Here&#8217;s the failure mode that wastes the most time, because every storage metric you check will look healthy.</p>



<p class="wp-block-paragraph"><code>iostat</code> measures the block layer. It cannot see anything above it. So if your latency is in NFS, in a network filesystem, in an overlay or FUSE mount, in filesystem lock contention, or in a journal that&#8217;s serialising writes, the device will report sub-millisecond service times while your application waits tens of milliseconds per operation.</p>



<p class="wp-block-paragraph">The diagnosis is the gap. Measure latency at the application&#8217;s layer and at the block layer, and if the two numbers disagree wildly, the problem lives in between.</p>



<pre class="wp-block-code"><code># Block layer latency as a histogram. Look for a bimodal shape
# or a long tail rather than the average.
sudo biolatency-bpfcc -m 10 1      # Debian/Ubuntu naming
sudo /usr/share/bcc/tools/biolatency -m 10 1   # RHEL family

# Filesystem operations slower than 10ms, with the process and file
sudo ext4slower-bpfcc 10
sudo xfsslower-bpfcc 10

# Everything at the VFS layer, higher overhead, use briefly
sudo fileslower-bpfcc 10</code></pre>



<p class="wp-block-paragraph">If <code>ext4slower</code> is full of hundred-millisecond operations and <code>biolatency</code> shows everything completing in microseconds, you have your answer and it is not the disk. For NFS specifically, <code>nfsiostat</code> splits out RTT from execution time, which separates network cost from server cost. Install the tooling with <code>bpfcc-tools</code> on Debian and Ubuntu, or <code>bcc-tools</code> on RHEL, AlmaLinux and Rocky.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">A working order for troubleshooting high I/O wait</h2>



<p class="wp-block-paragraph">On an unfamiliar box, this is the sequence I work through. Each step either finds the problem or eliminates a family.</p>



<ol class="wp-block-list">
<li>Read <code>/proc/pressure/io</code>. If <code>full</code> is near zero, the stall is not hurting the whole system and you should widen the search before you tune storage.</li>
<li>Check <code>/proc/pressure/memory</code> at the same time. If it&#8217;s rising too, fix memory first.</li>
<li>Run <code>iostat -xy 1</code> for thirty seconds. Note <code>r_await</code>, <code>w_await</code> and <code>aqu-sz</code> per device. Ignore <code>%util</code>.</li>
<li>List processes stuck in uninterruptible sleep: <code>ps -eo state,pid,comm | awk '$1 ~ /^D/'</code>. That&#8217;s your blocked set, and it names the workload.</li>
<li>Attribute the traffic with <code>pidstat -d 1</code>. Confirm the process you suspect is the process actually doing the I/O.</li>
<li>If await is high, decide saturated versus throttled. Check cgroup <code>io.max</code>, check provider volume metrics, check steal time.</li>
<li>If await is low but the application is slow, jump to the layer gap. Run <code>biolatency</code> and <code>ext4slower</code> together and compare.</li>
<li>Only now consider tuning: dirty ratios, I/O scheduler, flush settings, splitting volumes.</li>
</ol>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li><strong>Reading iostat&#8217;s first report.</strong> It&#8217;s a since-boot average. Use <code>-y</code>, or discard the first block.</li>
<li><strong>Trusting %util on NVMe or cloud volumes.</strong> Parallel devices break the assumption it was built on.</li>
<li><strong>Alerting on a fixed %iowait threshold.</strong> It fires on healthy backups and stays silent during real stalls on busy hosts.</li>
<li><strong>Changing the I/O scheduler first.</strong> Going from <code>mq-deadline</code> to <code>none</code> on NVMe is sometimes right, but it&#8217;s a tuning step, not a diagnosis, and it fixes nothing if you&#8217;re throttled.</li>
<li><strong>Adding swap to fix swapping.</strong> More swap on a memory-starved box means more disk traffic, not less.</li>
<li><strong>Stopping at the block layer.</strong> If every storage metric looks clean and the app is still slow, you&#8217;re measuring at the wrong layer, not measuring a healthy system.</li>
<li><strong>Testing with the wrong benchmark.</strong> A sequential <code>dd</code> tells you nothing about a workload doing random 4K synchronous writes. Use <code>fio</code> and match the real access pattern.</li>
</ul>



<h2 class="wp-block-heading">Best practices worth the effort</h2>



<ul class="wp-block-list">
<li><strong>Record PSI continuously.</strong> Storage incidents are short and bursty. Without history you&#8217;re guessing about something that already ended.</li>
<li><strong>Capture a baseline while things are healthy.</strong> Save an <code>iostat -xy</code> sample and a PSI reading from a normal day. &#8220;Is 8ms bad?&#8221; is unanswerable without one.</li>
<li><strong>Keep sar running.</strong> The sysstat package can archive metrics on a schedule, which means <code>sar -d</code> can show you what happened at 3am. Enable it before you need it.</li>
<li><strong>Separate hot write paths.</strong> Database data, database logs and application logs on separate volumes stops one from starving the others.</li>
<li><strong>Cap container logs.</strong> An uncapped JSON log driver quietly filling the root volume is a recurring cause of host-wide stalls.</li>
<li><strong>Alert on latency and pressure, not utilisation.</strong> Users feel <code>w_await</code> and PSI <code>full</code>. Nobody has ever felt a percentage of device busy time.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">What is a normal or acceptable I/O wait percentage?</h3>



<p class="wp-block-paragraph">There isn&#8217;t one, and any article that gives you a single number is guessing. Iowait scales with how idle your CPUs are, so the same workload produces very different figures on a busy server and a quiet one. Judge against your own baseline, and prefer PSI, which measures lost time directly rather than as a share of idle.</p>



<h3 class="wp-block-heading">Can high I/O wait be caused by something other than the disk?</h3>



<p class="wp-block-paragraph">Regularly. Memory pressure driving swap and page cache eviction is the most common non-disk cause. Network filesystems, hypervisor contention on shared hosts, and cgroup or cloud volume throttling all produce the same symptom. The block device being genuinely slow is one possibility among several.</p>



<h3 class="wp-block-heading">Why does iostat show 100% utilisation when the disk isn&#8217;t busy?</h3>



<p class="wp-block-paragraph">Because <code>%util</code> measures the fraction of time at least one request was in flight, which made sense for devices that handled one request at a time. NVMe drives and cloud volumes process many requests in parallel, so they can be continuously occupied while nowhere near capacity. Read <code>aqu-sz</code> and the await columns instead.</p>



<h3 class="wp-block-heading">How do I find which process is causing I/O wait?</h3>



<p class="wp-block-paragraph">Start with <code>pidstat -d 1</code>, which needs no special kernel settings. List processes in uninterruptible sleep with <code>ps -eo state,pid,comm</code> and filter for state D to see who is actually blocked. <code>iotop -o</code> gives a live view but needs <code>kernel.task_delayacct=1</code> on recent kernels for its percentage columns to populate.</p>



<h3 class="wp-block-heading">Does adding more RAM reduce I/O wait?</h3>



<p class="wp-block-paragraph">Often, yes, and it&#8217;s underrated. More RAM means a larger page cache, fewer reads reaching the device, and less swapping. If <code>vmstat</code> shows sustained swap activity or memory pressure tracks I/O pressure, RAM is likely the cheapest fix available.</p>



<h3 class="wp-block-heading">Should I change the I/O scheduler to fix high I/O wait?</h3>



<p class="wp-block-paragraph">Only after you know the device is the bottleneck. Check the current setting with <code>cat /sys/block/sda/queue/scheduler</code>. On NVMe, <code>none</code> is a common and sensible default because the hardware already does the queuing. On spinning disks, <code>mq-deadline</code> or <code>bfq</code> give better latency behaviour. It&#8217;s a tuning knob, not a diagnostic, and it will not help if you&#8217;re being throttled.</p>



<h3 class="wp-block-heading">Why is my application slow when iostat says storage is fine?</h3>



<p class="wp-block-paragraph">Because <code>iostat</code> only sees the block layer. Latency in NFS, FUSE, overlay filesystems, journal serialisation or filesystem locking is entirely invisible to it. Run <code>ext4slower</code> or <code>xfsslower</code> alongside <code>biolatency</code>: if the filesystem is slow and the device is fast, the gap between them is your problem.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">High I/O wait is a symptom that points in a general direction, not a diagnosis. It&#8217;s a slice of idle time, averaged across cores, measured at one specific layer of the stack. It goes up when nothing is wrong and stays flat when something is very wrong.</p>



<p class="wp-block-paragraph">Read PSI to find out whether anyone is actually suffering. Read <code>r_await</code>, <code>w_await</code> and <code>aqu-sz</code> to find out whether the device is the constraint. Compare filesystem latency against block latency to find out whether you&#8217;re even looking at the right layer. Do those three things and the answer usually falls out in a few minutes, instead of after a week of arguing with your hosting provider about a volume that was fine all along.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need someone to look at your storage stalls?</h2>



<p class="wp-block-paragraph">I work with teams on exactly this kind of problem, usually the ones that have already survived a round of &#8220;the host says the disk is fine&#8221;. Things I can help with:</p>



<ul class="wp-block-list">
<li>Diagnosing a live or recurring I/O stall and identifying which failure family it belongs to</li>
<li>Setting up PSI, sar and per-cgroup I/O metrics in Prometheus and Grafana, with alerts that fire on latency rather than utilisation</li>
<li>Separating cloud volume throttling from genuine device saturation, and sizing storage correctly instead of guessing upward</li>
<li>Tuning writeback, dirty ratios and I/O schedulers for a specific workload, with before-and-after measurements</li>
<li>Tracing container and Kubernetes I/O contention through cgroup v2 limits, overlay filesystems and log drivers</li>
<li>Benchmarking with <code>fio</code> against your real access pattern so capacity decisions rest on something other than a <code>dd</code> run</li>
</ul>



<p class="wp-block-paragraph">If you&#8217;ve got an <code>iostat</code> capture, a PSI reading or just a graph that doesn&#8217;t make sense, send it over and I&#8217;ll tell you what I see in it.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/troubleshooting-high-io-wait-linux/">Troubleshooting High I/O Wait Without Blaming the Disk</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
