Feat: Add production-grade multi-stage container image for API
Two-stage build (uv builder + python:3.12-slim runtime) with non-root user (UID 1001), no dev deps, layer-cache-optimised dep install, and graceful SIGTERM shutdown. Verified by api/tests/build/verify_production_image.sh covering build, health endpoint, non-root, stdout logging, secret-free layers, missing-env-var exit, and dep-layer cache hit. All 102 integration tests still pass; shellcheck clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
138
specs/010-api-prod-dockerfile/quickstart.md
Normal file
138
specs/010-api-prod-dockerfile/quickstart.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Quickstart: Production API Container Image
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker 24+ installed and running on the host
|
||||
- `make` available
|
||||
- A copy of `.env` (or the env vars from `.env.example`) for smoke-testing
|
||||
|
||||
---
|
||||
|
||||
## Build the Production Image
|
||||
|
||||
```sh
|
||||
make build-prod
|
||||
# Equivalent: docker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:latest
|
||||
```
|
||||
|
||||
On a warm cache (deps unchanged), the build should complete in under 60 seconds because the dependency layer is reused.
|
||||
|
||||
---
|
||||
|
||||
## Verify the Production Image (TDD Smoke Test)
|
||||
|
||||
```sh
|
||||
make verify-prod
|
||||
```
|
||||
|
||||
This runs `api/tests/build/verify_production_image.sh`, which:
|
||||
1. Builds the image (fails fast if `Dockerfile.prod` is missing — the **red** TDD state)
|
||||
2. Starts the container with test env vars
|
||||
3. Polls `/api/v1/health` until it returns 200 (or times out after 30s)
|
||||
4. Asserts the API process is running as a non-root user (UID ≠ 0)
|
||||
5. Sends SIGTERM and asserts the container exits with code 0 within 30s
|
||||
6. Asserts `pytest` is NOT importable inside the container (dev deps excluded)
|
||||
|
||||
**Expected output (green)**:
|
||||
```
|
||||
[verify] Building reactbin-api-prod:test ...
|
||||
[verify] Build OK
|
||||
[verify] Starting container ...
|
||||
[verify] Health check passed (GET /api/v1/health → 200)
|
||||
[verify] Process user: 1001 (non-root ✓)
|
||||
[verify] Sending SIGTERM ...
|
||||
[verify] Container exited with code 0 (graceful shutdown ✓)
|
||||
[verify] Dev deps absent ✓
|
||||
[verify] All checks passed.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Story Integration Scenarios
|
||||
|
||||
### US1 — API Runs Reliably in Production
|
||||
|
||||
```sh
|
||||
# Start container with real (or test) env vars
|
||||
docker run --rm -d \
|
||||
--name reactbin-test \
|
||||
-p 8000:8000 \
|
||||
-e JWT_SECRET_KEY=my-secret \
|
||||
-e OWNER_USERNAME=owner \
|
||||
-e OWNER_PASSWORD=changeme \
|
||||
-e DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/db \
|
||||
-e S3_ENDPOINT_URL=http://minio:9000 \
|
||||
-e S3_BUCKET_NAME=reactbin \
|
||||
-e S3_ACCESS_KEY_ID=minioadmin \
|
||||
-e S3_SECRET_ACCESS_KEY=minioadmin \
|
||||
-e S3_REGION=us-east-1 \
|
||||
reactbin-api-prod:latest
|
||||
|
||||
# Check health
|
||||
curl http://localhost:8000/api/v1/health
|
||||
# → {"status":"ok"}
|
||||
|
||||
# Graceful shutdown
|
||||
docker stop reactbin-test # sends SIGTERM
|
||||
docker wait reactbin-test # → exit code 0
|
||||
```
|
||||
|
||||
### US2 — Minimal, Secure Container
|
||||
|
||||
```sh
|
||||
# Verify non-root user
|
||||
docker inspect --format='{{.Config.User}}' reactbin-api-prod:latest
|
||||
# → appuser (or 1001)
|
||||
|
||||
# Verify no dev packages (pytest should not be importable)
|
||||
docker run --rm reactbin-api-prod:latest \
|
||||
/app/.venv/bin/python -c "import pytest" 2>&1
|
||||
# → ModuleNotFoundError: No module named 'pytest'
|
||||
|
||||
# Verify no source control or test files in image
|
||||
docker run --rm reactbin-api-prod:latest ls /app
|
||||
# → app .venv (no tests/, no alembic/, no .git/)
|
||||
```
|
||||
|
||||
### US3 — Fast, Reproducible Builds
|
||||
|
||||
```sh
|
||||
# First build (cold): installs all deps
|
||||
time docker build --no-cache -f api/Dockerfile.prod api/ -t reactbin-api-prod:cold
|
||||
|
||||
# Touch a source file only (no dep change)
|
||||
touch api/app/main.py
|
||||
|
||||
# Second build: dependency layer served from cache
|
||||
time docker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:warm
|
||||
# Expect: warm build < 30s; cold build varies (network-dependent)
|
||||
|
||||
# Confirm same health response from both
|
||||
docker run --rm ... reactbin-api-prod:cold
|
||||
docker run --rm ... reactbin-api-prod:warm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Missing Env Var Behaviour
|
||||
|
||||
```sh
|
||||
docker run --rm \
|
||||
-e JWT_SECRET_KEY=my-secret \
|
||||
# OWNER_USERNAME intentionally omitted
|
||||
reactbin-api-prod:latest
|
||||
# → Container exits non-zero, stderr logs: "field required: owner_username"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Read-Only Filesystem Compatibility
|
||||
|
||||
```sh
|
||||
docker run --rm --read-only \
|
||||
-e JWT_SECRET_KEY=... [other env vars] \
|
||||
reactbin-api-prod:latest &
|
||||
|
||||
curl http://localhost:8000/api/v1/health
|
||||
# → {"status":"ok"}
|
||||
```
|
||||
Reference in New Issue
Block a user