Container Image Bloat in CI/CD Pipelines: Diagnosing, Measuring, and Fixing Layer Accumulation Before It Tanks Your Deployment Speed
How a team's Docker builds hit 45+ minutes and registry storage exploded—and the diagnostic tools and layer-caching strategies that fixed it.
Container Image Bloat in CI/CD Pipelines: Diagnosing, Measuring, and Fixing Layer Accumulation Before It Tanks Your Deployment Speed
The Crisis
It was a Tuesday afternoon when the on-call engineer flagged something in Slack: a production deploy was taking 47 minutes. Not the build itself—that was 12 minutes. The remaining 35 minutes were spent pulling the container image from the registry, waiting for it to decompress on the node, and starting the container.
The team had 15 services in Kubernetes. Each deploy window was shrinking. Incident response became a waiting game. The registry bill had tripled in six months. And nobody could explain why.
I spent the next week with their infrastructure team, and what we found was textbook layer accumulation: six years of Dockerfiles, each one a small sin. A base image that had grown from 150 MB to 890 MB. Build caches that were never invalidated. Intermediate artifacts left in layers. A deploy pipeline that had optimized for "ship it fast" and paid the debt in operational friction.
This is a field note from that work. It's not about best practices. It's about what broke, why it mattered, and what actually fixed it.
Why Image Bloat Costs Real Money
Before diving into diagnosis, let's be concrete about the operational impact:
Deploy velocity during incidents: A 45-minute image pull means your incident response is bottlenecked by container orchestration, not by your code. A P1 at 2 AM becomes a 50-minute wait before you can even start debugging.
Registry storage: At $0.10 per GB-month (conservative AWS ECR pricing), a team pulling 50 GB of images per week across 6 environments is paying ~$300/month in storage alone. That's $3,600 annually for bloat that doesn't ship value.
Network strain: Every developer doing a local docker pull of a 2 GB image is burning bandwidth. Multi-region deployments amplify this. We saw one team's registry-to-node transfers consuming 40% of their egress quota.
Cache invalidation cascades: When a base layer bloats, it invalidates every downstream cache. A single change to a base image forced a full rebuild of 40+ services. That's 8 hours of CI time per deploy.
The team's math: 15 services × 3 deploys/day × 45 minutes = 33.75 hours of CI/CD time daily. They were paying $0.50/minute for runners. That's $1,012.50 per day in compute, before the human cost of waiting.
Diagnosing Layer Accumulation: The Tools That Work
1. Start with dive: Visual Layer Inspection
Dive is your first diagnostic tool. It shows you what's actually in each layer:
dive your-registry/your-service:latestThis opens an interactive UI. You see:
- Total image size (top left)
- Layer-by-layer breakdown (left panel)
- File-level detail (right panel)
- Wasted space (files added then deleted in later layers)
For the team's primary service, dive immediately showed something wrong: a 340 MB layer labeled RUN pip install -r requirements.txt followed by a 280 MB layer of RUN rm -rf /var/lib/apt/lists/*. The pip cache was never cleaned. Then it was partially removed. The bloat persisted in the layer history.
Action: Run dive on your top 10 images. Screenshot the results. Share them with your team. This is the wake-up call.
2. Measure with skopeo and jq
For automation, skopeo gives you programmatic access to image metadata:
skopeo inspect --raw docker://your-registry/service:latest | jq '.fsLayers[] | .blobSum' | while read layer; do
skopeo blobs-info docker://your-registry/service:latest "$layer" | jq '.size'
done | awk '{sum+=$1} END {print "Total layers: " NR ", Total size: " sum/1024/1024 " MB"}'This script iterates through every layer and sums their sizes. Run it weekly. Track it in a spreadsheet. The team found that their largest service's images had grown from 620 MB to 1.2 GB over four months—a 94% increase with no feature changes.
3. Analyze Build Cache with BuildKit
If you're using Docker BuildKit (you should be), enable inline cache analysis:
DOCKER_BUILDKIT=1 docker build \
--progress=plain \
--build-arg BUILDKIT_INLINE_CACHE=1 \
-t your-registry/service:latest .Then inspect what was cached and what wasn't:
docker buildx build \
--progress=plain \
--cache-from=type=registry,ref=your-registry/service:latest \
.Watch the output. If you see CACHED on every step, your cache is healthy. If you see rebuilds on steps that shouldn't change (like base image updates), your Dockerfile has ordering problems.
The team's build log showed that their COPY . /app step was invalidating cache on every commit, even when only test files changed. This forced a rebuild of all downstream layers. We fixed this with a .dockerignore file.
The Fixes: From Theory to Practice
Fix 1: Multi-Stage Builds (The Biggest Win)
The team's Dockerfile was a single stage. Build artifacts, dependencies, and source code all lived in the final image.
Here's what they had:
FROM python:3.11-bullseye
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
RUN apt-get update && apt-get install -y gcc postgresql-client
COPY . /app
RUN python setup.py build
ENTRYPOINT ["python", "app.py"]This image was 890 MB. The runtime needed: Python, the installed packages, and the app code. It didn't need: gcc, build tools, or the source directory for setup.py.
Here's the multi-stage version:
# Stage 1: Builder
FROM python:3.11-bullseye AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
COPY . /app
RUN python setup.py build
# Stage 2: Runtime
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y postgresql-client && rm -rf /var/lib/apt/lists/*
COPY --from=builder /root/.local /root/.local
COPY --from=builder /app/dist /app/dist
ENV PATH=/root/.local/bin:$PATH
ENTRYPOINT ["python", "app.py"]Result: 890 MB → 280 MB. That's a 69% reduction.
Key moves:
- Build in
bullseye, run inslim(the runtime doesn't need gcc) - Use
--userwith pip to avoid root installation overhead - Use
--no-cache-dirto prevent pip from storing wheels - Only copy what the runtime needs from the builder
- Clean apt caches explicitly (the
rm -rf /var/lib/apt/lists/*that wasn't being inherited)
Impact: Deploys dropped from 47 minutes to 18 minutes. Registry storage fell 65% across all services. CI time halved.
Fix 2: Layer Caching Strategy
The team was rebuilding the Python environment on every code change. This is because their Dockerfile had:
COPY . /app
RUN pip install -r requirements.txtWhen source code changed, the COPY step invalidated cache, and pip reinstalled everything.
Correct order:
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
COPY . /app
RUN python -m pytest # or your build stepNow, code changes don't invalidate the pip cache. Dependencies only rebuild if requirements.txt changes.
We also added a .dockerignore:
.git
.pytest_cache
__pycache__
*.pyc
.env
tests/
docs/
This prevents test files and documentation from bloating the build context and invalidating layers.
Impact: 80% of their deploys now hit full cache. Build time dropped from 12 minutes to 2 minutes for code-only changes.
Fix 3: Base Image Selection
The team was using python:3.11-bullseye for everything. Bullseye is 900 MB. For most services, they didn't need the full standard library.
We created a decision matrix:
| Image | Size | Use Case | Trade-offs |
|---|---|---|---|
python:3.11-bullseye | 900 MB | Complex builds, C extensions, legacy dependencies | Full stdlib, full OS tooling, slowest pulls |
python:3.11-slim | 150 MB | Standard Python apps, modern dependencies | Missing some build tools, still has apt |
python:3.11-alpine | 50 MB | Minimal services, constrained environments | Musl libc, slower pip builds, compatibility issues |
python:3.11-distroless | 80 MB | Security-critical services, locked dependencies |