453 lines
14 KiB
Markdown
453 lines
14 KiB
Markdown
---
|
|
name: fastapi-uv-docker
|
|
description: 'Audit and migrate an existing Python project to best practices for a cloud-native ASGI FastAPI app managed with uv and run with uvicorn in Docker. Use when: conforming a project to production standards, setting up src layout, configuring pyproject.toml, writing multi-stage Dockerfiles, wiring lifespan and settings, adding health endpoints, enforcing non-root container user, migrating from requirements.txt to uv.'
|
|
argument-hint: 'What is the current state of the project (bare Python, requirements.txt, pip, etc.)?'
|
|
x-personal-mcp:
|
|
id: fastapi-uv-docker
|
|
version: 1.0.0
|
|
tags:
|
|
- fastapi
|
|
- uv
|
|
- docker
|
|
capabilities:
|
|
- resource://skills/fastapi-uv-docker/document
|
|
depends_on: []
|
|
references:
|
|
fastapi-best-practices:
|
|
path: references/fastapi-best-practices.md
|
|
mime_type: text/markdown
|
|
title: FastAPI Best Practices
|
|
uv-project-layout:
|
|
path: references/uv-project-layout.md
|
|
mime_type: text/markdown
|
|
title: uv Project Layout
|
|
uvicorn-settings:
|
|
path: references/uvicorn-settings.md
|
|
mime_type: text/markdown
|
|
title: Uvicorn Settings
|
|
docker-cloud-native:
|
|
path: references/docker-cloud-native.md
|
|
mime_type: text/markdown
|
|
title: Docker Cloud Native
|
|
---
|
|
|
|
# FastAPI Project Best Practices
|
|
|
|
Bring an existing Python project into full conformance with cloud-native best practices: **FastAPI + uv + uvicorn + Docker**. This skill audits the current state, produces a gap list, and walks through each conformance area in priority order.
|
|
|
|
## When to Use
|
|
|
|
- Migrating an existing app from `pip`/`requirements.txt` to `uv`
|
|
- Conforming a FastAPI app to src-layout and app-factory structure
|
|
- Writing a production-grade Dockerfile with multi-stage builds
|
|
- Wiring Pydantic settings from environment variables
|
|
- Adding health endpoints and graceful lifespan shutdown
|
|
- Enforcing non-root container user and proper signal handling
|
|
- Setting up `docker-compose.yml` for local dev and CI
|
|
|
|
## Progressive Loading References
|
|
|
|
Load these references only when needed:
|
|
|
|
- FastAPI patterns and app structure: [FastAPI best practices](./references/fastapi-best-practices.md)
|
|
- uv project layout and dependency management: [uv project layout](./references/uv-project-layout.md)
|
|
- uvicorn CLI settings reference: [uvicorn settings](./references/uvicorn-settings.md)
|
|
- Docker and cloud-native patterns: [Docker cloud-native patterns](./references/docker-cloud-native.md)
|
|
|
|
---
|
|
|
|
## Procedure
|
|
|
|
### Step 0: Audit the Project
|
|
|
|
Before making changes, map the current state across six areas. Produce a short gap list for each.
|
|
|
|
| Area | Check |
|
|
|------|-------|
|
|
| **Project manager** | Is `uv` used? Is `pyproject.toml` present? Is `uv.lock` committed? |
|
|
| **Package layout** | Is a `src/` layout used? Is the package installable? |
|
|
| **App structure** | Is `create_app()` factory used? Is lifespan wired? Are routers registered via `APIRouter`? |
|
|
| **Configuration** | Are settings loaded from env via Pydantic `BaseSettings`? Are secrets out of code? |
|
|
| **Container** | Is there a `Dockerfile`? Multi-stage? Non-root user? `.dockerignore` present? |
|
|
| **Cloud-native** | Is there a `/healthz` endpoint? Graceful shutdown? Structured logs? |
|
|
|
|
Load the [FastAPI best practices reference](./references/fastapi-best-practices.md) for structure rules.
|
|
Load the [uv project layout reference](./references/uv-project-layout.md) for uv migration rules.
|
|
Load the [uvicorn settings reference](./references/uvicorn-settings.md) for uvicorn CLI reference.
|
|
|
|
Completion check: You can name every gap before touching any file.
|
|
|
|
---
|
|
|
|
### Step 1: Migrate to uv and Establish pyproject.toml
|
|
|
|
**If the project uses `requirements.txt` / `setup.py` / `setup.cfg` / `pip`:**
|
|
|
|
1. Initialize uv if not present: `uv init` (or `uv init --lib` for importable package).
|
|
2. Import existing requirements: `uv add -r requirements.txt`.
|
|
3. Remove `requirements.txt`, `setup.py`, `setup.cfg`, and any `Pipfile`.
|
|
4. Ensure `.python-version` is committed with the target Python version.
|
|
5. Commit `uv.lock` — it is the source of truth for reproducible installs.
|
|
6. Add `.venv` to `.gitignore` and `.dockerignore`.
|
|
|
|
**Canonical `pyproject.toml` shape:**
|
|
|
|
```toml
|
|
[project]
|
|
name = "my-app"
|
|
version = "0.1.0"
|
|
requires-python = ">=3.12"
|
|
dependencies = [
|
|
"fastapi[standard]>=0.115",
|
|
"uvicorn[standard]>=0.34",
|
|
"pydantic-settings>=2.0",
|
|
]
|
|
|
|
[project.scripts]
|
|
my-app = "my_app.main:main"
|
|
|
|
[build-system]
|
|
requires = ["hatchling"]
|
|
build-backend = "hatchling.build"
|
|
|
|
[tool.uv]
|
|
dev-dependencies = [
|
|
"pytest>=8",
|
|
"httpx>=0.27",
|
|
"pytest-asyncio>=0.24",
|
|
]
|
|
|
|
[tool.hatch.build.targets.wheel]
|
|
packages = ["src/my_app"]
|
|
```
|
|
|
|
**Commands:**
|
|
|
|
```bash
|
|
uv add fastapi[standard] uvicorn[standard] pydantic-settings
|
|
uv add --dev pytest httpx pytest-asyncio
|
|
uv sync # creates .venv and installs all deps
|
|
uv run pytest # run tests via uv
|
|
```
|
|
|
|
Completion check: `uv run python -c "import my_app"` succeeds.
|
|
|
|
---
|
|
|
|
### Step 2: Establish src Layout
|
|
|
|
Move the package under `src/` to prevent import confusion between installed and local code.
|
|
|
|
```
|
|
.
|
|
├── pyproject.toml
|
|
├── uv.lock
|
|
├── .python-version
|
|
├── .env.example
|
|
├── README.md
|
|
├── src/
|
|
│ └── my_app/
|
|
│ ├── __init__.py
|
|
│ ├── main.py # create_app() + entry point
|
|
│ ├── config.py # Pydantic BaseSettings
|
|
│ ├── lifespan.py # @asynccontextmanager lifespan
|
|
│ ├── api/
|
|
│ │ ├── __init__.py
|
|
│ │ ├── health.py # GET /healthz
|
|
│ │ └── v1/
|
|
│ │ └── __init__.py
|
|
│ └── services/
|
|
│ └── __init__.py
|
|
├── tests/
|
|
│ ├── conftest.py
|
|
│ └── test_health.py
|
|
├── Dockerfile
|
|
├── .dockerignore
|
|
└── docker-compose.yml
|
|
```
|
|
|
|
Completion check: `uv run python -m my_app` starts the server.
|
|
|
|
---
|
|
|
|
### Step 3: Wire the FastAPI App Factory
|
|
|
|
Load the [FastAPI best practices reference](./references/fastapi-best-practices.md) for the full patterns. Key rules:
|
|
|
|
**`src/my_app/main.py`:**
|
|
|
|
```python
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import FastAPI
|
|
from my_app.api.health import router as health_router
|
|
from my_app.config import Settings
|
|
|
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
if settings is None:
|
|
settings = Settings()
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# startup: open DB pools, load models, etc.
|
|
app.state.settings = settings
|
|
yield
|
|
# shutdown: close connections
|
|
|
|
app = FastAPI(
|
|
title="My App",
|
|
lifespan=lifespan,
|
|
docs_url="/docs" if settings.debug else None,
|
|
redoc_url=None,
|
|
)
|
|
app.include_router(health_router)
|
|
return app
|
|
|
|
app = create_app()
|
|
```
|
|
|
|
**`src/my_app/api/health.py`:**
|
|
|
|
```python
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import JSONResponse
|
|
|
|
router = APIRouter(tags=["ops"])
|
|
|
|
@router.get("/healthz", include_in_schema=False)
|
|
async def health() -> JSONResponse:
|
|
return JSONResponse({"status": "ok"})
|
|
```
|
|
|
|
**`src/my_app/config.py`:**
|
|
|
|
```python
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
|
|
debug: bool = False
|
|
host: str = "0.0.0.0"
|
|
port: int = 8000
|
|
log_level: str = "info"
|
|
```
|
|
|
|
Completion check: `uv run uvicorn my_app.main:app --reload` starts with no import errors.
|
|
|
|
---
|
|
|
|
### Step 4: uvicorn Production Configuration
|
|
|
|
Load the [uvicorn settings reference](./references/uvicorn-settings.md) for the full settings reference.
|
|
|
|
**Never** configure uvicorn inside application code. Pass all settings via CLI or environment variables (`UVICORN_*` prefix).
|
|
|
|
```bash
|
|
# Development — reload only; never in production
|
|
uv run uvicorn my_app.main:app \
|
|
--reload \
|
|
--host 127.0.0.1 \
|
|
--port 8000 \
|
|
--log-level debug
|
|
|
|
# Production — single process (orchestrator handles replication)
|
|
uv run uvicorn my_app.main:app \
|
|
--host 0.0.0.0 \
|
|
--port 8000 \
|
|
--workers 1 \
|
|
--loop auto \
|
|
--http auto \
|
|
--log-level info \
|
|
--proxy-headers \
|
|
--forwarded-allow-ips '*' \
|
|
--timeout-graceful-shutdown 30
|
|
```
|
|
|
|
**Key flags for production:**
|
|
|
|
| Flag | Value | Reason |
|
|
|------|-------|--------|
|
|
| `--host 0.0.0.0` | Required in containers | Bind to all interfaces, not just loopback |
|
|
| `--workers 1` | Kubernetes/Cloud Run | Orchestrator replicates containers |
|
|
| `--loop auto` | Default | Uses `uvloop` when available (install `uvicorn[standard]`) |
|
|
| `--http auto` | Default | Uses `httptools` when available |
|
|
| `--proxy-headers` | Behind any proxy | Trusts `X-Forwarded-For`, `X-Forwarded-Proto` |
|
|
| `--forwarded-allow-ips '*'` | Container/K8s | Trusts proxy headers from all IPs (safe when inside a trusted network) |
|
|
| `--timeout-graceful-shutdown 30` | Prod | Seconds to wait before force-closing requests on shutdown |
|
|
| `--no-access-log` | High-traffic prod | Disable per-request logs if using structured app-level logging |
|
|
|
|
**Environment variable equivalents** (useful in `docker-compose.yml` / K8s manifests):
|
|
|
|
```bash
|
|
UVICORN_HOST=0.0.0.0
|
|
UVICORN_PORT=8000
|
|
UVICORN_WORKERS=1
|
|
UVICORN_LOG_LEVEL=info
|
|
UVICORN_PROXY_HEADERS=true
|
|
UVICORN_FORWARDED_ALLOW_IPS=*
|
|
```
|
|
|
|
**`--reload` and `--workers` are mutually exclusive** — never combine them.
|
|
|
|
**When to use `--workers > 1`:** Only for Docker Compose on a single host where orchestrator-level replication is not available. For Kubernetes / Cloud Run / Fargate: always `--workers 1` and scale via replicas — this gives predictable per-container memory and cleaner crash isolation.
|
|
|
|
Completion check: `curl http://localhost:8000/healthz` returns `{"status":"ok"}`.
|
|
|
|
---
|
|
|
|
### Step 5: Write the Dockerfile
|
|
|
|
Load the [Docker cloud-native patterns reference](./references/docker-cloud-native.md) for the full template and cloud-native rules. Key requirements:
|
|
|
|
- Multi-stage build: `builder` stage installs deps; `runtime` stage is slim.
|
|
- Pin uv version (copy from official image, not `latest`).
|
|
- Use `uv sync --locked --no-editable` to install into `.venv`.
|
|
- Set `ENV PATH="/app/.venv/bin:$PATH"` — do **not** use `uv run` in production `CMD`.
|
|
- Run as non-root user.
|
|
- Use `CMD` exec form, never shell form.
|
|
- Add `HEALTHCHECK`.
|
|
|
|
**Canonical Dockerfile:**
|
|
|
|
```dockerfile
|
|
# syntax=docker/dockerfile:1
|
|
|
|
# ── builder ──────────────────────────────────────────────────────────────────
|
|
FROM python:3.12-slim-bookworm AS builder
|
|
|
|
# Pin uv version for reproducibility
|
|
COPY --from=ghcr.io/astral-sh/uv:0.5.27 /uv /uvx /bin/
|
|
|
|
WORKDIR /app
|
|
|
|
# Install dependencies first (cache layer)
|
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
|
uv sync --locked --no-install-project --no-editable
|
|
|
|
# Copy source and install project
|
|
COPY . /app
|
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
uv sync --locked --no-editable
|
|
|
|
# ── runtime ───────────────────────────────────────────────────────────────────
|
|
FROM python:3.12-slim-bookworm AS runtime
|
|
|
|
# Non-root user
|
|
RUN groupadd --system --gid 1001 appgroup && \
|
|
useradd --system --uid 1001 --gid appgroup --no-log-init appuser
|
|
|
|
WORKDIR /app
|
|
|
|
# Copy only the virtual environment (not source code)
|
|
COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
|
|
|
|
# Copy application source
|
|
COPY --chown=appuser:appgroup src/ /app/src/
|
|
|
|
ENV PATH="/app/.venv/bin:$PATH" \
|
|
PYTHONUNBUFFERED=1 \
|
|
PYTHONDONTWRITEBYTECODE=1
|
|
|
|
USER appuser
|
|
|
|
EXPOSE 8000
|
|
|
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"
|
|
|
|
# Exec form — required for graceful shutdown / lifespan events
|
|
CMD ["uvicorn", "my_app.main:app", \
|
|
"--host", "0.0.0.0", \
|
|
"--port", "8000", \
|
|
"--workers", "1", \
|
|
"--proxy-headers"]
|
|
```
|
|
|
|
Completion check: `docker build -t my-app . && docker run --rm -p 8000:8000 my-app` serves `/healthz`.
|
|
|
|
---
|
|
|
|
### Step 6: Write .dockerignore and docker-compose.yml
|
|
|
|
**`.dockerignore`:**
|
|
|
|
```
|
|
.venv/
|
|
.git/
|
|
.gitignore
|
|
.env
|
|
*.pyc
|
|
__pycache__/
|
|
.pytest_cache/
|
|
dist/
|
|
*.egg-info/
|
|
README.md
|
|
```
|
|
|
|
**`docker-compose.yml` (local dev):**
|
|
|
|
```yaml
|
|
services:
|
|
app:
|
|
build: .
|
|
ports:
|
|
- "8000:8000"
|
|
environment:
|
|
- DEBUG=true
|
|
- LOG_LEVEL=debug
|
|
env_file:
|
|
- .env
|
|
develop:
|
|
watch:
|
|
- action: sync
|
|
path: ./src
|
|
target: /app/src
|
|
- action: rebuild
|
|
path: ./pyproject.toml
|
|
- action: rebuild
|
|
path: ./uv.lock
|
|
```
|
|
|
|
Completion check: `docker compose up` starts the app; `docker compose watch` enables hot reload.
|
|
|
|
---
|
|
|
|
### Step 7: Cloud-Native Checklist
|
|
|
|
Run this final checklist before shipping:
|
|
|
|
- [ ] `GET /healthz` returns 200 with no auth required
|
|
- [ ] App reads all config from environment (`Settings` with no hardcoded values)
|
|
- [ ] `.env` is in `.gitignore` and `.dockerignore`; `.env.example` is committed
|
|
- [ ] `uv.lock` is committed
|
|
- [ ] `.python-version` is committed
|
|
- [ ] Dockerfile uses non-root user (`USER appuser`)
|
|
- [ ] `CMD` uses exec form (list, not string)
|
|
- [ ] `--proxy-headers` is set in the uvicorn `CMD` if behind a proxy
|
|
- [ ] `PYTHONUNBUFFERED=1` is set (logs flush immediately)
|
|
- [ ] `EXPOSE` declares the correct port
|
|
- [ ] `HEALTHCHECK` is defined
|
|
- [ ] Multi-stage build — final image contains no build tools or uv binary
|
|
- [ ] `.venv` is in `.dockerignore`
|
|
- [ ] No secrets hardcoded in `Dockerfile`, `pyproject.toml`, or source
|
|
- [ ] `uv sync --locked` in CI (fail if lock is stale)
|
|
- [ ] Tests pass via `uv run pytest`
|
|
|
|
---
|
|
|
|
## Anti-patterns to Fix
|
|
|
|
| Anti-pattern | Correct approach |
|
|
|---|---|
|
|
| `requirements.txt` | Use `uv` with `pyproject.toml` and `uv.lock` |
|
|
| `pip install` in Dockerfile | `uv sync --locked` |
|
|
| `ENV SECRET_KEY=abc123` in Dockerfile | Inject at runtime via env; never bake secrets |
|
|
| Shell form `CMD uvicorn ...` | Exec form `CMD ["uvicorn", ...]` |
|
|
| `FROM tiangolo/uvicorn-gunicorn-fastapi` | Build from scratch with `python:3.x-slim` |
|
|
| Multiple workers inside K8s container | `--workers 1`; scale via replicas |
|
|
| Running as root in container | `USER appuser` with explicit UID 1001 |
|
|
| Startup/shutdown in `@app.on_event` | Use `@asynccontextmanager` lifespan |
|
|
| Config loaded from `.env` directly in code | Pydantic `BaseSettings` with `env_file` |
|