Skip to content
← BACK TO BLOG
Fikri Firman Fadilah
5 min read
DevOps

Container Image Bloat in CI/CD Pipelines: Diagnosing, Measuring, and Fixing Layer Accumulation Before It Tanks Your Deployment Speed

Share:
Share on Twitter
Share on LinkedIn
Copy Link

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:

bash
dive your-registry/your-service:latest

This 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:

bash
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:

bash
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:

bash
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:

dockerfile
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:

dockerfile
# 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 in slim (the runtime doesn't need gcc)
  • Use --user with pip to avoid root installation overhead
  • Use --no-cache-dir to 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:

dockerfile
COPY . /app
RUN pip install -r requirements.txt

When source code changed, the COPY step invalidated cache, and pip reinstalled everything.

Correct order:

dockerfile
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
COPY . /app
RUN python -m pytest  # or your build step

Now, 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:

ImageSizeUse CaseTrade-offs
python:3.11-bullseye900 MBComplex builds, C extensions, legacy dependenciesFull stdlib, full OS tooling, slowest pulls
python:3.11-slim150 MBStandard Python apps, modern dependenciesMissing some build tools, still has apt
python:3.11-alpine50 MBMinimal services, constrained environmentsMusl libc, slower pip builds, compatibility issues
python:3.11-distroless80 MBSecurity-critical services, locked dependencies

Recommendations

You might also like