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
runAsNonRootsecurity 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 --userto 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=depsdirective 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 nonrootdirective 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), nodeC(Stage 2) gets Google green (#34a853), and nodeF(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
syntaxdirective enables BuildKit features such as--mount=type=cache. Thedepsstage usespython: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 sameRUNlayer to avoid persisting the cache in any layer. - Lines 10–11:
WORKDIR /buildisolates the build context. This directory will be discarded entirely when thedepsstage is not included in the final image. - Lines 13–14: Copying
requirements.txtalone before runningpip installmeans Docker caches this layer as long as the lockfile is unchanged. A code-only change in./appwill not invalidate this expensive layer. - Lines 16–17: The
--userflag 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-dirflag prevents pip from storing wheel archives that would bloat the layer. - Lines 19–20: The
runtimestage starts from a freshpython:3.12-slim, discarding everything fromdepsexcept what is explicitly copied. - Lines 22–26: Only runtime shared libraries are installed.
libgomp1provides OpenMP support fornumpy;curlenables theHEALTHCHECK. No compilers enter this stage. - Lines 28–31: A dedicated
appuserwith a fixed UID/GID of 1000 is created. Fixed IDs prevent permission drift across environments and satisfy KubernetesrunAsNonRootandrunAsUserpod security constraints used in GKE deployments with pod disruption budgets. - Lines 33–34:
COPY --from=depspulls only the/root/.localtree (installed wheels and console scripts) into the runtime user's home directory. This single instruction bridges the two stages. - Lines 36–38:
PATHis updated so console-script entry points (e.g.,uvicorn) resolve correctly.PYTHONUNBUFFERED=1ensures logs appear immediately in Cloud Run and GKE log aggregators.PYTHONDONTWRITEBYTECODE=1prevents.pycfiles 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
--chownflag ensures files are owned byappuser, preventing permission errors at runtime. - Lines 46–47: The
USERdirective switches toappuserbefore any port or entrypoint declaration. Any process spawned by theENTRYPOINTinherits this non-root identity. - Lines 49–51: The
HEALTHCHECKinstruction defines an in-container health probe that Cloud Run and Kubernetes liveness probes can leverage. The/healthendpoint 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 KubernetesServicewill 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
dataclassimportprovides a concise way to define the configuration object without writing an explicit__init__method. - Lines 5–12: The
CloudBuildConfigdataclass declares fields for the image URI, Dockerfile path, and a boolean toggle between Docker and Kaniko caching strategies. The_build_argsfield usesfield(default_factory=dict)to avoid the mutable default argument pitfall. - Lines 14–17: The
add_build_argmethod 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_stepsmethod constructs a three-step pipeline: pull the cached image (withallow_failureset to True because the first build has no cache), build with--cache-from, and push all tags. The*args_flagsunpacking injects any build arguments into the build command. - Lines 49–63: The
_kaniko_stepsmethod 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_dictmethod 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
- ✓Do copy
requirements.txtbefore copying application source code — this locks the dependency install layer to the lockfile only, so a one-line change tomain.pyreuses the cachedpip installlayer instead of re-running a 5–10 minute install in Cloud Build. - ✓Do install packages in the
depsstage with--no-cache-dir --user—--no-cache-dirprevents pip from writing a download cache into the image layer, and--userplaces all wheels under/root/.localso a singleCOPY --from=deps /root/.localpulls compiled packages into the runtime stage without any compiler toolchain following them. - ✓Do switch to a dedicated non-root user (e.g.,
appuserwith UID 1000) before theENTRYPOINTin 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 soCOPY --chown=appuser:appusercan set ownership in a single layer.
Don'ts
- ✗Don't install build-time system libraries like
build-essentialorlibffi-devin the runtime stage — those packages exist only sogrpcioandnumpycan compile their C/C++ extensions; copying the pre-built wheels viaCOPY --from=depsinto the runtime stage means the compilers and headers are never needed there, and including them inflates the final image by hundreds of megabytes. - ✗Don't use the full
python:3.12base image whenpython:3.12-slimsuffices — the full image adds ~900 MB of headers and documentation that FastAPI and LangChain never reference at runtime; usepython:3.12-slimin both stages and install only the runtime shared libraries actually needed (e.g.,libgomp1for NumPy's OpenMP threading). - ✗Don't place the
COPY ./appinstruction before dependency installation in the Dockerfile layer order — because application source changes on every commit, an earlyCOPYinvalidates all subsequent layers includingpip 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
- Ch 16Use Pydantic AI + Logfire as an alternative observability stack
- Ch 17Build async connection pools with FastAPI lifespan
- Ch 18Build multi-stage Docker images for FastAPI AI appsYou are here
- Ch 18Deploy FastAPI to Cloud Run with auto-scaling
- Ch 18Configure GKE deployments with HPA on custom metrics
- Ch 18Deploy NVIDIA NIM for self-hosted Llama 4 with LiteLLM routing
- Ch 18Deploy MCP tool servers as sidecars with external-secrets-operator