Last week I spent an afternoon debugging a CI failure in a Docker build that installs Python packages with uv. The build was unexpectedly recompiling CUDA kernels in a step where it shouldn’t have been. Finding the problem required actually reading the logs — not skimming them, but understanding what each line meant.
This post is what I wish I’d had before that debugging session. It covers how to read Docker BuildKit output, what uv’s symbols mean, and how to spot problems like unnecessary recompilation hiding in plain sight.
Docker Build Log Structure
Modern Docker builds use BuildKit, and the log format looks like this:
#51 [pretraining stage-0 8/14] RUN --mount=type=bind,source=...,target=...,rw --mount=type=cache,target=... uv sync --package mai_kernels --frozen
There’s a lot packed into that line. Let’s break it down:
Step numbering: #51 [pretraining stage-0 8/14] tells you:
#51— the global step number across all stagespretraining— the build stage name (from yourFROM ... AS pretraining)stage-0— BuildKit’s internal stage index8/14— step 8 of 14 within this stage
Completion time: At the end of each step, you’ll see:
#51 DONE 335.2s
This is wall-clock time for that step. When you’re comparing two steps that do similar work, time differences are your first clue that something is different.
Bind mounts: The --mount=type=bind,source=...,target=...,rw part means Docker is mounting a directory from the build context into the container at build time. The rw flag means read-write. This is how source code gets into the container without a COPY step — it’s available during the RUN command but doesn’t persist into the image layer unless something writes to a non-mounted path.
Cache mounts: --mount=type=cache,target=/root/.cache/uv keeps uv’s download cache across builds. This means downloaded wheels survive between builds, but the cache itself isn’t part of the final image. If you see uv downloading packages that should be cached, check whether your cache mount target matches what uv actually uses.
Docker Image Export & Push Phases
After your build finishes, Docker has to package and upload the image. This part of the log often takes longer than the build itself and looks confusing if you don’t know what’s happening.
Exporting layers
#64 exporting layers
#64 exporting layers 42.1s done
Docker is compressing the filesystem diffs from each build step into tarballs. This is proportional to the total image size — a 15GB image with CUDA libraries, Python packages, and model weights takes a while. There’s nothing to tune here; it’s just I/O.
Pushing layers
#64 pushing layers
#64 pushing layers 95.2s done
This is the network transfer — uploading layer blobs to your container registry. The time depends on image size and network bandwidth. But here’s the key insight: container registries deduplicate layers by content hash. If a layer already exists in the registry, it’s not uploaded again.
This means:
- First push of a new image: All layers upload. Slow.
- Subsequent pushes with the same base: Only changed layers upload. Fast.
- Pushing the same image with a different tag: Only the manifest (a tiny JSON pointer) uploads.
Pushing manifests
#64 pushing manifest for myregistry.azurecr.io/myimage:tag1@sha256:abc123
#64 pushing manifest for myregistry.azurecr.io/myimage:tag1@sha256:abc123 2.1s done
A manifest is just metadata — it says “this image tag points to these layers.” If the layers are already in the registry, pushing a manifest takes 2–4 seconds regardless of image size.
When you see your build pushing the same image to multiple tags:
#64 pushing manifest for myregistry.azurecr.io/myimage:tag1 2.1s done
#65 pushing manifest for myregistry.azurecr.io/myimage:tag2 1.8s done
#66 pushing manifest for myregistry.azurecr.io/myimage:tag3 2.3s done
That’s expected. Each push is just a new manifest referencing the same layers.
Auth lines
#64 [auth] myimage:pull,push token=bearer
These are token refreshes for registry authentication. They appear throughout push operations and are almost always harmless — Docker is just keeping its auth token alive. If pushes fail, check these for errors, but otherwise ignore them.
uv sync Output Symbols
This is the most immediately useful section. uv uses single-character symbols to tell you exactly what it did with each package, but the documentation is sparse. Here’s the complete reference:
| Symbol | Meaning | What happened |
|---|---|---|
+ |
Installed (new) | Package wasn’t in the venv, now it is |
~ |
Reinstalled | Package was already there, replaced with same or different version |
- |
Uninstalled | Package removed from the venv |
And the build/preparation lines:
| Line | Meaning |
|---|---|
Building X @ file:///path |
Running the package’s build system (setup.py, CMakeLists.txt, etc.) |
Built X @ file:///path |
Build finished, wheel produced |
Prepared N packages in Xs |
All wheels are ready (downloaded or built) |
Installed N packages in Xms |
Wheels copied into the virtual environment |
What these symbols tell you in practice
The difference between + and ~ is subtle but important for debugging:
+ mai-kernels==0.2.0 # First time installing -- built from scratch
~ mai-kernels==0.2.0 # Reinstalling -- may or may not rebuild
When you see ~, the package was already present. uv is replacing it, possibly because the source changed or because a different sync step is reconciling the environment. The package might be rebuilt from source, or it might be reinstalled from a cached wheel. You need to check the build output above it to know which.
The preparation vs installation distinction also matters:
Prepared 61 packages in 17.95s
Installed 61 packages in 17ms
Preparation (17.95s) is the expensive part — downloading wheels, building packages from source. Installation (17ms) is just copying files into the venv. If “Prepared” is slow, look at what’s being built. If “Installed” is slow, you might have a filesystem issue.
Real Example: Spotting an Unnecessary CUDA Recompilation
Here’s the debugging scenario that prompted this post. We had a Docker build with two uv sync steps:
Step 8/14: Install mai_kernels (a package with CUDA kernel code):
#51 [pretraining stage-0 8/14] RUN ... uv sync --package mai_kernels --frozen
Step 12/14: Install mai_job (depends on mai_kernels):
#55 [pretraining stage-0 12/14] RUN ... DISABLE_BUILD_EXT=1 uv sync --package mai_job --frozen
The second step had DISABLE_BUILD_EXT=1, which should tell mai_kernels to skip its C extension / CUDA compilation. But was it actually working?
How to tell: the four signals
1. Build time. Step 8 took 335s. Step 12 took ~4s for the mai_kernels portion. A 100x speedup means compilation was skipped. If it took 300+s again, CUDA code was compiling again.
2. Compiler output. CUDA compilation is loud. You’ll see lines like:
[1/4] /usr/local/cuda/bin/nvcc -I/usr/include ...
[2/4] /usr/local/cuda/bin/nvcc --generate-dependencies-with-compile ...
-- Build files have been written to: /tmp/build/cmake_build
CMake Warning at ...
If step 12 has no nvcc, no cmake, no CMake — compilation was skipped.
3. The symbol. In step 8:
+ mai-kernels==0.2.0 # fresh install
In step 12:
~ mai-kernels==0.2.0 # reinstall
The ~ tells you the package was already present from step 8 and is being reinstalled. This is expected — uv sync --package mai_job sees mai_kernels as a dependency and reconciles it.
4. No compiler warnings or linking messages. A real CUDA build produces pages of compiler output — warnings about deprecated GPU architectures, linking messages, cmake configuration. A skip produces a clean Building / Built pair with nothing in between.
Putting it together
In our case, step 12 showed:
- 4s build time (not 335s)
- No nvcc/cmake output
~symbol (reinstall, not fresh)- Clean build with no compiler chatter
The DISABLE_BUILD_EXT=1 flag was working correctly. The “build” in step 12 was just running the Python build system to produce a pure-Python wheel, without invoking any C/CUDA compilation.
Common uv Error Patterns
Distribution not found
error: Distribution not found at: file:///app/yolo/mai_kernels
This means uv expected a package at that filesystem path but nothing was there. In Docker builds, this usually means your bind mount paths are wrong — the source code isn’t being mounted where the pyproject.toml workspace configuration says it should be.
Check:
- Your
--mount=type=bind,source=...,target=...paths in the Dockerfile - Your workspace
memberspaths inpyproject.toml - Whether the directory actually exists in the build context
Missing build isolation dependencies
ModuleNotFoundError: No module named 'editables'
This happens when you use --no-build-isolation without pre-installing the build dependencies. Normally, uv creates an isolated virtual environment for each package build with all required build tools. --no-build-isolation skips this — it’s faster, but it means build dependencies (like editables, setuptools, wheel) must already be in the environment.
The fix is either:
- Remove
--no-build-isolation(slower but reliable) - Pre-install build dependencies:
uv pip install editables setuptools wheel
Understanding –no-build-isolation
| With isolation (default) | Without isolation (--no-build-isolation) |
|---|---|
| Creates temp venv for each build | Uses current environment |
| Installs build deps automatically | Requires build deps pre-installed |
| Slower (repeated setup) | Faster (skips setup) |
| Always works | Fails if build deps missing |
Understanding –frozen
The --frozen flag tells uv: “Use the lockfile exactly as it is. Don’t resolve anything, don’t update anything.” This is what you want in CI/Docker builds where reproducibility matters. Without it, uv might try to resolve dependencies and produce a different lockfile, which then fails because the lockfile is read-only.
Key uv sync Flags Reference
Here are the flags you’ll see most often in Docker builds and CI:
| Flag | What it does | When to use it |
|---|---|---|
--package X |
Only sync package X and its dependencies | When you don’t want to install the entire workspace |
--frozen |
Use lockfile as-is, don’t resolve | CI/Docker builds for reproducibility |
--no-install-workspace |
Install external deps only, skip workspace packages | Pre-populating the dependency cache without building your code |
--inexact |
Don’t remove packages not in the lockfile | When layering multiple sync steps (step 2 shouldn’t remove step 1’s packages) |
--no-build-isolation |
Skip isolated build environments | Speed optimization when build deps are pre-installed |
Flag combinations in practice
A typical Docker build might layer these:
# Step 1: Install external dependencies only (cached layer)
RUN uv sync --no-install-workspace --frozen
# Step 2: Install workspace package A with native code
RUN uv sync --package package_a --frozen --inexact
# Step 3: Install workspace package B (depends on A)
RUN DISABLE_BUILD_EXT=1 uv sync --package package_b --frozen --inexact
The --inexact on steps 2 and 3 prevents uv from uninstalling packages from previous steps. Without it, uv sync --package package_b might remove packages that were installed for package_a but aren’t direct dependencies of package_b.
Takeaways
- Read the symbols.
+vs~vs-tells you what uv actually did, not what you told it to do. - Compare build times. A step that should be fast but takes 5 minutes is rebuilding something it shouldn’t be.
- Look for compiler output. CUDA/C++ builds are noisy. Silence means the build was skipped or used a cached wheel.
- Understand layer deduplication. If your push phase is fast, it’s because the layers already exist — that’s by design, not a bug.
- Check mount paths early. “Distribution not found” almost always means a path mismatch between your Dockerfile mounts and your workspace configuration.
These logs tell you exactly what’s happening. The trick is knowing which lines matter.