Free lesson · GenAI Application Engineering

Build multi-stage Docker images for FastAPI AI apps

Build a production Dockerfile using multi-stage pattern for FastAPI with AI SDK dependencies. Implement the dependency stage (FROM python:3.12-slim AS deps) copying requirements.txt and running pip install --no-cache-dir into a virtualenv, maximizing layer cache reuse for large AI packages. Create the application stage (FROM python:3.12-slim AS app) copying the virtualenv from deps, creating a non-root appuser with useradd, and setting ENTRYPOINT to uvicorn --host 0.0.0.0 --port 8080 --workers 4. Build .dockerignore excluding tests/, .git/, __pycache__/, .env, and model checkpoints. Implement build_and_scan.py calling docker build then trivy image for vulnerability scanning, parsing results into a ScanReport Pydantic model with critical_count, high_count, and cve_list. Create docker-compose.yaml for local development with PostgreSQL, Redis, and FastAPI.

Course: Full-Stack GenAI Applications · Chapter 18 · Production Deployment on Cloud Run & GKE

Free to read — no subscription required.

Introduction

When you push a single-stage Docker image for a FastAPI GenAI application, you ship 4 GB of compilers, headers, and pip caches that production never executes — paying for the bloat at every cold start, scale-up, and registry pull, and leaving a root-owned process exposed in the runtime image. This lesson teaches you how to construct multistage Dockerfiles that produce small, secure, and cache-friendly container images for FastAPI-based GenAI applications. By the end you will be able to separate build-time toolchains from runtime artifacts, order layers to maximize cache hits in Cloud Build, run the final container as a non-root user, and integrate image scanning so vulnerable images never reach production.

Key Terminology

  • Multi-stage build: A Dockerfile pattern using multiple FROM instructions where intermediate stages produce build artifacts that are copied into a smaller final stage.
  • Layer caching: Docker's mechanism for reusing unchanged layers from previous builds, determined by instruction order and input file checksums.
  • Non-root execution: Running the container process under a user with UID ≠ 0, enforced by the USER directive and validated by Kubernetes runAsNonRoot security context.

Concepts

Security Scanning Integration

Building a small, non-root image is necessary but not sufficient. Production containers must be scanned for CVEs before deployment, and catching vulnerabilities at build time is far cheaper than rolling out an emergency patch after the image is already serving traffic.

Integrate scanning as a post-build validation step. Cloud Build can invoke gcloud artifacts docker images scan immediately after the push step, and the pipeline fails if any CRITICAL or HIGH severity CVE is found. Multi-stage builds reduce the scanning surface by design: because compilers, headers, and pip caches never reach the runtime stage, the scanner has fewer packages to flag and fewer false positives to triage. Pairing a small final stage with on-build scanning is the cheapest way to keep the CVE backlog from growing as dependencies are upgraded.

Code Walkthrough

Why Multi-Stage Matters for AI Workloads

AI application images carry unique weight. The transformers library alone pulls in tokenizers compiled from Rust; grpcio requires a C++ toolchain; and numpy/scipy need BLAS headers at compile time but only shared libraries at runtime. A single-stage build retains every compiler and header file in the final image. Multi-stage builds discard them, typically reducing image size by 60–75 %. Smaller images mean faster cold starts on Cloud Run's scale-to-zero model, faster node scheduling on GKE when HPA triggers a scale-up event, and reduced egress costs when pulling images across VPC connectors.

Beyond size, layer ordering determines cache hit rates. Every docker build replays layers from the first invalidated instruction onward. If you copy application source code before installing dependencies, a one-line code change forces a full pip install—wasting 5–10 minutes. The correct order is: copy lockfile → install dependencies → copy source. This pattern is non-negotiable in CI/CD pipelines backed by Cloud Build where build minutes translate directly to cost.

