Docker Builds: I Shipped an Optimization and Nothing Happened

2026/02/26

BUILDdebuggingdocker

I merged the two-phase uv sync optimization on Monday. Built a dedicated A/B test workflow that showed 86% faster rebuilds. Wrote a blog post about the design. Felt great. PR #21006, merged to main, deployed to production.

Then I opened the Datadog CI dashboard and saw… nothing. Same build durations. No drop. No inflection point. The line was flat.

The instinct that fires in that moment is a very specific flavor of dread: I shipped a no-op.

The dashboard doesn’t lie (but it doesn’t tell the whole truth either)

Before panicking, I forced myself to do the thing that distinguishes engineering from anxiety: write down hypotheses, rank them, eliminate them.

I came up with eight. Here are the three that mattered.

Hypothesis 1: Too early

The PR merged today. How many builds have run since then?

I checked. A handful — all on commits that didn’t touch uv.lock.

Remember the three scenarios:

Every post-merge build was Scenario A. No dependency changes happened since the merge. The optimization hadn’t been exercised. It’s like installing a fire sprinkler system and then checking whether it works by… not having a fire.

No Scenario B events = no speedup = correct behavior.

Hypothesis 2: Wrong metric granularity

Even if a Scenario B build had run, would the dashboard show it?

Datadog was tracking job-level duration. But the optimization lives inside one step of one job: the docker buildx bake command. A single CI job includes:

And the overall workflow includes multiple jobs — rocket builds (not optimized), enroot conversion, multiarch manifest merge. The uv sync speedup is buried three levels deep in a Russian nesting doll of CI steps.

Staring at job-level duration to find a step-level optimization is like checking your electricity bill to see if you replaced a lightbulb.

Hypothesis 3: Registry cache overhead

This one was more interesting. I found one post-merge build where uv.lock actually changed — commit 59c54cf (“Add gateway process & runtime metrics”). A real Scenario B event in the wild. Downloaded the raw BuildKit logs from GitHub Actions. Parsed them. And found something I didn’t expect.

The optimization was working perfectly. Phase 2 uv sync completed in 0.3 seconds. The stable lockfile stayed cached, the delta was installed in under a second. Exactly as designed.

But total wall time was higher post-merge than pre-merge. 13 minutes vs 10 minutes.

What? How?

Registry cache import overhead. BuildKit pulls compressed layers from ACR to verify they’re cached — even on cache hits. With a 66GB image, that’s significant I/O. The optimization saved time on the uv sync step but couldn’t offset the cost of Docker checking “do I already have this enormous layer?”

The optimization works. It’s just one variable in a system with many variables. And right now, the other variables are louder.

Reading BuildKit logs like tea leaves

GitHub Actions logs for docker buildx bake are a wall of interleaved text from BuildKit’s parallel execution engine. Multiple build targets running simultaneously, each with their own step numbering, all dumped into one stream. It looks like this:

#19 [pretraining internal] load build context
#19 transferring context: 19.33MB 0.2s done
#20 [pretraining stage-0 5/14] COPY pyproject-stable.toml ...
#20 CACHED
#21 [pretraining stage-0 6/14] COPY uv-stable.lock ...
#21 CACHED
#22 [pretraining stage-0 7/14] RUN --mount=type=cache ...
#22 CACHED
#23 [pretraining stage-0 8/14] COPY pyproject.toml ...
#23 CACHED* 9m 16.4s
#24 [pretraining stage-0 9/14] COPY uv.lock ...
#24 DONE 3.4s
#25 [pretraining stage-0 10/14] RUN --mount=type=cache ...
#25 DONE 0.3s

I stared at this format long enough that I started seeing patterns. Then I built a tool to see them for me.

The tool: parse-buildx-log.py

A single-file Python script that turns BuildKit log noise into a readable table. Stdlib only — no dependencies, because a diagnostic tool that requires pip install has already failed.

Single-file mode parses a downloaded log file:

Step   Target        Phase  Status    Duration  Role
5/14   pretraining   P1     CACHED    -         COPY pyproject-stable.toml
6/14   pretraining   P1     CACHED    -         ★ COPY uv-stable.lock
7/14   pretraining   P1     CACHED    -         ★ uv sync (Phase 1)
8/14   pretraining   P2     CACHED*   9m 16s    COPY pyproject.toml
9/14   pretraining   P2     BUILT     3.4s      ★ COPY uv.lock
10/14  pretraining   P2     BUILT     0.3s      ★ uv sync (Phase 2)
11/14  pretraining   post   BUILT     2m 38s    ★ CUDA compile

Compare mode shows three builds side-by-side — pre-merge baseline, Scenario B, Scenario A — with status and duration diffs.

Fetch mode pulls logs directly from the GitHub Actions API: python3 parse-buildx-log.py --fetch JOB_ID. Because the hardest part of build investigation is always getting the logs in the first place.

Some design decisions worth noting:

The actual evidence

Three builds, parsed and compared. Here are the actual runs — you can click through and check the logs yourself:

Build 1 — Pre-merge baseline (1-phase, 04:36 UTC):

Build 2 — Post-merge, Scenario B (2-phase, 08:15 UTC, includes 59c54cf which changed uv.lock):

Build 3 — Post-merge, Scenario A (2-phase, 12:15 UTC, nothing changed):

The 0.3-second Phase 2 is the proof. When uv.lock changes post-optimization, instead of a 6+ minute full reinstall, it installs only the delta in under a second. The mechanism works exactly as designed.

The total wall time being higher is a separate problem — registry cache I/O overhead on a 66GB image — and has nothing to do with the optimization’s effectiveness.

Three interpretations of “nothing happened”

This experience crystallized something I want to write on a sticky note and put on my monitor:

“No visible change” has at least three interpretations:

  1. It’s broken. The feared one. The code doesn’t work.
  2. It works but hasn’t been exercised. The code works perfectly — it just hasn’t encountered the conditions where it matters. This was our case.
  3. You’re measuring the wrong thing. The code works AND has been exercised, but your metrics are at the wrong granularity to see it. Also partially our case — job-level duration vs step-level duration.

The instinct is to jump to interpretation #1. It’s the one that triggers the most adrenaline. But it’s not the most likely. Especially when you have A/B test data showing 86% improvement. At some point you have to trust your controlled experiment over your dashboard squinting.

What I told the team

I drafted a Slack message to the DevEx squad explaining:

The key framing: “the optimization is deployed but not yet activated.” It’s waiting for its first real-world dependency bump to show value at scale. The A/B test data is solid. The one organic Scenario B confirms the mechanism. We just need more data points — and they’ll come naturally, because someone bumps something in a monorepo almost every day.

What I still don’t know

The meta-lesson

The two-phase optimization took a day to write and a week to prove. Now I’ve spent another day proving that the absence of proof on a dashboard isn’t evidence of absence.

Building the diagnostic tooling — parse-buildx-log.py — before panicking turned a “did it work?” question into a “here’s exactly what happened at each layer” answer. The log parser is now a reusable tool for any future build investigation. Next time someone asks “why was this build slow?”, the answer is a command, not an archaeology expedition.

If I’ve learned anything from this Docker optimization saga, it’s this: ship the fix, ship the proof, and ship the tool that lets you re-prove it six months from now. The fix is six lines of Dockerfile. The proof is a test workflow. The tool is a log parser. Together, they form a complete argument — not just “it works” but “here’s how to check.”

The dashboard will catch up. Dep bumps happen every day in a monorepo. The first weekly uv-stable.lock refresh hasn’t even fired yet. By next week, there’ll be enough Scenario B events to draw a trendline.

And if the trendline is flat, I’ll have the tools to figure out why.