Deploy Meridian (Milestone N)¶
Docker (single node)¶
docker pull ghcr.io/imv-in/meridian:0.9.2
docker run --rm -p 8080:8080 \
-v "$(pwd)/config.yaml:/app/config.yaml:ro" \
-e MERIDIAN_CONFIG=/app/config.yaml \
ghcr.io/imv-in/meridian:0.9.2
Non-root image (uid 10001) + HEALTHCHECK on /meridian/status.
Helm¶
helm upgrade --install meridian ./deploy/helm/meridian \
--set image.tag=0.10.0 \
--namespace meridian --create-namespace
- ConfigMap: gateway YAML (
values.config) - Optional Secret:
keys.yamlmounted at/secrets→ setauth.keys_file: /secrets/keys.yaml - PVC: JSONL + cost/budget sqlite under
/var/lib/meridian
Hardened primitives in the chart (all off by default where they touch scheduling):
podDisruptionBudget.enabled— keep a replica through node drainstopologySpreadConstraints— spread replicas across zones/nodesserviceAccount— least-privilege identity; automount off (Meridian needs no K8s API)ingress.enabled(+tls, cert-manager annotation) — see TLS sectiongrafana.enabled— ships the bundled dashboards as a sidecar-watched ConfigMapkeda.enabled— ScaledObject on gateway inflight (see Autoscaling)
Template check (no cluster required):
helm template meridian ./deploy/helm/meridian >/dev/null
TLS (edge)¶
Terminate TLS at nginx/Traefik; proxy to Meridian HTTP. Or let the chart's Ingress do it with cert-manager:
# values override
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: meridian.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: meridian-tls
hosts: [meridian.example.com]
Raw nginx example:
location / {
proxy_pass http://meridian:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off; # streaming SSE
proxy_read_timeout 3600s;
}
Autoscaling¶
Two distinct layers — don't confuse them:
1) Scale the GATEWAY (stateless, CPU/queue-bound)¶
KEDA on the gateway's inflight metric (chart ships it, off by default):
keda:
enabled: true
maxReplicas: 5
prometheusAddress: "http://prometheus-server.prometheus.svc:9090"
query: "sum(meridian_backend_inflight)"
threshold: "50" # inflight requests per replica to scale past
Plain HPA equivalent (no KEDA):
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: meridian
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: meridian
minReplicas: 2
maxReplicas: 5
metrics:
- type: Pods
pods:
metric:
name: meridian_backend_inflight # requires prometheus-adapter
target:
type: AverageValue
averageValue: "50"
2) Scale BACKENDS to zero (GPU pods, idle cost)¶
Gateway marks a backend idle after idle_timeout_min without traffic — it
stops routing there, pauses health checks (a scaled-to-zero pod SHOULD fail
them), and exports meridian_backend_idle{name}=1. Point a KEDA ScaledObject
for each backend Deployment at that gauge:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: vllm-fairytale-scaler
spec:
scaleTargetRef:
name: vllm-fairytale
minReplicaCount: 0 # the point: zero when idle
maxReplicaCount: 1
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus-server.prometheus.svc:9090
query: '1 - meridian_backend_idle{backend="vllm-fairytale"}'
threshold: "0.5" # idle=1 → scale to 0; active=1 → scale to 1
# meridian config.yaml
backends:
- name: vllm-fairytale
url: http://vllm-fairytale:8000
model: my-model
idle_timeout_min: 30 # quiet for 30 min → idle → KEDA scales pod to 0
Wake-up: the first request matching the idle backend's model re-marks it active
(gauge → 0 → KEDA scales up) and routes to it immediately — early attempts may
502 while the engine cold-starts; resilience.max_retries + timeouts.connect
smooth the window. Requests keep flowing to any active backend meanwhile.
Dashboards (Grafana)¶
Pre-built dashboards live in deploy/grafana/:
- Gateway Overview — request rate by backend/model, p50/p95 latency, inflight, error rate, retries, circuits, idle backends
- Governance & Cost — budget rejections/reconciles, PII detections, attributed tokens by model
Import the JSONs directly (choose the Prometheus datasource when prompted), or
deploy via the chart: grafana.enabled=true ships them as a
grafana_dashboard: "1" labeled ConfigMap for the Grafana dashboards sidecar —
tune the label with grafana.sidecarLabel. Every metric they use is emitted by
the gateway's /metrics endpoint out of the box.
Key rotation (no full restart)¶
- Put keys in a separate file:
# /secrets/keys.yaml
keys:
- key: "mrdn_..."
org_id: "acme"
- key: "mrdn_..."
org_id: "ops"
ops_admin: true
# config.yaml
auth:
enabled: true
keys_file: /secrets/keys.yaml
keys: [] # optional inline keys still work; merged with file
- Reload without dropping the process:
- SIGHUP to the Meridian PID, or
POST /meridian/reloadwith anops_adminBearer key
In-flight requests keep their already-resolved identity; new requests use the new index.
Config reload (no restart)¶
POST /meridian/reload (or SIGHUP) re-reads the config file and applies it
atomically — an invalid file is rejected (400) and the running state is
kept. When the gateway was started from an in-memory config (tests), reload
falls back to keys-only and reports "scope": "keys".
Applies without restart: routing strategy + weights, backends (list,
weights, tags, timeouts), tiering rules, auth keys, health thresholds,
resilience knobs, session_affinity, rate_limit store bounds, and
telemetry adapters. Response:
{"reloaded": true, "scope": "full", "keys": 3}
Budget/cost stores, the audit bus, and the JSONL log path keep their running
objects (data continuity); changing those sections logs a "requires restart"
warning. Full field reference: CONFIGURATION.md.
Backup / retention¶
| Path | Content | Suggested retention |
|---|---|---|
logging.jsonl_path |
Request metadata JSONL (no prompts by default) | 7–30 days hot; rotate daily |
budgets.sqlite_path |
Budget counters | Daily backup; period buckets auto-expire old rows on write |
cost.sqlite_path |
Cost ledger (enterprise: sqlite, not memory) | Per finance policy; daily backup + WAL |
Backup with the same cadence as other DB files. Cost ledger uses SQLite WAL — include -wal/-shm or checkpoint first. Full recipes: OPS_RUNBOOK.md.
Sizing (starting point)¶
| Concurrent streams | CPU | RAM |
|---|---|---|
| ~50 | 0.5–1 core | 256–512 Mi |
| ~200 | 1–2 cores | 512 Mi–1 Gi |
| ~500 | 2–4 cores | 1–2 Gi |
Gateway is mostly I/O bound; size backends/GPUs separately. Measure overhead with
python scripts/bench_overhead.py and see LOAD.md.
Response headers (ops)¶
| Header | Meaning |
|---|---|
x-request-id |
Meridian request id |
x-meridian-backend |
Backend that served the request |
x-meridian-tier |
Workload tier (if tiering on) |
x-meridian-session-route |
Affinity route (if session affinity on) |
x-meridian-budget-remaining-tokens |
Tightest token headroom after pre-flight debit |
x-meridian-budget-remaining-requests |
Tightest request headroom after pre-flight debit |
Enterprise cost¶
See ENTERPRISE_COST.md before enabling cost:. Day-2:
OPS_RUNBOOK.md.