Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.git/
.venv/
__pycache__/
.pytest_cache/
*.py[cod]
.DS_Store
.env
bench/results/
training/runs/
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
26 changes: 26 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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 .
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions gateway/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
67 changes: 67 additions & 0 deletions scripts/smoke_check.py
Original file line number Diff line number Diff line change
@@ -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"))
Comment on lines +16 to +18


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()
4 changes: 4 additions & 0 deletions worker/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]