Cloud Run containers are tiny by default, but you can make them even tinier and faster to deploy by thinking about your Dockerfile.
Let’s say you have a Python app that uses Flask. A naive Dockerfile might look like this:
FROM python:3.11-slim
WORKDIR /app
COPY . /app
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "app.py"]
This works, but it’s not optimal. We’re installing dependencies in a way that invalidates the cache more often than necessary, and we’re including build tools we don’t need at runtime.
Here’s a more optimized version, using multi-stage builds and a leaner base image:
# ---- Builder Stage ----
FROM python:3.11-slim AS builder
WORKDIR /app
# Copy only requirements first to leverage Docker cache
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ---- Final Stage ----
FROM python:3.11-slim
# Install only necessary packages for runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy installed packages from the builder stage
COPY --from=builder /install /usr/local
# Copy the rest of the application code
COPY . .
CMD ["python", "app.py"]
Let’s break down what’s happening and why it’s better.
Why Multi-Stage Builds?
The core idea of multi-stage builds is to use one Dockerfile to build your application and then copy only the necessary artifacts into a completely separate, minimal final image. This means your final container doesn’t include build tools, compilers, or intermediate files that were only needed during the build process.
In our example, the builder stage is where we install Python dependencies. We copy requirements.txt first and run pip install. This is a crucial caching optimization. If only your application code changes, but requirements.txt remains the same, Docker will reuse the layer where pip install ran, saving significant build time.
The --prefix=/install flag for pip install is key. It tells pip to install packages into a specific directory (/install) rather than the system’s default site-packages. This makes it easy to copy just those installed packages to the final stage.
The final stage starts from a clean python:3.11-slim image. We then copy the installed Python packages from the builder stage’s /install directory into the final image’s /usr/local directory. This ensures that only the runtime dependencies are present.
Reducing the Base Image Footprint
We’re using python:3.11-slim. These images are already significantly smaller than the full Python images because they omit development headers, documentation, and other non-essential files. For even smaller images, you might consider python:3.11-alpine, but be aware of potential compatibility issues with C extensions that rely on glibc (Alpine uses musl). The slim variant is often a good balance.
In the final stage, we explicitly install python3-pip and then immediately clean up /var/lib/apt/lists/*. This is a standard Docker practice to keep the image size down by removing cached package lists that are no longer needed after installation.
Leveraging Docker’s Build Cache
The order of operations in a Dockerfile dictates how Docker builds layers. Each RUN, COPY, and ADD command creates a new layer. If the command and its context haven’t changed since the last build, Docker reuses the cached layer.
By copying requirements.txt and running pip install before copying the rest of your application code (COPY . .), you ensure that if only your application code changes, the expensive pip install step is cached. This dramatically speeds up subsequent builds.
Security Considerations
Minimizing your container image isn’t just about speed and cost; it’s also a security best practice. A smaller image has a smaller attack surface because there are fewer packages and libraries installed. You’re only shipping what’s absolutely necessary for your application to run.
Putting It All Together
Here’s how you’d build and run this:
- Save the
Dockerfile: Put theDockerfilecontent into a file namedDockerfilein your project root. - Build the image:
docker build -t my-cloud-run-app . - Run locally (optional):
docker run -p 8080:8080 my-cloud-run-app
When you deploy this to Cloud Run, you’ll notice faster deployment times and potentially lower storage costs because the container image is smaller.
The next step in optimizing container deployments is understanding how to configure the Cloud Run service itself for optimal performance, like setting CPU and memory limits.