Code snippet mermaid
Loading diagram...
  • Line 1: Declares a Mermaid flowchart with top-down (TD) layout direction.
  • Line 2: Defines node A representing the first Docker multi-stage build stage ("deps") using python:3.12-slim, with an edge label showing it copies requirements.txt and runs pip install --user to produce node B, the compiled wheels stored in /root/.local.
  • Line 3: Connects node B to node C, which represents the second Docker stage ("runtime"), also based on python:3.12-slim.
  • Line 4: Shows the COPY --from=deps directive that copies only the pre-built wheels from /root/.local into node D, emphasizing that no compilers are carried over to the runtime image.
  • Line 5: Connects node D to node E, representing the COPY ./app layer that adds the application source code into the image.
  • Line 6: Connects node E to the final node F, showing the USER nonroot directive that switches to a non-root user, producing the final image at approximately 380 MB.
  • Line 7: Blank separator line between the flowchart definition and the styling directives.
  • Lines 8-10: Apply custom CSS styling to three nodes — node A (Stage 1) gets Google blue (#1a73e8), node C (Stage 2) gets Google green (#34a853), and node F (final image) gets Google red (#ea4335) — all with white text for contrast.

The diagram above shows the two-stage flow. Stage 1 (deps) installs Python packages into a user-local directory. Stage 2 (runtime) copies only the installed packages and the application source, then switches to a non-root user. Compiler toolchains, pip caches, and header files never enter the final image.

The Production Dockerfile: Stage by Stage

The following Dockerfile implements the multi-stage pattern for a FastAPI application that imports langchain_anthropic.ChatAnthropic, google.cloud.aiplatform, and fastapi.FastAPI. It uses python:3.12-slim as the base to avoid the 900 MB overhead of the full python:3.12 image while retaining apt-get access for installing runtime shared libraries like libgomp1 (required by numpy for OpenMP threading). The deps stage runs pip install with --no-cache-dir and --user flags so all packages land in /root/.local, making the subsequent COPY --from deterministic. The runtime stage creates a dedicated appuser with UID 1000, copies the installed packages into that user's local path, and sets the USER directive before the ENTRYPOINT—ensuring the container process never runs as root, which is a hard requirement for GKE Pod Security Standards and Cloud Run's default security posture.

Code snippet python
1# === Stage 1: Dependency builder === 2# syntax=docker/dockerfile:1 3FROM python:3.12-slim AS deps 4 5# Install build-time system libraries required by grpcio, numpy, etc. 6RUN apt-get update && \ 7 apt-get install -y --no-install-recommends \ 8 build-essential \ 9 libffi-dev \ 10 && rm -rf /var/lib/apt/lists/* 11 12WORKDIR /build 13 14# Copy ONLY the lockfile first — this layer is cached until deps change 15COPY requirements.txt . 16 17# Install into /root/.local so we can copy cleanly to runtime stage 18RUN pip install --no-cache-dir --user -r requirements.txt 19 20# === Stage 2: Production runtime === 21FROM python:3.12-slim AS runtime 22 23# Install only runtime shared libraries (no compilers) 24RUN apt-get update && \ 25 apt-get install -y --no-install-recommends \ 26 libgomp1 \ 27 curl \ 28 && rm -rf /var/lib/apt/lists/* 29 30# Create non-root user before copying anything 31RUN groupadd --gid 1000 appuser && \ 32 useradd --uid 1000 --gid appuser --shell /bin/bash \ 33 --create-home appuser 34 35# Copy installed packages from builder 36COPY --from=deps /root/.local /home/appuser/.local 37 38# Ensure the user-local bin is on PATH 39ENV PATH="/home/appuser/.local/bin:${PATH}" 40ENV PYTHONUNBUFFERED=1 41ENV PYTHONDONTWRITEBYTECODE=1 42 43WORKDIR /home/appuser/app 44 45# Copy application source — this layer changes most often, so it comes last 46COPY --chown=appuser:appuser ./app ./app 47COPY --chown=appuser:appuser ./main.py . 48 49# Switch to non-root BEFORE exposing port or setting entrypoint 50USER appuser 51 52EXPOSE 8080 53 54HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ 55 CMD curl -f http://localhost:8080/health || exit 1 56 57ENTRYPOINT ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
  • Lines 1–2: The syntax directive enables BuildKit features such as --mount=type=cache. The deps stage uses python:3.12-slim (≈ 120 MB) rather than the full image (≈ 920 MB).
  • Lines 4–8: Build-time system packages (build-essential, libffi-dev) are installed and the apt cache is purged in the same RUN layer to avoid persisting the cache in any layer.
  • Lines 10–11: WORKDIR /build isolates the build context. This directory will be discarded entirely when the deps stage is not included in the final image.
  • Lines 13–14: Copying requirements.txt alone before running pip install means Docker caches this layer as long as the lockfile is unchanged. A code-only change in ./app will not invalidate this expensive layer.
  • Lines 16–17: The --user flag installs packages under /root/.local, creating a self-contained directory tree that can be copied to the runtime stage without path conflicts. The --no-cache-dir flag prevents pip from storing wheel archives that would bloat the layer.
  • Lines 19–20: The runtime stage starts from a fresh python:3.12-slim, discarding everything from deps except what is explicitly copied.
  • Lines 22–26: Only runtime shared libraries are installed. libgomp1 provides OpenMP support for numpy; curl enables the HEALTHCHECK. No compilers enter this stage.
  • Lines 28–31: A dedicated appuser with a fixed UID/GID of 1000 is created. Fixed IDs prevent permission drift across environments and satisfy Kubernetes runAsNonRoot and runAsUser pod security constraints used in GKE deployments with pod disruption budgets.
  • Lines 33–34: COPY --from=deps pulls only the /root/.local tree (installed wheels and console scripts) into the runtime user's home directory. This single instruction bridges the two stages.
  • Lines 36–38: PATH is updated so console-script entry points (e.g., uvicorn) resolve correctly. PYTHONUNBUFFERED=1 ensures logs appear immediately in Cloud Run and GKE log aggregators. PYTHONDONTWRITEBYTECODE=1 prevents .pyc files from being written at runtime, avoiding filesystem write overhead in read-only container filesystems.
  • Lines 40–44: Application source is copied last because it changes most frequently. The --chown flag ensures files are owned by appuser, preventing permission errors at runtime.
  • Lines 46–47: The USER directive switches to appuser before any port or entrypoint declaration. Any process spawned by the ENTRYPOINT inherits this non-root identity.
  • Lines 49–51: The HEALTHCHECK instruction defines an in-container health probe that Cloud Run and Kubernetes liveness probes can leverage. The /health endpoint is a standard FastAPI route returning a 200 status.
  • Line 53: Uvicorn binds to 0.0.0.0:8080—the port Cloud Run expects by default and the port your Kubernetes Service will target.

Layer Cache Optimization in CI/CD

In a Cloud Build pipeline, layer caching requires explicit configuration because each build runs on a fresh VM. The standard approach is to pull the previous image, tag it as a cache source, and pass --cache-from to docker build. The following Python helper generates the Cloud Build configuration dictionary that implements this pattern. It uses the google.cloud.devtools.cloudbuild_v1 library's Build and BuildStep types to define the pull → build → push sequence. The function accepts a project_id and an image URI, returning a dictionary compatible with CloudBuildClient.create_build. Setting use_kaniko to True switches from Docker-layer caching to Kaniko's built-in registry-based caching, which is faster for large AI images because it caches individual layers in Artifact Registry rather than pulling the entire previous image.

Code snippet python
1from dataclasses import dataclass, field 2 3@dataclass 4class CloudBuildConfig: 5 """Generates Cloud Build steps with layer caching for AI images.""" 6 7 project_id: str 8 image: str # e.g., "us-docker.pkg.dev/myproj/repo/api" 9 dockerfile: str = "Dockerfile" 10 use_kaniko: bool = False 11 _build_args: dict = field(default_factory=dict) 12 13 def add_build_arg(self, key: str, value: str) -> None: 14 if key is None or value is None: 15 raise ValueError("Build arg key and value must not be None") 16 self._build_args[key] = value 17 18 def _docker_steps(self) -> list[dict]: 19 """Return classic docker build steps with --cache-from.""" 20 cache_image = f"{self.image}:cache" 21 args_flags = [ 22 f"--build-arg={k}={v}" for k, v in self._build_args.items() 23 ] 24 return [ 25 { 26 "name": "gcr.io/cloud-builders/docker", 27 "args": ["pull", cache_image], 28 "allow_failure": True, # First build has no cache 29 }, 30 { 31 "name": "gcr.io/cloud-builders/docker", 32 "args": [ 33 "build", 34 f"--cache-from={cache_image}", 35 f"-f={self.dockerfile}", 36 f"-t={self.image}:latest", 37 f"-t={cache_image}", 38 *args_flags, 39 ".", 40 ], 41 }, 42 { 43 "name": "gcr.io/cloud-builders/docker", 44 "args": ["push", "--all-tags", self.image], 45 }, 46 ] 47 48 def _kaniko_steps(self) -> list[dict]: 49 """Return a single Kaniko step with registry-layer caching.""" 50 args_flags = [ 51 f"--build-arg={k}={v}" for k, v in self._build_args.items() 52 ] 53 return [ 54 { 55 "name": "gcr.io/kaniko-project/executor:latest", 56 "args": [ 57 f"--dockerfile={self.dockerfile}", 58 f"--destination={self.image}:latest", 59 f"--cache=true", 60 f"--cache-ttl=168h", 61 *args_flags, 62 ], 63 }, 64 ] 65 66 def to_build_dict(self) -> dict: 67 steps = ( 68 self._kaniko_steps() if self.use_kaniko else self._docker_steps() 69 ) 70 return {"steps": steps, "timeout": "1800s"}
  • Lines 1–2: The dataclass import provides a concise way to define the configuration object without writing an explicit __init__ method.
  • Lines 5–12: The CloudBuildConfig dataclass declares fields for the image URI, Dockerfile path, and a boolean toggle between Docker and Kaniko caching strategies. The _build_args field uses field(default_factory=dict) to avoid the mutable default argument pitfall.
  • Lines 14–17: The add_build_arg method validates that neither the key nor value is None, raising a ValueError if either is missing. This prevents silent failures where a missing build argument produces an image with incorrect configuration.
  • Lines 19–47: The _docker_steps method constructs a three-step pipeline: pull the cached image (with allow_failure set to True because the first build has no cache), build with --cache-from, and push all tags. The *args_flags unpacking injects any build arguments into the build command.
  • Lines 49–63: The _kaniko_steps method emits a single Kaniko executor step. Kaniko caches layers directly in the container registry with a 168-hour TTL, eliminating the need for a separate pull step. This approach is 2–3× faster for images with large AI dependency layers because it avoids transferring the entire previous image.
  • Lines 65–69: The to_build_dict method selects the appropriate strategy and wraps the steps in a build dictionary with a 30-minute timeout—sufficient for large AI images that compile native extensions.

Do's and Don'ts

Do's

  1. Do copy requirements.txt before copying application source code — this locks the dependency install layer to the lockfile only, so a one-line change to main.py reuses the cached pip install layer instead of re-running a 5–10 minute install in Cloud Build.
  2. Do install packages in the deps stage with --no-cache-dir --user--no-cache-dir prevents pip from writing a download cache into the image layer, and --user places all wheels under /root/.local so a single COPY --from=deps /root/.local pulls compiled packages into the runtime stage without any compiler toolchain following them.
  3. Do switch to a dedicated non-root user (e.g., appuser with UID 1000) before the ENTRYPOINT in the runtime stage — running as root violates GKE Pod Security Standards and Cloud Run's default security posture; create the user before copying any files so COPY --chown=appuser:appuser can set ownership in a single layer.

Don'ts

  1. Don't install build-time system libraries like build-essential or libffi-dev in the runtime stage — those packages exist only so grpcio and numpy can compile their C/C++ extensions; copying the pre-built wheels via COPY --from=deps into the runtime stage means the compilers and headers are never needed there, and including them inflates the final image by hundreds of megabytes.
  2. Don't use the full python:3.12 base image when python:3.12-slim suffices — the full image adds ~900 MB of headers and documentation that FastAPI and LangChain never reference at runtime; use python:3.12-slim in both stages and install only the runtime shared libraries actually needed (e.g., libgomp1 for NumPy's OpenMP threading).
  3. Don't place the COPY ./app instruction before dependency installation in the Dockerfile layer order — because application source changes on every commit, an early COPY invalidates all subsequent layers including pip install, turning a cache hit into a full rebuild on every push and negating the performance advantage of multistage builds in CI/CD pipelines.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Application Engineering subscription.

From · cancel anytime · Already a subscriber? Sign in →

More free lessons in Full-Stack GenAI Applications

All free lessons in GenAI Application Engineering