poimenandrock 369d51a5c2
CI / CI (push) Successful in 41m20s
feat(proxy): engine-wide LLM admission control via vLLM gauges (#65)
Closes #64

Replaces the per-pod concurrency semaphore as the effective cap with admission gated on the vLLM engine's own scheduler gauges.

## Why

`internal/proxy/proxy.go:119` built a semaphore of 4 slots *inside each gateway process*, and `api-gateway` runs 3 replicas, so the advertised cap was 12 while the engine's `--max-num-seqs` is 4. Nothing coordinated replicas, and the mismatch was invisible because the queue never rejected anything (`queue_full_requests_total 0`).

Measured on the engine while investigating a 10-minute TTFT:

- `vllm:cache_config_info{num_gpu_blocks="9527",block_size="16"}` = **152,432 tokens** of KV capacity for the whole engine. PP=2 splits layers, so both ranks hold KV for the same tokens — not 2x that.
- Uncached prefill runs at **262 tok/s** contended, 525 tok/s over 7d (`vllm:request_prefill_time_seconds_sum/count`).
- One request showed `llm_ttft_seconds_sum 590.981 / count 5` at the gateway and a `(480, 960]` s entry in `vllm:request_prefill_time_seconds_bucket`, with engine-side generation throughput at 0.0-0.1 tok/s throughout: a 65k-token prompt costs ~250 s of prefill and starves every other request while it runs.
- `vllm:num_preemptions_total = 0`; KV peaked at `0.5399`.

Reading the engine's own gauges is global by construction, needs no lock and no new datastore, and it also accounts for callers that never pass the gateway — namespace `poimen` is admitted directly on 80/8080 by `llm-serving-ingress`, and `memory/crates/mem-cli/src/kv_warmer.rs` is such a caller. A distributed lock would have been blind to that traffic.

## Changes

- **new `internal/proxy/engine_state.go`** — 2s scrape of `http://<addr>/metrics` on its own client (the proxy transports carry socket options tuned for inference), hand-rolled exposition parse, `Healthy=false` after 3 consecutive failed polls with `engine_state_stale_total` counted once at the transition, and a startup self-check that raises `observer_degraded` and logs at error level instead of degrading to fail-open silently.
- **admission gate** (`internal/proxy/scheduler.go`) — parks while `kv_cache_usage_perc >= 0.90` or `running + waiting >= ADMISSION_MAX_INFLIGHT`, bounded by `ADMISSION_MAX_PARK_SECONDS`, then `503 + Retry-After: 5`. Unknown or unhealthy state fails **open** on purpose: a metrics outage must not become an inference outage.
- **parked streaming heartbeat** (`internal/proxy/proxy.go`) — `stream: true` requests get 200 + `text/event-stream` and a `: keep-alive` comment every 15s while parked, so a client idle timeout does not kill a request that is legitimately waiting. Non-streaming requests park silently.
- **`internal/proxy/router.go`** — extract only the top-level `model` field with a `json.Decoder` token walk instead of unmarshalling the whole body into `map[string]interface{}` (that built the entire `messages`+`tools` tree per request, ~300 KB for a 65k-token prompt). `MaxBodySize` now comes from `MAX_BODY_BYTES`; the model path previously inherited the 100 MB default at `proxy.go:93` against a 512Mi container limit.
- **`cmd/gateway/main.go`** — `GOMAXPROCS` from the cgroup CPU quota (cgroup v2 `cpu.max`, v1 fallback). The container is limited to `500m` while Go defaulted to the host core count and was being CFS-throttled.
- **`k8s/deployment.yaml`** — `ADMISSION_MAX_PARK_SECONDS=120`, `ADMISSION_OBSERVE_MODELS=reasoning`, `VLLM_MAX_QUEUE_DEPTH=8`, `MAX_BODY_BYTES=1048576`; corrected the `llm-client` comment (the NetworkPolicy selects by namespace now, not by that label).
- **deleted `k8s/configmap.yaml`** — absent from `k8s/kustomization.yaml`, and the live ConfigMap of that name carries an unrelated key (`gw-config-fixed.yaml`) plus a stale routing table.
- **`internal/observability/`** — `admission_parked_total`, `admission_rejected_total{reason}`, `engine_state_stale_total`, and gauges `vllm_requests_running` / `vllm_kv_usage_perc` (the gateway's own reading, deliberately mirroring the vLLM series so a disagreement is visible) and `observer_degraded`. Names follow the existing convention, which carries no prefix.

Out of scope here, in `riotpiao-poimen/homelab#51`: the engine flags (`--max-num-partial-prefills`, `--max-long-partial-prefills`, `--long-prefill-token-threshold`, `--max-num-batched-tokens`, `--max-num-seqs`, `--gpu-memory-utilization`) and the four dashboard panels in `generate-dashboards.py`.

## Verification

- `go build ./...` clean, `go vet ./...` clean.
- `go test ./...` — 368 tests, 0 failures (baseline on `main` is 368/0).
- `structural-check.sh` on the changed files — 0 errors, 1 pre-existing warning (`:latest` image tag).
- `go.mod` / `go.sum` byte-identical: no new dependency.

Not yet verified: the real-engine load test (2x65k + 15x8k against `reasoning`). That run also produces the number for `ADMISSION_MAX_PARK_SECONDS`, which today is the provisional 120; retuning it is a follow-up since it is env-only and needs no rebuild.

## Accepted risks

- **No tests were added** for the observer or the gate (deliberate: production is the test bed). The mitigation is the startup self-check, which is production self-diagnosis rather than a test, plus the `observer_degraded` alert. A parse failure that outlives the first poll logs and surfaces as a metric; it does not silently disable gating without a signal.
- **The cap is soft, not hard.** Between polls each replica may admit more than one request, so there is no clean bound. Exposure is low (~11,500 requests over 7d ≈ 1.4/min) and the engine remains the hard limit — a race parks requests in vLLM's queue rather than over-committing KV.
- **`ADMISSION_MAX_INFLIGHT` is manually coupled to `--max-num-seqs`** and can drift silently. It defaults to `VLLM_MAX_NUM_SEQS`, and startup logs the effective value.
- The first scrape waits one interval, so the first ~2s of admission is ungated (fail-open). Scraping at `Start()` instead would make the gateway contact every observed backend before any request needs one, which is wasted work and a surprising side effect.

## Rollback

Revert this commit and redeploy. The admission path is fail-open, so the code can also be neutralised without reverting by unsetting `ADMISSION_OBSERVE_MODELS`. Separately, the orphaned cluster object can be removed by hand: `kubectl delete cm -n api api-gateway-config` (not a code change, deliberately left to a human).

---------
Reviewed-on: #65
Co-authored-by: poimen <[email protected]>
2026-09-30 10:56:10 +00:00

homelab-frontend

Production API gateway for the homelab cluster. Single entry point (api.riotpiao.com) for all services: LLM inference, workflows, queues, memory, and cluster operations.

Status: Live in production. Replaced Kong OSS entirely.


  • API Reference: See API.md — how to call every service
  • Base URL: https://api.riotpiao.com
  • Source: ssh://git.riotpiao.com:2222/rock/homelab-frontend.git

Architecture

┌─────────────┬──────────────┬────────────┐
│  Browser    │     SDK      │    CLI     │
└──────┬──────┴──────┬───────┴────┬───────┘
       │             │            │
       └─────────────┼────────────┘
                     │
                  HTTPS/TLS
                     │
       ┌─────────────┼────────────┐
       │   Cloudflare Edge        │
       │   (DDoS, caching)        │
       └──────────┬──────────────┘
                  │
            ingress-nginx
         (SSL termination)
                  │
    ┌─────────────────────────────┐
    │   homelab-frontend Gateway  │
    │   (routing, auth, limits)    │
    └──────┬───────────────────────┘
           │
    ┌──────┴───────────────────────────────────┐
    │                                           │
 /v1/*           X-Service header routing       /
(LLM)    (workflow, sqs, s3, iam, memory)    /
    │             │                           │
    ▼             ▼                           ▼
llm-serving  temporal:7233   kmsvc/Kafka, MinIO,
(vLLM, Ollama) (gRPC)        Authentik, poimen-memory
(TEI)

Design principles:

  • ✅ Single hostname, unified X-Service + X-Resource header routing
  • ✅ HTTP REST gateway → gRPC Temporal bridge (via X-Service: workflow)
  • ✅ Bearer token auth via Authentik (JWT + RBAC)
  • ✅ Streaming unbuffered (SSE, WebSocket, HTTP/2 multiplexing)
  • ✅ Per-route timeouts & rate limits
  • ✅ No cluster credentials held by gateway

Services & Capabilities

Service Method Upstream Status
LLM Chat POST /v1/chat/completions llm-serving (vLLM) ✅ Live
Embeddings POST /v1/embeddings llm-serving (TEI) ✅ Live
Reranking POST /v1/rerank llm-serving (TEI) ✅ Live
Workflows X-Service: workflow + X-Resource: {action} Temporal gRPC (7233) ✅ Live (START, DESCRIBE, SIGNAL, QUERY, etc)
Queues X-Service: sqs + X-Resource: {action} kmsvc/Kafka ✅ Live
Memory X-Service: memory + X-Resource: {action} poimen-memory ✅ Live
IAM X-Service: iam + X-Resource: {action} Authentik API ✅ Live
S3 X-Service: s3 + X-Resource: {action} MinIO ✅ Live

How to Use

1. Get a token

Human (OIDC device code):

core auth login
export TOKEN=$(cat ~/.cache/talos/authentik_id_token)

Service account (client credentials):

core mwinit login --username sa-name --password secret
export TOKEN=$(cat ~/.talos/.riotpiao-auth)

2. Call any service

Chat:

curl -X POST https://api.riotpiao.com/v1/chat/completions \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "model": "reasoning",
    "messages": [{"role": "user", "content": "What is 2+2?"}]
  }'

Workflow (via X-Service header):

curl -X POST https://api.riotpiao.com/ \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Service: workflow" \
  -H "X-Resource: start" \
  -d '{
    "namespace": "default",
    "workflow_id": "my-workflow",
    "workflow_type": "MyWorkflow",
    "task_queue": "default"
  }'

Memory:

curl -X GET https://api.riotpiao.com/ \
  -H "Authorization: Bearer $TOKEN" \
  -H 'X-Service: memory' \
  -H 'X-Resource: query' \
  -G --data-urlencode 'query=explain machine learning'

Full examples: See API.md


Available Models

LLM (Chat & Reasoning)

  • reasoning — DeepSeek-R1-Distill-Qwen-32B (8 concurrent slots)
  • ornith:35b — Ollama 35B
  • qwen2.5:3b-instruct — Qwen 2.5 3B

Embeddings

  • nomic-ai/nomic-embed-text-v2-moe — Fast, multilingual

Reranking

  • BAAI/bge-reranker-base — Document relevance scoring

Authentication

All endpoints (except /healthz, /readyz) require:

Authorization: Bearer <jwt-token>

Tokens validated via Authentik JWKS (runtime fetched, cached, auto-rotated).

Capabilities (RBAC):

  • llm:inference — /v1/* chat/embeddings/rerank
  • workflow:execute — /workflow operations
  • memory:read / memory:write — Memory operations
  • sqs:access — Queue operations
  • s3:access — S3 operations
  • iam:admin — User/group management

Error Handling

All errors return RFC 9457 application/problem+json:

{
  "type": "https://api.example.com/problems/unknown-model",
  "title": "Unknown Model",
  "status": 400,
  "detail": "Model 'gpt-4' is not available",
  "valid_models": ["reasoning", "ornith:35b", ...]
}

Common status codes:

  • 200 OK
  • 400 Bad Request (validation, unknown model)
  • 401 Unauthorized (missing/invalid token)
  • 403 Forbidden (insufficient capability)
  • 404 Not Found (workflow, resource)
  • 429 Too Many Requests (rate limit)
  • 503 Service Unavailable (backend down)

Rate Limits

Endpoint Limit Retry-After
/v1/chat/completions 8 concurrent Yes
/v1/embeddings 10 concurrent Yes
/v1/rerank 10 concurrent Yes
/workflow 100 concurrent Yes

Hitting limit returns 429 with Retry-After header.


Timeouts

Endpoint Connect Read Write
/v1/chat 10s 1h 1h
/v1/embeddings 10s 10m 10m
/v1/rerank 10s 10m 10m
/workflow 10s 30s 10s

Client disconnects cancel upstream request immediately (no orphaned slots).


Local Development

Run without cluster, no credentials needed:

# Build
go build ./cmd/gateway

# Run locally
./gateway

# Test in another terminal
curl http://localhost:8080/healthz

Points upstreams at local stubs if not connected to cluster (see internal/config).


Code Layout

cmd/gateway/               Server entrypoint
internal/
  server/                  Router, health checks
  proxy/                   Reverse proxy, streaming, timeouts
  temporal/                Workflow handler + gRPC bridge
  serviceadapter/          X-Service dispatcher (CRD-driven)
  config/                  Route + upstream configuration
  auth/                    Authentik JWT validation
  observability/           Metrics, structured logging
k8s/
  configmap.yaml           Route definitions
  rbac.yaml                Service account, roles
  deployment.yaml          Pod spec
  networkpolicy.yaml       Ingress/egress rules
testdata/                  Fixtures for offline tests

Deployment

Deployed to Kubernetes via ArgoCD:

# Check deployment
kubectl -n api get deployment homelab-frontend

# View logs
kubectl -n api logs -l app=homelab-frontend -f

# Restart
kubectl -n api rollout restart deployment/homelab-frontend

Configuration mounted as ConfigMap (k8s/configmap.yaml).


Health Checks

# Liveness (always succeeds)
curl https://api.riotpiao.com/healthz

# Readiness (waits for config + JWKS)
curl https://api.riotpiao.com/readyz

# List available models
curl https://api.riotpiao.com/v1/models

Support

Issues: Check pod logs

kubectl -n api logs deployment/homelab-frontend

Debug config:

kubectl -n api get configmap homelab-frontend-config -o yaml

Restart pod:

kubectl -n api rollout restart deployment/homelab-frontend

Test endpoint directly:

kubectl -n api port-forward svc/homelab-frontend 8080:8080
curl http://localhost:8080/healthz

See Also

  • API.md — Complete API reference with examples
  • internal/ — Source code (handlers, routing, auth)
  • k8s/ — Kubernetes manifests
S
Description
No description provided
Readme
1.3 MiB
Languages
Go 96.1%
Shell 3.6%
Dockerfile 0.3%