Docker images
We build and publish three images: router, classifier, and ui. We do
not build cloudflared (the chart consumes the upstream
cloudflare/cloudflared image, pinned to a tag) and we do not build
ScalarLM (upstream publishes per-GPU-architecture images we consume directly).
| Image | Built from | Base | Runs on |
|---|---|---|---|
split-brain-router |
docker/router/ |
python:3.12-slim |
CPU |
split-brain-classifier |
docker/classifier/ |
python:3.12-slim |
CPU |
split-brain-ui |
docker/ui/ |
python:3.12-slim |
CPU |
cloudflare/cloudflared |
upstream (pinned tag) | upstream | CPU |
scalarlm (upstream) |
n/a | per ScalarLM release | GPU |
Conventions
- Multi-stage builds for the Python images: a
builderstage installs dependencies into a venv, aruntimestage copies the venv and source. Keeps the final image small and free of build tools. - Non-root user (
app:app, uid 10001) in every image. The filesystem is read-only except for/tmp. - Pinned dependencies via
uv.lock;uvitself is pinned to an exact version (uv==0.11.15). Lockfiles are committed and the build usesuv sync --frozen --no-dev. - Base image is
python:3.12-slim(tag, not currently digest-pinned).
docker/router/Dockerfile (shape)
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim@sha256:<digest> AS builder
WORKDIR /app
RUN pip install --no-cache-dir uv
COPY router/pyproject.toml router/uv.lock ./
RUN uv sync --frozen --no-dev
COPY router/src ./src
FROM python:3.12-slim@sha256:<digest> AS runtime
RUN groupadd -g 10001 app && useradd -u 10001 -g 10001 -s /sbin/nologin app
WORKDIR /app
COPY --from=builder /app/.venv ./.venv
COPY --from=builder /app/src ./src
ENV PATH=/app/.venv/bin:$PATH \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
USER app
EXPOSE 8080
ENTRYPOINT ["uvicorn", "router.app:app", "--host", "0.0.0.0", "--port", "8080"]
docker/classifier/Dockerfile (shape)
Identical to the router image except:
- The build stage bakes the MiniLM encoder into the image by importing it
once (
SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')intoHF_HOME=/opt/model). The image is larger but cold start is bounded by cluster pull bandwidth, not Hugging Face. - The runtime stage sets
HF_HUB_OFFLINE=1andTRANSFORMERS_OFFLINE=1so the container never reaches out to Hugging Face. (The trained head is not baked — it's loaded from the PVC at/var/split-brain/heads/.)
The ui image mirrors the router image and additionally bundles its
bootstrap_pipeline/ and the sentence-transformers stack (it trains the head
in-process).
There is no cloudflared image in this repo — the chart uses the upstream
cloudflare/cloudflared image pinned to a tag in the subchart values.
ScalarLM image selection
ScalarLM is deployed separately (its own chart, possibly its own cluster);
its image/tag is not selected in this repo. The router only needs
SCALARLM_BASE_URL.
Build process
Images are built with the ./split-brain build CLI (bashly), not a
Makefile:
./split-brain build all --remote --tag v0.0.1 # router + classifier + ui
./split-brain build ui --remote --tag v0.0.1 # one component
--remote rsyncs the working tree to the amd64 build host
(BLACKWELL_HOST, default normal@blackwell), runs
docker build --platform linux/amd64, and pushes — avoiding slow qemu
cross-builds on an arm64 laptop (the cluster is amd64). Without --remote
it uses local docker buildx. See cli.md.
Registry
The registry prefix comes from $REGISTRY (default docker.io/gdiamos); the
build host is logged in to it. The Helm chart references images via
global.image.registry joined with each component's image.repository — see
helm.md.
Layout
docker/
router/Dockerfile
classifier/Dockerfile
ui/Dockerfile
The Python source for each service lives outside docker/ (under top-level
router/, classifier/, ui/) so the build context is the repo root.