My Docker Image Was 4.7 GB. Kubernetes Made Me Care.
The container worked locally. A small GKE cluster made the cost of shipping every dependency impossible to ignore.
For a while, my Dockerfile had one job: make the application run.
It did.
The problem was that it also produced an image around 4.7 GB.
I knew that was large. I didn’t really care until the image had to live on a small Kubernetes cluster.
I had shipped my development environment
The early Dockerfile was roughly this:
FROM python:3.11-slim
WORKDIR /app
COPY . .
RUN pip install -e ".[all]"
CMD ["cairnops-api"]
The important mistake was .[all].
That installed everything the repository knew about: API dependencies, database code, Redis, RAG ingestion, evaluation tooling, test packages, lint tooling, and other development dependencies.
On my laptop it was harmless. Disk was available, the image was cached, and I wasn’t repeatedly pulling it onto fresh nodes.
GKE made the waste visible.
Large pulls took longer, ephemeral storage became a real constraint, and every unnecessary package was now part of a production artifact.
Multi-stage was only half the fix
I moved build-time work into a builder stage and copied only the virtual environment and application code into the runtime stage.
The runtime image no longer needed compilers or build tooling.
More importantly, I stopped installing the “everything” dependency group.
The final stage looked closer to this:
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash appuser
COPY --from=builder --chown=appuser:appuser \
/app/.venv /app/.venv
COPY --chown=appuser:appuser \
src/ ./src/
ENV PATH="/app/.venv/bin:$PATH"
USER appuser
I also used COPY --chown instead of copying files as root and fixing ownership later.
The result was around 121 MB compressed in the registry.
The number is nice. The behavior change mattered more.
Node storage pressure stopped being a problem. Pulls were much faster. New nodes could become useful sooner. CI builds benefited more from caching.
Deleting later does not undo a layer
This was also the project where Docker layers finally stopped being an abstract concept for me.
This does not work the way people often expect:
RUN apt-get install build-essential
RUN apt-get remove build-essential
The second layer does not erase the bytes from the first one. The earlier layer is still part of the image history.
The reliable way to keep build tooling out of a runtime image is to never copy it into that image in the first place.
That is why multi-stage builds matter.
Not because “multi-stage” is a checkbox on a Docker best-practices list, but because they give you a clean boundary between what is required to build software and what is required to run it.
I now log image size in CI after every build.
It is a small check, but it catches a surprisingly expensive class of mistakes before Kubernetes has to teach me the same lesson again.