Docker for Python: Multi-Stage Builds and Best Practices
Learn how to optimize Python Docker images using multi-stage builds.
20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.
- ✓Basic knowledge of Docker (images, containers, Dockerfiles)
- ✓Python programming experience
- ✓Familiarity with pip and virtual environments
- Multi-stage builds use multiple FROM statements to separate build and runtime environments, resulting in smaller images.
- Best practices include using slim base images, .dockerignore, and non-root users.
- Production incidents often involve bloated images due to including build tools.
- Debugging in production requires minimal images with proper logging and health checks.
Imagine building a house. You need tools like hammers and saws during construction, but once the house is finished, you don't want to live with those tools cluttering your living room. Multi-stage builds let you build your Python app in a 'workshop' container with all the tools, then copy only the final product into a clean 'living room' container.
You've written a Python application that works perfectly on your local machine. But when you try to containerize it with Docker, the image is 1.2GB! That's not just a storage problem—it slows down deployments, increases bandwidth costs, and expands the attack surface. In production, every megabyte matters. This is where multi-stage builds come to the rescue.
Multi-stage builds are a Docker feature that allows you to use multiple FROM statements in a single Dockerfile. Each FROM begins a new stage, and you can selectively copy artifacts from one stage to another. This means you can use a heavy base image with all build tools (compilers, headers, etc.) to compile dependencies, then copy only the necessary runtime files into a minimal final image.
In this tutorial, you'll learn how to apply multi-stage builds to Python applications, following best practices that production teams use. We'll cover real-world debugging techniques, a cautionary production incident, and common mistakes. By the end, you'll be able to reduce your Python Docker images by 80-90% while maintaining security and performance.
What Are Multi-Stage Builds?
Multi-stage builds are a Docker feature introduced in Docker 17.05. They allow you to use multiple FROM statements in a single Dockerfile, each starting a new stage. You can copy artifacts from one stage to another, discarding everything you don't need in the final image. This is particularly useful for compiled languages or when you need build tools that are not required at runtime.
- Build stage: Use a full Python image with build tools (gcc, python3-dev, etc.) to compile dependencies that require C extensions (e.g., numpy, pandas, cryptography).
- Runtime stage: Use a minimal Python image (slim or alpine) and copy only the compiled wheels or installed packages from the build stage.
This approach can reduce image size by 80% or more. For example, a Flask app with numpy might go from 1.2GB to 150MB.
Setting Up a Multi-Stage Dockerfile for Python
Let's walk through a complete example. Suppose you have a Python Flask app that uses numpy for some computation. Your requirements.txt includes flask, numpy, and gunicorn.
First, create a .dockerignore file to exclude unnecessary files:
`` __pycache__ .pyc .git .env .md ``
Now, the Dockerfile:
```dockerfile # Stage 1: Build dependencies FROM python:3.9-slim AS builder
WORKDIR /app
# Install system build dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ python3-dev \ libffi-dev \ && rm -rf /var/lib/apt/lists/*
# Create a virtual environment and install dependencies RUN python -m venv /venv ENV PATH="/venv/bin:$PATH"
COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt
# Stage 2: Runtime FROM python:3.9-slim
WORKDIR /app
# Copy virtual environment from builder COPY --from=builder /venv /venv ENV PATH="/venv/bin:$PATH"
# Copy application code COPY . .
# Expose port and run EXPOSE 8000 CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:app"] ```
Notice we use a virtual environment. This is cleaner than --user because it isolates dependencies and avoids PATH issues. The virtual environment is created in /venv and copied entirely to the final stage.
Best Practices for Python Docker Images
Beyond multi-stage builds, follow these best practices to create production-ready Python Docker images:
- Use slim or alpine base images: 'python:3.9-slim' is based on Debian slim, which is much smaller than the full image. Alpine is even smaller but may have compatibility issues with some C extensions.
- Minimize layers: Combine RUN commands to reduce layers. Use '&&' to chain commands and clean up in the same layer (e.g., remove apt cache).
- Use .dockerignore: Exclude unnecessary files like .git, __pycache__, .env, and test files to reduce build context and image size.
- Run as non-root user: Create a dedicated user in the runtime stage to improve security.
- Use specific tags: Avoid 'latest'; pin to a specific Python version and patch (e.g., python:3.9.18-slim).
- Leverage Docker layer caching: Order your Dockerfile from least to most frequently changing. Copy requirements.txt and install dependencies before copying the rest of the code.
- Scan for vulnerabilities: Use 'docker scan' or third-party tools to check for known vulnerabilities in your base image and dependencies.
Handling System Dependencies in Multi-Stage Builds
Some Python packages require system libraries at runtime (e.g., libpq for psycopg2, libssl for cryptography). In a multi-stage build, you need to install these runtime dependencies in the final stage, but you can keep them minimal.
For example, if you use psycopg2, you need libpq5 at runtime. Instead of installing the full postgresql-dev in the final stage, install only the runtime library:
```dockerfile # Build stage FROM python:3.9-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ libpq-dev \ && rm -rf /var/lib/apt/lists/*
# ... install dependencies
# Runtime stage FROM python:3.9-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ libpq5 \ && rm -rf /var/lib/apt/lists/*
# ... rest ```
This way, you only include the necessary shared libraries, not the development headers.
Another approach is to use the '--copy-links' option when copying from the build stage, but that's less common.
Debugging Multi-Stage Builds
When something goes wrong, debugging multi-stage builds can be tricky because intermediate stages are not saved by default. Here are techniques:
- Build with --target: You can build a specific stage to inspect it:
docker build --target builder -t myapp:builder .Then run a shell in that stage:docker run -it myapp:builder /bin/bash. - Use docker history: After building the final image, run
docker history <image>to see layer sizes and commands. This helps identify which layers are bloated. - Check installed packages: In the final stage, run
pip listto verify only runtime dependencies are present. - Use buildkit: Enable BuildKit by setting
DOCKER_BUILDKIT=1for better output and debugging. You can also use--progress=plainto see detailed logs. - Inspect intermediate containers: If a build fails, Docker leaves the intermediate container. You can commit it and inspect:
docker commit <container-id> debug-image.
Advanced: Using Docker Compose with Multi-Stage Builds
In a microservices architecture, you often have multiple services. Docker Compose can build each service using multi-stage Dockerfiles. You can also define build arguments to control stages.
Example docker-compose.yml:
``yaml version: '3.8' services: web: build: context: . target: runtime # Build only the runtime stage ports: - "8000:8000" worker: build: context: . target: runtime command: celery -A tasks worker ``
This ensures both services use the same base image and dependencies, but you can also have separate Dockerfiles for different services.
You can also use build arguments to conditionally include development tools:
```dockerfile ARG BUILD_ENV=production
FROM python:3.9-slim AS base ...
FROM base AS development RUN apt-get install -y vim
FROM base AS production ... ```
Then in docker-compose.override.yml for development:
``yaml services: web: build: args: BUILD_ENV: development ``
Security Considerations
Security is paramount in production. Multi-stage builds help by reducing the attack surface—fewer packages mean fewer vulnerabilities. But there's more:
- Use official images: Always pull from official repositories (e.g., python, debian) to ensure they are maintained and scanned.
- Regularly update base images: Set up automated builds to rebuild images when base images are updated.
- Scan images: Integrate tools like Trivy, Clair, or Docker Scan into your CI/CD pipeline.
- Avoid running as root: As shown earlier, create a non-root user.
- Use secrets properly: Never hardcode secrets in Dockerfiles. Use Docker secrets or build args with care (build args are visible in image history).
- Remove unnecessary files: In the build stage, clean up apt cache and temporary files in the same RUN command to avoid leaving them in layers.
- Use read-only root filesystem: When running containers, mount volumes for writable directories and set
--read-onlyflag.
The 1.2GB Image That Broke the CI Pipeline
- Always use multi-stage builds to separate build and runtime environments.
- Use slim or alpine base images for the final stage.
- Leverage pip's wheel cache and install only runtime dependencies.
- Include a .dockerignore to exclude unnecessary files.
- Regularly audit image sizes with 'docker history'.
docker history <image>docker image inspect <image> | jq '.[].RootFS.Layers'| File | Command / Code | Purpose |
|---|---|---|
| Dockerfile | FROM python:3.9-slim AS builder | What Are Multi-Stage Builds? |
| debug.sh | export DOCKER_BUILDKIT=1 | Debugging Multi-Stage Builds |
| docker-compose.yml | version: '3.8' | Advanced |
Key takeaways
Interview Questions on This Topic
What are multi-stage builds in Docker and why are they useful for Python applications?
Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.
That's Advanced Python. Mark it forged?
4 min read · try the examples if you haven't