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:
@@ -12,3 +12,6 @@ dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
tests/
|
||||
alembic/
|
||||
alembic.ini
|
||||
|
||||
51
api/Dockerfile.prod
Normal file
51
api/Dockerfile.prod
Normal file
@@ -0,0 +1,51 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ════════════════════════════════════════════════
|
||||
# Build stage: install production deps via uv
|
||||
# ════════════════════════════════════════════════
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=never
|
||||
|
||||
# Layer cache split: deps only (changes rarely)
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
# Layer cache split: source (changes often)
|
||||
COPY app/ ./app/
|
||||
|
||||
# ════════════════════════════════════════════════
|
||||
# Runtime stage: lean image with venv + source
|
||||
# ════════════════════════════════════════════════
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd --system --gid 1001 appgroup \
|
||||
&& useradd --system --uid 1001 --gid 1001 --no-create-home appuser
|
||||
|
||||
COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
|
||||
COPY --chown=appuser:appgroup app/ ./app/
|
||||
|
||||
USER appuser
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/api/v1/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "app.main:app", \
|
||||
"--host", "0.0.0.0", \
|
||||
"--port", "8000", \
|
||||
"--timeout-graceful-shutdown", "30"]
|
||||
0
api/tests/build/.gitkeep
Normal file
0
api/tests/build/.gitkeep
Normal file
119
api/tests/build/verify_production_image.sh
Executable file
119
api/tests/build/verify_production_image.sh
Executable file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
# TDD verification script for api/Dockerfile.prod
|
||||
# Fails (red) if Dockerfile.prod does not exist or any check fails.
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="reactbin-api-prod:verify-$$"
|
||||
IMAGE2="reactbin-api-prod:verify-cache-$$"
|
||||
PG_CONTAINER=""
|
||||
APP_CONTAINER=""
|
||||
|
||||
cleanup() {
|
||||
[ -n "$APP_CONTAINER" ] && docker rm -f "$APP_CONTAINER" 2>/dev/null || true
|
||||
[ -n "$PG_CONTAINER" ] && docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker rmi "$IMAGE" 2>/dev/null || true
|
||||
docker rmi "$IMAGE2" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── US1 check 1: build ────────────────────────────────────────────────────────
|
||||
echo "[verify] Building $IMAGE..."
|
||||
docker build -f api/Dockerfile.prod api/ -t "$IMAGE"
|
||||
echo "[verify] Build OK"
|
||||
|
||||
# ── US1 check 2: start with a throwaway postgres ──────────────────────────────
|
||||
echo "[verify] Starting postgres..."
|
||||
PG_CONTAINER=$(docker run -d \
|
||||
-e POSTGRES_DB=reactbin_verify \
|
||||
-e POSTGRES_USER=verify \
|
||||
-e POSTGRES_PASSWORD=verify \
|
||||
postgres:16-alpine)
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec "$PG_CONTAINER" pg_isready -U verify -q 2>/dev/null; then break; fi
|
||||
sleep 1
|
||||
if [[ $i -eq 30 ]]; then echo "FAIL: postgres did not become ready"; exit 1; fi
|
||||
done
|
||||
|
||||
PG_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_CONTAINER")
|
||||
|
||||
echo "[verify] Starting production container..."
|
||||
APP_CONTAINER=$(docker run -d \
|
||||
-p 18000:8000 \
|
||||
-e JWT_SECRET_KEY=verify-key \
|
||||
-e OWNER_USERNAME=testowner \
|
||||
-e OWNER_PASSWORD=testpassword \
|
||||
-e DATABASE_URL="postgresql+asyncpg://verify:verify@${PG_IP}:5432/reactbin_verify" \
|
||||
-e S3_ENDPOINT_URL=http://noop:9000 \
|
||||
-e S3_BUCKET_NAME=noop \
|
||||
-e S3_ACCESS_KEY_ID=noop \
|
||||
-e S3_SECRET_ACCESS_KEY=noop \
|
||||
-e S3_REGION=us-east-1 \
|
||||
"$IMAGE")
|
||||
|
||||
# ── US1 check 3: health endpoint ──────────────────────────────────────────────
|
||||
echo "[verify] Polling health endpoint..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:18000/api/v1/health > /dev/null; then break; fi
|
||||
sleep 1
|
||||
if [[ $i -eq 30 ]]; then echo "FAIL: health check timed out after 30s"; exit 1; fi
|
||||
done
|
||||
echo "[verify] Health check passed"
|
||||
|
||||
# ── US2 check 1: non-root user ────────────────────────────────────────────────
|
||||
UID_IN_CONTAINER=$(docker exec "$APP_CONTAINER" id -u)
|
||||
if [[ "$UID_IN_CONTAINER" -eq 0 ]]; then
|
||||
echo "FAIL: process running as root (UID 0)"; exit 1
|
||||
fi
|
||||
echo "[verify] Non-root user OK (UID $UID_IN_CONTAINER)"
|
||||
|
||||
# ── C1: stdout/stderr log capture ────────────────────────────────────────────
|
||||
LOGS=$(docker logs "$APP_CONTAINER" 2>&1)
|
||||
if [[ -z "$LOGS" ]]; then
|
||||
echo "FAIL: no output on stdout/stderr"; exit 1
|
||||
fi
|
||||
if ! echo "$LOGS" | grep -qiE "(started server|application startup complete|uvicorn)"; then
|
||||
echo "FAIL: no startup logs found on stdout/stderr"; exit 1
|
||||
fi
|
||||
echo "[verify] Stdout logging OK"
|
||||
|
||||
# ── US1 check 4: SIGTERM → exit 0 ────────────────────────────────────────────
|
||||
docker stop "$APP_CONTAINER" > /dev/null
|
||||
EXIT_CODE=$(docker wait "$APP_CONTAINER")
|
||||
if [[ "$EXIT_CODE" -ne 0 ]]; then
|
||||
echo "FAIL: non-zero exit code $EXIT_CODE after SIGTERM"; exit 1
|
||||
fi
|
||||
echo "[verify] Graceful shutdown OK (exit $EXIT_CODE)"
|
||||
|
||||
# ── US2 check 2: dev deps absent ─────────────────────────────────────────────
|
||||
if docker run --rm "$IMAGE" /app/.venv/bin/python -c "import pytest" 2>/dev/null; then
|
||||
echo "FAIL: pytest importable in production image (dev deps present)"; exit 1
|
||||
fi
|
||||
echo "[verify] Dev deps absent OK"
|
||||
|
||||
# ── C2: no hardcoded secrets in image layers ─────────────────────────────────
|
||||
if docker history --no-trunc "$IMAGE" 2>&1 | grep -qiE "(password|secret_key|api_key|token)"; then
|
||||
echo "FAIL: potential secret found in image history"; exit 1
|
||||
fi
|
||||
echo "[verify] No secrets in image layers OK"
|
||||
|
||||
# ── C3: missing env var → non-zero exit ──────────────────────────────────────
|
||||
set +e
|
||||
docker run --rm -e JWT_SECRET_KEY=verify-key "$IMAGE" 2>/dev/null
|
||||
MISSING_ENV_EXIT=$?
|
||||
set -e
|
||||
if [[ "$MISSING_ENV_EXIT" -eq 0 ]]; then
|
||||
echo "FAIL: container exited 0 despite missing OWNER_USERNAME"; exit 1
|
||||
fi
|
||||
echo "[verify] Missing-env-var exit check OK (exit $MISSING_ENV_EXIT)"
|
||||
|
||||
# ── US3: dep layer cached on source-only rebuild ──────────────────────────────
|
||||
echo "[verify] Testing cache hit on source-only rebuild..."
|
||||
touch api/app/main.py
|
||||
BUILD2_OUTPUT=$(docker build --progress=plain -f api/Dockerfile.prod api/ -t "$IMAGE2" 2>&1)
|
||||
if ! echo "$BUILD2_OUTPUT" | grep -q "CACHED"; then
|
||||
echo "FAIL: dependency layer not reused on source-only rebuild"; exit 1
|
||||
fi
|
||||
echo "[verify] Dep layer cache hit confirmed (US3 OK)"
|
||||
|
||||
echo "[verify] All checks passed (US1 + US2 + US3)."
|
||||
Reference in New Issue
Block a user