Python Containers with uv/uvx: High-Performance Images, Minimal Memory, Minimal Start Time¶
Python is the worst language to containerize well and the most common language to containerize badly. The defaults are user-hostile: python:3.12 is 1 GB before you write a line of code; pip install is single-threaded and re-resolves the world; requirements.txt is unhashed; native wheels disagree with Alpine's musl; import pandas parses ~2,000 files before main() runs. Every layer of the stack — interpreter, packager, image, runtime — has a default that prioritizes "works for everybody" over "small, fast, secure."
This chapter is about flipping every one of those defaults. The thesis: with uv as the package manager, distroless or python:3.12-slim as the base, multi-stage builds, AOT-compiled .pyc, and tuned interpreter flags, a Python service can be packaged into a 50–100 MB image that cold-starts in 200–400 ms and uses 30–40% less RSS than the naive equivalent. Every dial along the way matters; most teams turn none of them.
If you read chapter 39 (Dockerfile best practices), this chapter is the language-specific deep dive that ties it to Python's particular pathologies. It assumes you're already comfortable with multi-stage builds, BuildKit cache mounts, and distroless bases — we'll go past those, into Python-specific territory.
Table of Contents¶
- Why Python Defaults Are Hostile to Containers
uvanduvx: What They Are and Why They Belong in Your Dockerfile- The
uvMental Model: Lockfile, Resolver, Cache - Base Image Choice for Python: A Reality Check
- The Canonical Multi-Stage
uvDockerfile - Cache Mounts for
uv: The Real Win uv syncvsuv pip install: Project Mode vs Pip Compat- Bytecode Compilation:
--compile-bytecodeand Why It Matters - Image Size: Where the Bytes Actually Go in Python Images
- Distroless Python: The Production Default
uvxInside the Build: Tools Without Polluting the Image- Native Dependencies:
psycopg,cryptography,numpy,lxml - Memory: Interpreter Tuning, Allocator Tuning, Per-Worker Sizing
- Cold Start: The Import-Time Tax and How to Pay Less
- ASGI/WSGI Server Choice: uvicorn, gunicorn, granian, hypercorn
__pycache__Strategies and Read-Only Filesystems- Reproducibility with
uv lock - CI/CD Patterns for
uvBuilds - The Gold-Standard Dockerfile, Fully Annotated
- Measuring: How to Tell If You Actually Improved Anything
- TL;DR
1. Why Python Defaults Are Hostile to Containers¶
Run through the data:
python:3.12(Debian-based, full image): ~1.0 GB compressed, ~1.05 GB uncompressed.python:3.12-slim: ~45 MB compressed, ~125 MB uncompressed. Drops dev headers, docs, locales.python:3.12-alpine: ~20 MB compressed, ~55 MB uncompressed. musl libc.gcr.io/distroless/python3-debian12: ~25 MB compressed, ~70 MB uncompressed. No shell, no package manager.
Then your dependencies pile on. pip install fastapi sqlalchemy psycopg2-binary adds another ~80 MB (FastAPI alone is small; psycopg2-binary ships a libpq inside the wheel; SQLAlchemy ships ~12 MB). Add pandas and you're +200 MB. Add tensorflow and you're at +2 GB. Add torch with CUDA and you're at +4 GB.
The problems compound:
- Slow
pip install. pip's resolver is correct but single-threaded and SAT-solver-slow. On a 50-dep project, a clean resolve can take 20–60 seconds. - No content-addressed cache by default. pip's wheel cache is per-user, not content-addressed; reinstalling the same package may or may not hit the cache.
__pycache__written at first import. Cold start pays the.py→.pyccompilation tax every time, unless you precompile.- Import time dominated by
site-packagesscanning.import pandasparses 1,800+ files before returning control.import djangoreads ~700. - No native standard for lockfiles.
pip freeze,requirements.txt,Pipfile.lock,poetry.lock,pdm.lock,pip-tools— six conventions, none built in. - musl vs glibc wheels split. Many wheels are
manylinux2014only; on Alpine, pip falls back to source builds. Now you needgcc,python3-dev, library headers — image triples in size. - Two-step builds without tooling support. Out of the box, building a wheel-only image (no compilers in production) requires hand-rolled stages.
uv and a tight Dockerfile fix every one of these.
2. uv and uvx: What They Are and Why They Belong in Your Dockerfile¶
uv is a Python package manager and resolver, written in Rust by Astral (the makers of Ruff). In one binary, it replaces:
pip(installing).pip-tools(locking).virtualenv/venv(env creation).pipx(running Python CLIs in isolated envs — that'suvx).- Parts of
poetry/pdm(project management withpyproject.toml+uv.lock). pyenv(managing multiple Python versions).
The performance claims are not marketing — uv is 10–100× faster than pip on real workloads. On a fresh resolve of FastAPI + SQLAlchemy + Pydantic + uvicorn + ~20 deps, pip takes ~25 seconds; uv takes ~0.8 seconds. On warm cache, uv reuses already-extracted wheels via hardlinks and uv sync finishes a 50-dep install in well under a second.
Why this matters in a container:
- CI builds drop 30–90 seconds. At 50 builds/day, that's a meaningful CI cost.
- Cache mounts get utilized better.
uvhas a sane, content-addressed global cache (typically/root/.cache/uv) that BuildKit cache mounts handle cleanly. - Reproducibility is built-in.
uv.lockis hashed, deterministic, and resolved with a real SAT-style resolver. - Bytecode compilation is one flag.
uv sync --compile-bytecodeoruv pip install --compile-bytecodeprecompiles.pycat install time. - No interpreter required to bootstrap
uv.uvis a static Rust binary; you don't need a Python in the image to install Python. (uv python install 3.12will fetch a CPython if you don't have one.)
uvx is the run-a-Python-CLI-without-installing-it command:
In a Dockerfile build stage, uvx lets you run code-formatters, linters, test-runners, and migration tools without polluting the image — the tool runs in an ephemeral venv that lives in uv's cache, never in the final image. Compare to the bad pattern of pip install pytest && pytest && pip uninstall pytest (which leaves layer cruft regardless of the uninstall).
3. The uv Mental Model: Lockfile, Resolver, Cache¶
Three concepts:
pyproject.toml: declares dependencies, Python version constraints, and optional dependency groups.uv.lock: the resolved, hashed lockfile. Committed to git. Cross-platform: contains entries for all platforms you care about.- Cache (
~/.cache/uvor$UV_CACHE_DIR): a content-addressed store of downloaded wheels, extracted archives, built source distributions, and resolved version info. Reused across projects.
Workflow:
uv init # bootstrap pyproject.toml + .venv
uv add fastapi sqlalchemy # add deps; updates pyproject.toml + uv.lock
uv sync # install everything in uv.lock into the venv
uv lock # re-resolve and write uv.lock without installing
uv lock --upgrade-package fastapi # bump a single package
uv run python -m app # run inside the venv without `source`
For Docker, the key insight: uv sync is fully deterministic given pyproject.toml + uv.lock + a Python version. Two identical uv sync invocations on the same lockfile produce identical site-packages directories, byte for byte (modulo .pyc mtimes, which we'll fix in §8 and §17).
uv and --locked: in CI, always use uv sync --locked (or --frozen). --locked errors if uv.lock would have to change to satisfy pyproject.toml; --frozen skips re-resolving entirely. Either guarantees you're installing what the lockfile says.
Three flags worth knowing:
--no-dev: skip thedevdependency group. Production image doesn't needpytest,mypy,ruff.--no-install-project: don't install the project itself (yet). We'll copy source and install in a later step to get cache layering right.--locked: fail if lockfile is stale.
4. Base Image Choice for Python: A Reality Check¶
The honest comparison for production:
| Base | Size | Wheels | Shell | Verdict |
|---|---|---|---|---|
python:3.12 |
~1 GB | Both | Yes | Never for runtime. Sometimes for build stage. |
python:3.12-slim-bookworm |
~45 MB | glibc | Yes | Default for build stage. Reasonable for runtime if you need a shell. |
python:3.12-alpine |
~20 MB | musl (often missing) | Yes | Avoid unless you've audited every wheel. |
gcr.io/distroless/python3-debian12 |
~25 MB | glibc | No | Production runtime default. |
cgr.dev/chainguard/python:latest |
~30 MB | glibc | No | Chainguard's distroless equivalent. Aggressively CVE-tracked. |
python:3.12-slim + uv (no python in final) |
varies | n/a | yes | If you're shipping a script. |
ghcr.io/astral-sh/uv:python3.12-bookworm-slim |
~85 MB | glibc | Yes | Bundles uv + Python; convenient for build stages. |
The Alpine warnings are not theoretical. Specific failures you'll encounter:
psycopg2-binaryhas no musl wheel. Falls back to source build → needslibpq-dev,gcc.numpy,pandas,scipy,scikit-learnshipmanylinuxwheels first;musllinuxwheels are newer and sometimes incomplete for older versions.cryptographyhas musl wheels but requiresrustcto build older versions.- DNS behaves differently (single-label, parallel queries — see ch 39 §3).
- Threading stack sizes are smaller; deep recursion in Python (e.g., parsing nested JSON) can crash.
The two-line rule: build in python:3.12-slim-bookworm (or the astral-sh/uv image); ship in gcr.io/distroless/python3-debian12:nonroot. Everything else is a deviation that should be justified.
When not to use distroless:
- Your app shells out to subprocesses (calls
subprocess.run(['ffmpeg', ...])). Distroless has no ffmpeg, no shell. Either bundle the binary you need into the image (copy it from a separate stage) or usepython:3.12-slimfor runtime. - You need to
kubectl execfor ad-hoc debugging. Usekubectl debugwith an ephemeral debug container instead; it's the right tool. - Your app uses
os.systemor anyshell=Truesubprocess call. Refactor that — but if you can't, you need a shell in the image.
5. The Canonical Multi-Stage uv Dockerfile¶
The starting point. We'll improve on this in later sections, but get this in your head as the baseline:
# syntax=docker/dockerfile:1.7
# ----- builder ----------------------------------------------------------
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Install deps first (cache-friendly)
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-install-project
# Now copy the project and install it
COPY src ./src
COPY README.md ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ----- runtime ----------------------------------------------------------
FROM gcr.io/distroless/python3-debian12:nonroot
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/app/.venv/bin:${PATH}"
WORKDIR /app
# Copy the built venv from the builder
COPY --from=builder --chown=nonroot:nonroot /app/.venv /app/.venv
COPY --from=builder --chown=nonroot:nonroot /app/src /app/src
USER nonroot
EXPOSE 8000
ENTRYPOINT ["python", "-m", "src.main"]
What's happening, line by line:
# syntax=docker/dockerfile:1.7: pin BuildKit frontend.FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder: starts from Astral's image which has bothuvand Python 3.12 already, on Debian slim. Saves anapt installstep.UV_COMPILE_BYTECODE=1: tellsuvto compile.py→.pycat install time. (Equivalent to passing--compile-bytecode.)UV_LINK_MODE=copy: forcesuvto copy wheel contents into the venv rather than hardlink. Hardlinks are faster but break when the cache mount is on a different filesystem than the project — which it always is in BuildKit. Without this, you sometimes seeOSError: [Errno 18] Invalid cross-device link.UV_PYTHON_DOWNLOADS=never:uvwon't try to download a Python interpreter; it uses the one in the image. Saves bytes and avoids surprises.PYTHONDONTWRITEBYTECODE=1: don't write.pycat runtime (we already did it at build time).PYTHONUNBUFFERED=1: flush stdout/stderr after every write. Critical for container logging: without this, your logs are buffered until the process exits or fills the buffer.COPY pyproject.toml uv.lockbefore source: cache layer for dependencies.--mount=type=cache,target=/root/.cache/uv: BuildKit cache mount onuv's global cache. Massive speedup across builds.uv sync --locked --no-dev --no-install-project: install dependencies but not the project itself yet.COPY srcthen seconduv sync: now the project is installed (in editable or non-editable mode, depending on pyproject config).- Final stage: distroless Python, copies the prebuilt
.venvand source. Nouv, nopip, no shell in the runtime image. USER nonroot: distroless ships anonrootuser at UID 65532. Use it.
Resulting image: ~75–90 MB for a typical FastAPI/SQLAlchemy service. Compare to ~400 MB for a python:3.12 + pip install build of the same code.
6. Cache Mounts for uv: The Real Win¶
uv's cache directory is the secret sauce. By default, on Linux it's ~/.cache/uv (or $UV_CACHE_DIR). It contains:
- Wheel cache: downloaded
.whlfiles, content-addressed. - Extracted wheel cache: pre-extracted wheel contents (so install is just copy/hardlink).
- Source distribution cache:
.tar.gzsdists. - Built wheels: if
uvhad to build a wheel from an sdist (because no binary wheel was available), it caches the built wheel. - Resolver cache: cached metadata for already-queried packages.
A BuildKit cache mount keeps this directory across builds:
Cache hit rates in practice:
- Same project, same lockfile, different code change: ~100% hit. The
uv syncline completes in <1 second. - Same project, dep version bump: ~95% hit (only the changed package is re-downloaded).
- Different project, overlapping deps: significant hit (this is the global-cache magic —
numpy==1.26.4is in the cache regardless of which project pulled it).
Two important details:
1. UV_LINK_MODE=copy is required for cache mounts to work reliably.
uv's default link mode is hardlink on Linux: when installing a wheel into a venv, it creates hardlinks from the cache to the venv. Hardlinks are fast and disk-efficient — but they require the source and destination to be on the same filesystem. In BuildKit, the cache mount is a separate filesystem from the build's working directory. Hardlinking fails with EXDEV: Invalid cross-device link.
Set UV_LINK_MODE=copy (or pass --link-mode=copy) to force copy. Loses the hardlink speedup but gains correctness. Net result: still much faster than pip.
2. Cache mount sharing across stages.
If you have multiple stages doing uv sync (e.g., one for dev deps to run tests, another for production deps), they share the cache mount. No duplicate downloads:
FROM ... AS test
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked # includes dev deps
RUN uv run pytest
FROM ... AS prod
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev # production only
Both stages share /root/.cache/uv. The test stage downloads pytest; the prod stage doesn't install it, but numpy (used by both) was downloaded once.
7. uv sync vs uv pip install: Project Mode vs Pip Compat¶
uv has two main install commands:
uv sync: project mode. Readspyproject.tomlanduv.lock. Manages a single venv (typically.venv). The canonical command for project workflows.uv pip install: pip-compatible mode. Readsrequirements.txt. Installs into the current Python environment. Useful for legacy projects or when you don't want apyproject.toml.
In a new project, use uv sync. In a legacy project with requirements.txt, you have two options:
Option A: migrate to pyproject.toml + uv.lock.
uv init --no-readme --no-pin-python
# Hand-edit pyproject.toml to add dependencies, or:
uv add $(cat requirements.txt)
uv lock
Option B: keep requirements.txt, use uv pip install.
COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system --no-deps -r requirements.txt
--system installs into the system Python (no venv). --no-deps is dangerous unless you trust your requirements.txt to be fully resolved (use uv pip compile requirements.in -o requirements.txt --generate-hashes to produce a hashed, fully-resolved file).
For new projects, always use uv sync with pyproject.toml + uv.lock. Lockfile-driven workflows are the future; requirements.txt is legacy.
Bonus tip — uv pip compile:
This is the pip-tools replacement. Even if you keep requirements.txt, generating it from requirements.in with uv pip compile --generate-hashes gives you a hashed, fully-pinned, reproducible install file. The Dockerfile can then uv pip install --require-hashes -r requirements.txt for hash-verified installs.
8. Bytecode Compilation: --compile-bytecode and Why It Matters¶
When Python imports a module, it:
- Looks for a
.pycfile (compiled bytecode) in__pycache__/. - If found and the source's mtime/hash matches: load the
.pyc. - If not found or stale: parse the
.py, compile to bytecode, write.pyc, then execute.
On a cold container with a read-only filesystem, step 3 fails to write but still does the parse-and-compile work — every time the process starts. For a service that imports a couple thousand modules at startup (Django + DRF + a few apps; FastAPI + SQLAlchemy + Alembic; anything ML-flavored), compilation can take 300–800 ms at every boot.
uv sync --compile-bytecode (or UV_COMPILE_BYTECODE=1) compiles .pyc at install time, in the build stage. The .pyc ships in the image. Cold start now skips compilation entirely:
# Without --compile-bytecode
container start (350ms)
+ import django (220ms parse + 80ms execute)
+ import drf (180ms parse + 50ms execute)
+ ... = ~700ms before first request
# With --compile-bytecode
container start (350ms)
+ import django (45ms load .pyc + 80ms execute)
+ import drf (30ms load .pyc + 50ms execute)
+ ... = ~250ms before first request
Real numbers from a FastAPI service: cold start drops from ~1.2 s to ~0.4 s with bytecode compilation. Just flip the flag. It costs nothing at build time except a small disk increase (~10–20% more bytes for the .pyc files alongside .py).
A related trick — PYTHONDONTWRITEBYTECODE=1 at runtime — prevents Python from writing new .pyc files at runtime. Combined with read-only root filesystems (readOnlyRootFilesystem: true in K8s), this means:
- Build stage compiles
.pycs into the image. - Runtime has all the
.pycs available. - Runtime can't write new
.pycs (no need to, no permission to). - No write attempts to a read-only FS that would fail loudly.
This is the right configuration. Both flags. Always.
9. Image Size: Where the Bytes Actually Go in Python Images¶
Run dive on a typical Python service image. The breakdown:
| Component | Size | Reducible? |
|---|---|---|
| Base OS (distroless or slim) | 25–45 MB | Choose smaller base |
| Python interpreter | 25–30 MB | Already minimal |
site-packages (dependencies) |
50–200 MB | Yes (next sections) |
| Your code | 0.5–5 MB | Already minimal |
__pycache__ |
10–30 MB | Keep for cold-start; alternative: strip .py |
The dependency layer is where the savings are. Specific patterns:
Strip tests and docs from wheels.
Many wheels ship their tests/ directory and docs. Examples: numpy ships a tests/ directory (~30 MB), scipy (~50 MB), pandas (~15 MB). These are useless in production. Strip them in the build stage:
RUN find /app/.venv -type d -name tests -prune -exec rm -rf {} + \
&& find /app/.venv -type d -name __pycache__ -prune \
-path '*/tests/*' -exec rm -rf {} + \
&& find /app/.venv -type d -name "*.dist-info" -exec sh -c \
'rm -rf "$1/RECORD" "$1/INSTALLER" "$1/REQUESTED"' _ {} \;
That find typically saves 30–100 MB on data-science-flavored images.
Strip the C extension .so debug info.
Many wheels ship native extensions (.so files) with debug symbols. strip them:
Saves 20–50 MB for ML-heavy images. The || true is because some .sos can't be stripped further; we don't want the find to fail the build.
Remove __pycache__ for unused Python versions.
If wheels ship .pyc files for multiple Python versions in their __pycache__/ (look for *.cpython-39.pyc, *.cpython-310.pyc, *.cpython-311.pyc, etc.), strip those that don't match your runtime:
# Keep only 3.12 bytecode
RUN find /app/.venv -name "__pycache__" -type d -exec sh -c \
'find "$1" -name "*.cpython-*.pyc" ! -name "*.cpython-312.pyc" -delete' _ {} \;
Not common (most wheels only ship .py, with .pyc compiled at install) but worth knowing.
Drop locale data.
locale/ directories under site-packages are huge for libraries with translations (Django, Babel, polib). If your app is English-only:
This is aggressive — your error messages won't be localized — but it's a 10–40 MB save for some apps.
Beware of pip install --no-deps followed by manual deps.
A common "minimize" pattern is to install only what you import. It works, but: dependency graphs are deep, and you'll get confusing import errors at runtime instead of build time. Use --no-deps only when you have a fully-resolved hashed requirements file.
10. Distroless Python: The Production Default¶
gcr.io/distroless/python3-debian12 includes:
- The CPython interpreter (3.12 currently).
- The CPython standard library.
- glibc, ca-certificates, tzdata, /etc/passwd with a
nonrootuser.
It does NOT include:
- pip, setuptools, wheel (none of them are needed at runtime).
- A shell. No
sh, nobash, nobusybox. - Coreutils, curl, wget.
apt,dpkg.
For a Python service, this is fine — you're running python -m yourapp (or your venv's python). No shell needed.
Variant tags:
:latest— current version, root user.:nonroot— current version, runs as UID 65532.:debug— adds a busybox shell. Useful for ad-hoc debugging in CI; do not ship to production.:debug-nonroot— both.
Use :nonroot in production. It maps to runAsUser: 65532 in your pod spec.
Healthchecks in distroless: there's no curl. Two options:
1. Use a Python-based healthcheck.
livenessProbe:
exec:
command: ["python", "-c", "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"]
periodSeconds: 10
Adds ~50–80 ms per probe (interpreter startup). Fine for liveness; might be slow for readiness if you probe aggressively.
2. Bundle a tiny health probe binary.
Compile a 5-line Go binary that does an HTTP GET and exits 0/1. Copy it into the image. Distroless still works, probe takes <5 ms.
grpc_health_probe does this for gRPC.
11. uvx Inside the Build: Tools Without Polluting the Image¶
uvx (alias for uv tool run) executes a Python CLI in an ephemeral venv, similar to pipx run. Critically, the tool's installation does not affect the project's venv — it lives in uv's cache.
Use cases inside a Dockerfile:
# Run formatter / linter as a build-time check
RUN --mount=type=cache,target=/root/.cache/uv \
uvx ruff check src/
# Run tests
COPY tests ./tests
RUN --mount=type=cache,target=/root/.cache/uv \
uvx pytest -q tests/
# Generate type stubs
RUN --mount=type=cache,target=/root/.cache/uv \
uvx mypy --install-types --non-interactive src/
# Pin tool version explicitly
RUN --mount=type=cache,target=/root/.cache/uv \
uvx --from "ruff==0.6.4" ruff check src/
None of these tools end up in the final image because:
uvxinstalls into a separate venv (cache-mounted, ephemeral).- The final stage copies only
/app/.venvand/app/src, not theuvxcache.
This is much cleaner than the legacy:
The pip uninstall doesn't remove the disk bytes from the layer (image is a stack of immutable layers). uvx sidesteps this entirely.
uv run is the project equivalent.
For tools that are dev dependencies of your project (in the dev group of pyproject.toml):
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked # installs dev deps too
RUN uv run pytest
But in a production-image-only build, you don't want dev deps installed. Use uvx for one-off tool runs in test stages.
12. Native Dependencies: psycopg, cryptography, numpy, lxml¶
Python wheels often include C extensions. The wheel format includes prebuilt binaries for specific (Python version, platform, libc) tuples. If a wheel matching your environment exists, install is fast and stable. If not, pip/uv falls back to building from source, which needs compilers.
Common offenders and their fixes:
psycopg2-binary vs psycopg2 vs psycopg¶
psycopg2-binaryships libpq inside the wheel. Easy install, but a duplicated libpq per container (security hygiene problem — when libpq has a CVE, you wait forpsycopg2-binaryto release a new wheel).psycopg2(no-binary) needs system libpq at install time and runtime. Requiresapt-get install libpq-devin build stage andlibpq5in runtime stage. Smaller image, system-managed libpq.psycopg(psycopg3) is the modern replacement;psycopg[binary]is the easy version. New code should use psycopg3.
For most projects: psycopg[binary] is fine. For regulated environments: psycopg linked against system libpq.
cryptography¶
Native wheels exist for glibc and musl on amd64 and arm64. Should "just work" with uv on modern bases.
If you see source builds: you've probably pinned an old version. cryptography>=41 has comprehensive wheels.
numpy, pandas, scipy¶
manylinux wheels are reliable on python:3.12-slim. On Alpine, you need musl wheels (sometimes available) or a source build (slow, needs OpenBLAS/LAPACK dev libraries). Don't put data-science workloads on Alpine.
For ML images, consider:
- Use
python:3.12-slimwith prebuilt wheels. - Skip MKL unless you need it (MKL adds ~500 MB and rarely matters for inference).
- For GPU: use NVIDIA's CUDA base images, install Python + uv on top.
lxml¶
Has glibc and musl wheels for current versions. Older Alpine + older lxml = source build needing libxml2-dev, libxslt-dev, gcc.
Generic pattern for unavoidable source builds¶
If you must build from source, use a multi-stage:
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc g++ libpq-dev libxml2-dev libxslt-dev \
&& rm -rf /var/lib/apt/lists/*
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-install-project
# Runtime: no compilers, only runtime libs
FROM gcr.io/distroless/python3-debian12:nonroot
# Copy runtime shared libraries
COPY --from=builder /usr/lib/x86_64-linux-gnu/libpq.so.5 /usr/lib/x86_64-linux-gnu/
COPY --from=builder /usr/lib/x86_64-linux-gnu/libxml2.so.2 /usr/lib/x86_64-linux-gnu/
COPY --from=builder /app/.venv /app/.venv
COPY src /app/src
WORKDIR /app
USER nonroot
ENTRYPOINT ["python", "-m", "src.main"]
The runtime image gets only the .so files it needs, not the dev packages. Use ldd /app/.venv/lib/python3.12/site-packages/somepackage/_internals.cpython-312-x86_64-linux-gnu.so to discover which libs each native extension needs.
13. Memory: Interpreter Tuning, Allocator Tuning, Per-Worker Sizing¶
Python's memory characteristics in containers:
- Resident set size (RSS) is what the kernel counts toward cgroup limits.
- Python rarely returns memory to the OS, so RSS grows toward the high-water mark and stays there.
- The default allocator (
pymalloc) has per-thread arenas; multi-threaded apps consume more memory than single-threaded equivalents.
Sizing: how much memory does a Python process need?¶
Rough order-of-magnitude:
- Bare Python 3.12 interpreter: 10–15 MB RSS.
- Plus Django + DRF + a few apps: 50–80 MB.
- Plus FastAPI + SQLAlchemy + uvicorn: 60–90 MB.
- Plus
pandasandnumpyimported: 120–180 MB. - Plus a 10 MB ORM cache warmed up: + actual data size.
Set Kubernetes requests.memory near the post-warmup RSS and limits.memory ~2× that to allow growth. Too-tight limits cause OOMKill on transient spikes (large request bodies, ORM queries returning lots of rows, periodic batch jobs).
PYTHONMALLOC and the allocator¶
Python has multiple memory allocators. The default pymalloc is fast for small allocations. For diagnostic builds, you can use PYTHONMALLOC=malloc to bypass pymalloc and route everything to libc malloc — useful for tracking down memory issues with tools that hook libc malloc (valgrind, address sanitizer). In production, leave it default.
MALLOC_ARENA_MAX for multi-threaded workloads¶
glibc's malloc allocates per-thread arenas. By default, it can use up to 8 × num_cpus arenas. On a 16-core machine, that's 128 arenas per process. Each arena reserves virtual address space and over time can hold significant unused memory.
For containerized Python workloads (especially uvicorn with multiple workers, or any process with many threads):
This caps glibc arenas at 2. RSS savings can be 20–40% on multi-threaded apps. Tradeoff: slightly more lock contention on malloc, rarely measurable for normal Python workloads (the GIL serializes most allocation anyway).
PYTHONHASHSEED and security¶
PYTHONHASHSEED=random (the default in Python 3) randomizes hash seeds per process. Important for security (prevents algorithmic complexity attacks against dicts).
PYTHONHASHSEED=0 disables this. Don't. The performance gain is nil; the security cost is real.
PYTHONUNBUFFERED=1 and logging¶
Without this, stdout/stderr are line-buffered when attached to a TTY, block-buffered when not (pipes, files, container log streams). In a container, your stdout is piped to the container runtime → block-buffered → logs disappear until the buffer is full.
Always set PYTHONUNBUFFERED=1. Costs nothing.
PYTHONOPTIMIZE¶
PYTHONOPTIMIZE=1: removesassertstatements and sets__debug__to False. Small startup speedup, smaller.pycfiles.PYTHONOPTIMIZE=2: also removes docstrings. ~5–10% memory savings on doc-heavy libraries.
If you rely on assertions in production code, don't use this. If you only use asserts in tests, PYTHONOPTIMIZE=1 is a nice tweak.
Forking and copy-on-write with gunicorn --preload¶
If you use gunicorn with --preload, the master process imports all your code once, then forks workers. The workers share memory via copy-on-write (COW) — until they touch a page, which then gets duplicated. For Django/Flask apps, this can save 30–60% RSS across all workers.
The catch: any code that opens connections, file descriptors, or starts threads at import time will share them across workers, which is broken. Defer such initialization to a post_fork hook or worker startup.
14. Cold Start: The Import-Time Tax and How to Pay Less¶
Cold start is dominated by imports. Profile your imports:
The log shows hierarchical time spent in each import. Read it; you'll be surprised what's expensive.
Common offenders (Python 3.12 measurements on a modern Linux box):
import numpy: ~80 ms (imports linalg, fft, polynomial, ma, ctypeslib, etc.).import pandas: ~250 ms (imports numpy + a hundred submodules).import django: ~150 ms (imports settings, ORM, template engine).import requests: ~50 ms (imports urllib3, charset_normalizer, idna, certifi).import boto3: ~300 ms (loads service descriptors for ~300 AWS services).import google.cloud.storage: ~400 ms (similar story).
Reduce imports at startup¶
Lazy import frequent vs rare paths. If only 1% of requests need boto3.client('s3'), defer the import:
def upload_to_s3(blob: bytes, key: str) -> None:
import boto3
s3 = boto3.client("s3")
s3.put_object(...)
Now boto3 is imported once per process (on first call), not at startup. Cold start drops by 300 ms.
Use __getattr__ at the module level (PEP 562) for optional features.
# package/__init__.py
def __getattr__(name):
if name == "HeavyClass":
from .heavy_module import HeavyClass
return HeavyClass
raise AttributeError(name)
from package import HeavyClass works as expected, but import package doesn't pull in heavy_module.
Skip pkg_resources.
pkg_resources (from setuptools) is notoriously slow to import (~200 ms). Any library that does import pkg_resources at module load pays this. The modern replacement is importlib.metadata (stdlib, fast). Audit your deps.
Precompiled .pyc (revisited)¶
Covered in §8 — UV_COMPILE_BYTECODE=1 is a free 300–500 ms cold-start win on import-heavy apps. Always do it.
PYTHONDONTWRITEBYTECODE=1 and the read-only filesystem¶
Once you have precompiled .pycs in the image, you don't want runtime to write more. PYTHONDONTWRITEBYTECODE=1 + readOnlyRootFilesystem: true is the right combo. Bonus: prevents an attacker from writing executable bytecode into the container.
python -S (skip site)¶
Python's site.py runs at startup, scanning site-packages and processing .pth files. For ultra-lean services, python -S -m yourapp skips this. You then have to manually adjust sys.path (uv venvs do this via the activation scripts). Saves ~30 ms. Use it if you've optimized everything else and want the last drop.
Threading initialization¶
import threading doesn't start threads, but import concurrent.futures doesn't either; however, libraries like urllib3 initialize connection pools (with locks) at import. Largely unavoidable; just be aware.
"Warm up" the process before declaring it ready¶
If your app has expensive first-request paths (loading an ML model, opening DB connection pools, JIT compiling a regex), do them at startup, not at first request. Mark readiness only after warm-up completes. Otherwise the first request after pod start is slow, which gets routed to a probe-failing pod, which marks as not-ready, which causes oscillation under autoscaling.
async def startup():
await db.connect()
await load_model()
app.state.ready = True
@app.get("/healthz/ready")
def ready():
return Response(status_code=200 if app.state.ready else 503)
Set Kubernetes readinessProbe to hit /healthz/ready. Pod doesn't get traffic until warm.
Cold start budget¶
For a typical FastAPI service: target < 500 ms from docker run to "first request served." Hitting that:
- ~100 ms image pull (if cached on node; otherwise much more).
- ~100 ms container setup.
- ~200 ms Python interpreter + imports (with precompiled .pyc).
- ~50 ms app startup (DB connection, warm-up).
Above 500 ms, you're losing autoscaling responsiveness. Above 2 seconds, you're losing user experience during scale events.
15. ASGI/WSGI Server Choice: uvicorn, gunicorn, granian, hypercorn¶
For HTTP/web services in containers, the server choice affects memory, CPU, and concurrency model.
| Server | Sync/Async | Multi-process | Use case |
|---|---|---|---|
gunicorn |
WSGI (sync) | Yes (--workers) |
Django, Flask, classic WSGI apps |
uvicorn |
ASGI (async) | Single process (use --workers carefully) |
FastAPI, Starlette, async Django |
gunicorn + uvicorn.workers.UvicornWorker |
ASGI (async) | Yes (gunicorn manages, uvicorn handles) | FastAPI in prod |
granian |
ASGI/WSGI/RSGI (Rust-backed) | Yes | Performance-critical Python web apps |
hypercorn |
ASGI | Yes | HTTP/2, HTTP/3 needs |
daphne |
ASGI | Yes | Django Channels (WebSockets) |
The right default for FastAPI in 2026: gunicorn with uvicorn workers, or granian.
ENTRYPOINT ["gunicorn", \
"-k", "uvicorn.workers.UvicornWorker", \
"--workers", "4", \
"--worker-tmp-dir", "/dev/shm", \
"--bind", "0.0.0.0:8000", \
"--access-logfile", "-", \
"--preload", \
"app.main:app"]
Notes:
--worker-tmp-dir /dev/shm: gunicorn writes worker heartbeat files. By default/tmp(disk);/dev/shm(tmpfs) is faster and avoids disk I/O. Distroless doesn't have/tmpwritable by default — mount one or use this.--workers 4: usually2 * CPU + 1but in containers cap based on memory and request profile. Async workers can handle hundreds of concurrent requests each, so don't over-provision workers.--preload: master imports app, forks workers. COW memory savings (see §13).--access-logfile -: log to stdout.
For pure performance, granian is faster than gunicorn+uvicorn:
ENTRYPOINT ["granian", "--interface", "asgi", "--workers", "4", "--host", "0.0.0.0", "--port", "8000", "app.main:app"]
Granian is Rust-based, handles HTTP at the C/Rust layer, dispatches into Python only for the application code. ~20–40% lower CPU per request on benchmarks.
Single uvicorn vs gunicorn+uvicorn¶
In Kubernetes, a common pattern is one uvicorn per pod, scale by replica count:
- Simpler than gunicorn+uvicorn.
- Each pod is one process; no preload, no master.
- Scale concurrency horizontally (more pods, fewer workers per pod).
- Memory is predictable per pod.
- Plays well with Kubernetes autoscaling.
This is increasingly the default for async services. Use gunicorn+uvicorn when you want process-level isolation within a pod (one bad worker crashes, others survive).
Sync Django¶
Django (even with async views in 4+) is mostly sync. Use gunicorn with sync workers:
gthread (gunicorn's threaded sync worker) handles I/O-bound DB queries with threads. Pure sync workers block on the DB.
16. __pycache__ Strategies and Read-Only Filesystems¶
Two consistent strategies for __pycache__:
Strategy A: Precompile at build, ban writes at runtime.
This is the recommended pattern. Bytecode is precompiled, sits in the image, runtime never writes. Read-only FS prevents an attacker from writing a webshell. Cold start is fast.
Strategy B: Allow writes, write at first import.
volumeMounts:
- name: pycache
mountPath: /app/.venv/lib/python3.12/site-packages
volumes:
- name: pycache
emptyDir: {}
Not recommended in production. The first request pays the compilation cost. Subsequent requests (same pod) are fast. New pods start cold.
Strategy A wins. The "compile at runtime" strategy was a workaround for build pipelines that couldn't precompile; with uv, you can.
If you must use Strategy B (e.g., dynamic plugins loaded at runtime), at least don't ship a read-only filesystem.
17. Reproducibility with uv lock¶
uv.lock is hashed and deterministic. Two builds from the same lockfile produce identical site-packages directories.
For bit-identical image digests (advanced, see ch 39 §13):
ENV SOURCE_DATE_EPOCH=1700000000 \
PYTHONDONTWRITEBYTECODE=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
# Compile bytecode with deterministic mtime
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-install-project
RUN find /app/.venv -name "*.pyc" -exec touch -t 202311140000 {} +
SOURCE_DATE_EPOCH is the SDE convention for reproducible builds; many tools honor it. For Python .pyc files, the mtime is part of the file (used to detect stale bytecode). Forcing a consistent mtime makes .pyc outputs identical across builds.
Most teams don't need bit-identical images. They need dependency-identical images, which uv.lock + --locked gives you for free.
Lockfile hygiene¶
- Commit
uv.lockto git. Always. uv.lockshould be in your.dockerignoreexclude (you do want it copied into the build).- Run
uv lockperiodically (e.g., monthly via a renovation bot) to absorb security fixes. - In CI, fail fast if
uv.lockis stale:
This catches the bug where a contributor added a dependency to pyproject.toml but forgot to uv lock.
18. CI/CD Patterns for uv Builds¶
GitHub Actions¶
name: Build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
permissions: { contents: read, packages: write }
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.sha }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
platforms: linux/amd64,linux/arm64
cache-to type=gha,mode=max exports all stages' caches; cache-from type=gha restores them. Combined with uv's own cache mount, builds drop from minutes to ~30 seconds on cached deps.
Testing in the same Dockerfile¶
FROM ... AS test
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked
COPY tests ./tests
RUN uv run pytest -q --tb=short
FROM ... AS build
# ... production stages
docker buildx build --target test . # CI: tests run
docker buildx build --target build . # Deploy: tests are skipped (separate target)
--target test builds up to the test stage and stops. --target build skips the test stage entirely. CI runs both: tests first (gate), then build (the artifact).
A more concise variant — tests inside the build stage:
FROM ... AS builder
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked
COPY src ./src
COPY tests ./tests
RUN --mount=type=cache,target=/root/.cache/uv \
uv run pytest -q
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev # remove dev deps for the final venv
Tests run as part of the build; failures abort the build. Dev deps are uninstalled afterward.
19. The Gold-Standard Dockerfile, Fully Annotated¶
Putting everything together for a typical FastAPI service:
# syntax=docker/dockerfile:1.7
# ============================================================================
# Stage 1: Builder
# - Astral's uv-prebuilt image (Debian slim + Python 3.12 + uv).
# - Installs deps with cache mount, compiles bytecode at install time.
# - Strips tests, docs, locales from site-packages.
# ============================================================================
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Dependency layer
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-install-project
# Project layer
COPY src ./src
COPY README.md ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# Slim the venv
RUN find /app/.venv -type d -name tests -prune -exec rm -rf {} + \
&& find /app/.venv -type d -name "*.dist-info" -exec sh -c \
'for d; do rm -f "$d/RECORD" "$d/INSTALLER" "$d/REQUESTED"; done' _ {} \; \
&& find /app/.venv -name "*.so" -exec strip --strip-unneeded {} + 2>/dev/null || true \
&& find /app/.venv -path "*/locale/*" \( -name "*.mo" -o -name "*.po" \) -delete
# ============================================================================
# Stage 2: Test (optional, run via --target test)
# ============================================================================
FROM builder AS test
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked # bring in dev deps
COPY tests ./tests
RUN uv run pytest -q tests/
# ============================================================================
# Stage 3: Runtime
# - Distroless Python, nonroot.
# - Only the built venv + source.
# - No shell, no package manager.
# ============================================================================
FROM gcr.io/distroless/python3-debian12:nonroot AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONOPTIMIZE=1 \
PYTHONHASHSEED=random \
MALLOC_ARENA_MAX=2 \
PATH="/app/.venv/bin:${PATH}"
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /app/.venv /app/.venv
COPY --from=builder --chown=nonroot:nonroot /app/src /app/src
USER nonroot
EXPOSE 8000
# Bind to 0.0.0.0 inside the pod; service maps the port.
ENTRYPOINT ["python", "-m", "uvicorn", "src.main:app", \
"--host", "0.0.0.0", "--port", "8000", \
"--log-config", "/app/src/log-config.json", \
"--no-access-log"]
Annotations:
- Cache mounts on the global
uvcache mean repeated builds reuse downloaded wheels. --no-install-projectin the first sync gives a clean dependency layer; project install in the second sync is fast and re-runs only when source changes.--lockedensuresuv.lockis the source of truth (build fails if it's stale).- Strip tests/dist-info/locales trims ~30–80 MB.
strip --strip-unneededon.sofiles removes debug info.- Distroless nonroot runtime; no shell, no pip, no curl. Smallest reasonable surface.
MALLOC_ARENA_MAX=2for memory hygiene.PYTHONOPTIMIZE=1drops asserts, small startup win.PYTHONHASHSEED=randomexplicit (defensive, also the default).- Single uvicorn process per pod, scale via replicas.
--no-access-logfor low CPU overhead; ship access logs from a sidecar if needed.
Expected result for a FastAPI service with ~30 deps:
- Image size: 70–90 MB compressed.
- Cold start (from
docker runto first response): ~300–500 ms. - RSS under idle: ~80 MB.
- RSS under load: ~150–200 MB.
Compare to the "naive" version (python:3.12 + pip install):
- Image size: ~600–800 MB.
- Cold start: ~1.5–2.5 s.
- RSS under idle: ~120 MB.
- RSS under load: ~200–300 MB.
4–10× smaller image, 3–5× faster cold start, 20–30% lower RSS. Same code.
20. Measuring: How to Tell If You Actually Improved Anything¶
Don't optimize without measurement. The relevant numbers:
Image size¶
docker images myapp:latest --format "{{.Size}}"
docker inspect myapp:latest | jq '.[0].Size' # uncompressed
docker image history myapp:latest
dive myapp:latest # layer-by-layer with score
For a quick check that you're not adding bloat: dive shows "wasted space" — files added and later removed, which still occupy bytes.
Cold start¶
Roughly captures import time. For real cold start, time from container start to first 200 OK from the readiness probe:
# In a test cluster
START=$(date +%s.%N)
kubectl run test --image=myapp:latest --restart=Never -- python -m uvicorn src.main:app
kubectl wait --for=condition=ready pod/test --timeout=60s
END=$(date +%s.%N)
echo "Cold start: $(echo "$END - $START" | bc) seconds"
Memory¶
# Inside the pod
cat /sys/fs/cgroup/memory.current # cgroup v2
cat /sys/fs/cgroup/memory/memory.usage_in_bytes # cgroup v1
# From Python
import resource; resource.getrusage(resource.RUSAGE_SELF).ru_maxrss # in KB
Memory profiling tools: memray, tracemalloc, py-spy. memray is the modern choice — accurate, low overhead, produces good flame graphs.
Import time¶
python -X importtime -c "import src.main" 2> imports.log
# Sort by cumulative time:
awk -F'|' '{print $2, $4}' imports.log | sort -n
Anything above 50 ms per import deserves investigation.
Continuous tracking¶
In production:
- Image size in CI: fail the build if size grows >20% week-over-week.
- Cold start in observability: tag pod start events with a histogram bucket, alert on regressions.
- RSS as a standard cluster metric: alert when p99 approaches memory limit.
Don't trust "I made it smaller" without numbers. Don't trust "it's faster now" without a probe time histogram.
21. TL;DR¶
- Use
uv(not pip, not poetry) for Python dependency management in containers. 10–100× faster, deterministic lockfile, BuildKit-friendly cache. - Use
uvxfor one-shot CLI tools inside the build (ruff, pytest, mypy) — they don't pollute the image. - Multi-stage build with
python-bookworm-slim(orastral-sh/uv) as builder, distroless as runtime. 70–100 MB images are routine. - Cache mounts on
/root/.cache/uvwithUV_LINK_MODE=copy. Required for correctness; gives most of the speedup. UV_COMPILE_BYTECODE=1+PYTHONDONTWRITEBYTECODE=1+readOnlyRootFilesystem: true— precompile at build, ban writes at runtime, ~300–500 ms cold start savings.- Strip tests, locales,
.dist-infocruft, and debug symbols from native.sos — 30–80 MB image savings. - Avoid Alpine for Python unless you've audited every wheel's musl availability. Use
python:3.12-slim-bookwormbuild, distroless runtime. MALLOC_ARENA_MAX=2in multi-threaded apps for 20–40% RSS reduction.PYTHONUNBUFFERED=1,PYTHONOPTIMIZE=1,PYTHONHASHSEED=random— always.- Lazy-import heavy modules (boto3, pandas, ML libs) from request handlers, not at app startup.
- One uvicorn per pod, scale via replicas for async services; gunicorn+uvicorn workers with
--preloadfor COW memory savings if you want multi-process per pod. - Healthchecks via tiny Go probe or
python -c "import urllib.request,sys; urllib.request.urlopen(...)"— no curl in distroless. - Commit
uv.lock. Use--lockedin CI. Fail builds if lockfile is stale. - Measure before claiming improvement. Image size with
dive, cold start by timing pod-ready, memory bymemrayor cgroup files.
The Python-in-Docker default in 2026 is uv + distroless + multi-stage + bytecode precompilation. Anything else is leaving 50–90% of the performance and 50–80% of the image size on the table.