| # ββ Stage 1: builder βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Installs all Python deps into a venv that is copied to the slim runtime stage. | |
| FROM python:3.11-slim AS builder | |
| ENV PYTHONDONTWRITEBYTECODE=1 \ | |
| PYTHONUNBUFFERED=1 | |
| WORKDIR /build | |
| # uv is only needed in the builder to export a pip-compatible requirements file. | |
| RUN pip install --no-cache-dir uv | |
| # Copy dependency manifests before source so Docker layer-caches the install | |
| # step when only code changes. | |
| COPY pyproject.toml uv.lock ./ | |
| # Build the venv with a CPU-only torch wheel. | |
| # | |
| # uv.lock pins the CUDA-enabled torch build (+ all nvidia-*/cuda-*/triton | |
| # packages) which adds ~9 GB. We run CPU-only inference, so instead we: | |
| # 1. Export deps without hashes (one package per line, easy to grep). | |
| # 2. Strip torch and every CUDA/GPU package from the generated requirements. | |
| # 3. Install torch from the official CPU wheel index at the SAME version uv | |
| # locked β this avoids the ~7 GB of CUDA libraries entirely. | |
| # 4. Install everything else from the filtered requirements (pip sees torch | |
| # already present at a satisfying version and skips it). | |
| RUN uv export --no-dev --frozen --no-hashes -o /tmp/req-full.txt \ | |
| && grep -vE '^(torch|nvidia-|cuda-|triton)' /tmp/req-full.txt > /tmp/req.txt \ | |
| && TORCH_VER=$(sed -n 's/^torch==\([^ ;]*\).*/\1/p' /tmp/req-full.txt | head -1) \ | |
| && python -m venv /opt/venv \ | |
| && /opt/venv/bin/pip install --no-cache-dir \ | |
| "torch==${TORCH_VER}" \ | |
| --index-url https://download.pytorch.org/whl/cpu \ | |
| && /opt/venv/bin/pip install --no-cache-dir -r /tmp/req.txt \ | |
| # xgboost declares nvidia-nccl-cu12 for distributed GPU training, which is | |
| # never used in CPU-only inference. Strip it to keep the image GPU-free. | |
| && /opt/venv/bin/pip uninstall -y nvidia-nccl-cu12 2>/dev/null || true | |
| # Pre-download the sentence-transformers embedding model used by the RAG layer | |
| # so the runtime container needs no outbound network access at startup, and the | |
| # non-root churn user never needs to write to the HF cache. | |
| RUN HF_HOME=/opt/hf-cache /opt/venv/bin/python -c \ | |
| "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')" | |
| # ββ Stage 2: runtime βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| FROM python:3.11-slim AS runtime | |
| ENV PYTHONDONTWRITEBYTECODE=1 \ | |
| PYTHONUNBUFFERED=1 \ | |
| # Put the venv on PATH so every python/pip call uses it without activation. | |
| PATH="/opt/venv/bin:$PATH" \ | |
| # Make churn/ and api/ importable without a package install. | |
| PYTHONPATH=/app \ | |
| # Point sentence-transformers/HuggingFace Hub at the pre-downloaded cache so | |
| # the non-root churn user never needs write access to download the model. | |
| HF_HOME=/opt/hf-cache | |
| WORKDIR /app | |
| # Non-root user β drop privileges before the server starts. | |
| RUN groupadd -r churn && useradd -r -g churn -d /app churn | |
| # Copy only the venv (no build tools, no uv, no temp files). | |
| COPY --from=builder /opt/venv /opt/venv | |
| # Copy the pre-downloaded HuggingFace model cache and give the churn user | |
| # ownership so sentence-transformers can write lock files on first load. | |
| COPY --from=builder /opt/hf-cache /opt/hf-cache | |
| RUN chown -R churn:churn /opt/hf-cache | |
| # Application code. Copy explicit directories so stray local files (logs/, | |
| # mlruns/, .env) never enter the image even if .dockerignore is incomplete. | |
| COPY churn/ churn/ | |
| COPY api/ api/ | |
| COPY data/playbooks/ data/playbooks/ | |
| COPY data/raw/telco_churn.csv data/raw/telco_churn.csv | |
| # reports/threshold.json is the fallback threshold when the registry tag is absent. | |
| COPY reports/ reports/ | |
| # Create writable runtime directories before dropping to non-root. | |
| # /app is owned by root after WORKDIR; the churn user needs logs/ for the | |
| # SQLite prediction log, .streamlit/ is unused here but harmless to create. | |
| RUN mkdir -p /app/logs && chown -R churn:churn /app | |
| USER churn | |
| EXPOSE 7860 | |
| # Lightweight health probe β no shell required. | |
| HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ | |
| CMD python -c \ | |
| "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')" \ | |
| || exit 1 | |
| # Champion model + MLflow auth come from env at runtime, not baked in. | |
| # Set MLFLOW_TRACKING_URI, MLFLOW_TRACKING_USERNAME, MLFLOW_TRACKING_PASSWORD. | |
| CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "7860"] | |