From 40c8457287623c3c444741949a87c171359ff17f Mon Sep 17 00:00:00 2001 From: Prakhar Verma Date: Fri, 8 May 2026 00:36:55 -0400 Subject: [PATCH 1/2] Add deployment-ready runtime files --- .dockerignore | 9 ++++++ .env.example | 7 +++++ Makefile | 26 ++++++++++++++++ README.md | 10 ++++++ docker-compose.prod.yml | 39 ++++++++++++++++++++++++ docs/deployment.md | 44 +++++++++++++++++++++++++++ gateway/Dockerfile | 4 +++ scripts/smoke_check.py | 67 +++++++++++++++++++++++++++++++++++++++++ worker/Dockerfile | 4 +++ 9 files changed, 210 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Makefile create mode 100644 docker-compose.prod.yml create mode 100644 docs/deployment.md create mode 100644 scripts/smoke_check.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2d8e515 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git/ +.venv/ +__pycache__/ +.pytest_cache/ +*.py[cod] +.DS_Store +.env +bench/results/ +training/runs/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6eb2537 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +WORKER_URLS=http://worker-a:9000,http://worker-b:9000 +REDIS_URL=redis://redis:6379/0 +REQUEST_TIMEOUT_SECONDS=1.5 +ROUTER_STRATEGY=load_aware +CACHE_ENABLED=true +SIMULATE_DELAY_MS=0 +FAIL_RATE=0 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..881f2fb --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +.PHONY: test train bench replay compose compose-prod smoke docker-build + +test: + python -m pytest -q + +train: + python training/train_text_model.py + +bench: + python bench/run_scenarios.py + +replay: + python bench/replay_traffic.py --seconds-per-stage 8 --rps 18 + +compose: + docker compose up --build + +compose-prod: + docker compose -f docker-compose.prod.yml up --build + +smoke: + python scripts/smoke_check.py + +docker-build: + docker build -f gateway/Dockerfile -t modelmesh-gateway:local . + docker build -f worker/Dockerfile -t modelmesh-worker:local . diff --git a/README.md b/README.md index a6e6556..c5f2513 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,16 @@ docker compose up --build That starts Redis, one gateway, and two workers. +For a production-style local run: + +```bash +cp .env.example .env +docker compose -f docker-compose.prod.yml up --build +python scripts/smoke_check.py +``` + +Deployment notes are in `docs/deployment.md`. + ## Benchmarks The scenario runner starts local gateway and worker processes, sends load, then writes JSON and markdown results. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..886deb1 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,39 @@ +services: + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--save", "", "--appendonly", "no"] + + worker-a: + build: + context: . + dockerfile: worker/Dockerfile + restart: unless-stopped + environment: + WORKER_ID: worker-a + + worker-b: + build: + context: . + dockerfile: worker/Dockerfile + restart: unless-stopped + environment: + WORKER_ID: worker-b + + gateway: + build: + context: . + dockerfile: gateway/Dockerfile + restart: unless-stopped + environment: + WORKER_URLS: ${WORKER_URLS:-http://worker-a:9000,http://worker-b:9000} + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + REQUEST_TIMEOUT_SECONDS: ${REQUEST_TIMEOUT_SECONDS:-1.5} + ROUTER_STRATEGY: ${ROUTER_STRATEGY:-load_aware} + CACHE_ENABLED: ${CACHE_ENABLED:-true} + ports: + - "${PORT:-8000}:8000" + depends_on: + - redis + - worker-a + - worker-b diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..3f70c32 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,44 @@ +# Deployment notes + +ModelMesh is still easiest to demo locally because the failure controls and traffic replay are designed for a closed lab. The deployable part is the same gateway and worker stack packaged with Docker. + +## Local production compose + +```bash +cp .env.example .env +docker compose -f docker-compose.prod.yml up --build +python scripts/smoke_check.py +``` + +Open: + +```text +http://localhost:8000/dashboard +``` + +## Hosting shape + +For a hosted demo, deploy the services as a private internal mesh: + +| Service | Public | Notes | +| --- | --- | --- | +| gateway | yes | Exposes `/predict`, `/dashboard`, `/metrics`, and worker control proxy | +| worker-a | no | Internal service on port 9000 | +| worker-b | no | Internal service on port 9000 | +| redis | no | Internal cache only | + +Required gateway env: + +```text +WORKER_URLS=http://worker-a:9000,http://worker-b:9000 +REDIS_URL=redis://redis:6379/0 +REQUEST_TIMEOUT_SECONDS=1.5 +ROUTER_STRATEGY=load_aware +CACHE_ENABLED=true +``` + +## What not to overclaim + +This is not a production ML platform yet. It is a compact inference mesh that demonstrates training, serving, caching, routing, live worker controls, failure recovery, and benchmark evidence. + +The next real hosting step is choosing a provider and wiring its private service networking. The Docker setup here is intentionally provider-neutral so the app can go to Fly.io, Render, Railway, a VM, or a Kubernetes cluster without changing the Python code. diff --git a/gateway/Dockerfile b/gateway/Dockerfile index e27b557..f553694 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -1,8 +1,12 @@ FROM python:3.12-slim +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY gateway ./gateway +HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).read()" CMD ["uvicorn", "gateway.app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/scripts/smoke_check.py b/scripts/smoke_check.py new file mode 100644 index 0000000..29b718e --- /dev/null +++ b/scripts/smoke_check.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import argparse +import json +import time +from urllib import request + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--base-url", default="http://localhost:8000") + parser.add_argument("--timeout", type=float, default=12.0) + return parser.parse_args() + + +def get_json(url: str, timeout: float = 2.0) -> dict: + with request.urlopen(url, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def post_json(url: str, payload: dict, timeout: float = 2.0) -> dict: + data = json.dumps(payload).encode("utf-8") + req = request.Request(url, data=data, headers={"content-type": "application/json"}, method="POST") + with request.urlopen(req, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def wait_for_gateway(base_url: str, timeout: float) -> dict: + deadline = time.time() + timeout + last_error: Exception | None = None + while time.time() < deadline: + try: + health = get_json(f"{base_url}/health") + if health.get("status") == "ok": + return health + except Exception as exc: + last_error = exc + time.sleep(0.25) + raise RuntimeError(f"gateway did not become healthy: {last_error}") + + +def main() -> None: + args = parse_args() + base_url = args.base_url.rstrip("/") + health = wait_for_gateway(base_url, args.timeout) + prediction = post_json( + f"{base_url}/predict", + {"text": "fast stable reliable service", "request_id": "smoke-check"}, + ) + metrics = get_json(f"{base_url}/metrics") + workers = get_json(f"{base_url}/workers") + + if prediction["prediction"]["label"] != "healthy": + raise RuntimeError(f"unexpected prediction: {prediction}") + if not workers: + raise RuntimeError("gateway returned no workers") + + print(json.dumps({ + "health": health, + "prediction": prediction, + "metrics": metrics, + "workers": workers, + }, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/worker/Dockerfile b/worker/Dockerfile index 2c7f2c6..9a1dbca 100644 --- a/worker/Dockerfile +++ b/worker/Dockerfile @@ -1,8 +1,12 @@ FROM python:3.12-slim +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY worker ./worker +HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9000/health', timeout=2).read()" CMD ["uvicorn", "worker.app.main:app", "--host", "0.0.0.0", "--port", "9000"] From 53a764cff7a11648b83a7f06dad22df54989e9f1 Mon Sep 17 00:00:00 2001 From: Prakhar Verma Date: Fri, 8 May 2026 00:36:58 -0400 Subject: [PATCH 2/2] Build Docker images in CI --- .github/workflows/test.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ab59a46..d429bf9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,3 +24,14 @@ jobs: - name: Run tests run: python -m pytest -q + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Build gateway image + run: docker build -f gateway/Dockerfile -t modelmesh-gateway:test . + + - name: Build worker image + run: docker build -f worker/Dockerfile -t modelmesh-worker:test .