369d51a5c2788feff2c0a0c888f0757e05575487
6
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
369d51a5c2 |
feat(proxy): engine-wide LLM admission control via vLLM gauges (#65)
CI / CI (push) Successful in 41m20s
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]> |
||
|
|
30e0a83a50 |
feat(network): SSE optimization for local LLM streaming (#31 #32 #33) (#26)
CI / CI (push) Successful in 3m35s
Addresses three critical network issues for LLM streaming performance: **#33 Disable proxy buffering for SSE** - Add X-Accel-Buffering: no header to response - Tells nginx/Ingress to stream events immediately instead of buffering **#32 HTTP/2 multiplexing for concurrent streams** - Enable HTTP/2 in server config via http2.ConfigureServer() - Increase MaxConnsPerHost to 10 for better concurrency - Allows multiple concurrent LLM requests without blocking **#31 TCP backpressure for streaming LLM responses** - Set TCP_NODELAY on dialer to disable Nagle's algorithm - Reduces latency by sending small packets immediately - Critical for low TTFT (time-to-first-token) under load **Tests added:** - TestTCPBackpressure: Verifies TCP backpressure handling with slow client - TestConcurrentSSEStreams: Confirms HTTP/2 multiplexing works correctly --------- Co-authored-by: poison <[email protected]> Reviewed-on: #26 Co-authored-by: poimen <[email protected]> |
||
|
|
7de71180b3 |
feat: add Tekton Pipelines for integration testing (#25)
CI / CI (push) Successful in 3m13s
Implement Kubernetes-native CI/CD with Tekton Pipelines for pre-merge integration testing. ## What This Does Adds Tekton Pipelines to orchestrate integration tests before deployment: 1. **Tekton Task** (task-integration-test.yaml) - Runs integration tests in container - Tests memory, S3, SQS, workflow, IAM services - Reports pass/fail results 2. **Tekton Pipeline** (pipeline-integration-test.yaml) - Parameterized pipeline for reusability - Takes image tag as input - Outputs test results 3. **ArgoCD Application** (k8s/argocd-apps/tekton.yaml) - Manages Tekton installation - Tekton controller watches and executes PipelineRuns - No manual kubectl applies needed 4. **Updated CI** (.gitea/workflows/ci.yaml) - Build image with commit SHA - Create PipelineRun to test image - Wait for Tekton to complete tests - Only promote to :latest if tests pass - ArgoCD detects :latest and deploys ## Architecture git push → CI builds image:sha → Create PipelineRun → Tekton runs tests → Results to CI → Promote :latest → ArgoCD deploys ## Code Quality ✓ DRY: Parameterized, reusable Task and Pipeline ✓ SOLID: Single responsibility, clean interfaces ✓ GitOps: Everything in git, managed by ArgoCD ✓ Security: Non-root containers, resource limits ✓ Observable: Logs, status, results tracking ## Files Changed - k8s/tekton/task-integration-test.yaml - Task definition - k8s/tekton/pipeline-integration-test.yaml - Pipeline definition - k8s/tekton/kustomization.yaml - Kustomize for management - k8s/tekton/base/tekton-release.yaml - Release reference - k8s/tekton/README.md - Documentation - k8s/argocd-apps/tekton.yaml - ArgoCD Application - .gitea/workflows/ci.yaml - Updated CI workflow ## Review Checklist - [ ] Tekton manifests are clean and parameterized - [ ] ArgoCD Application properly configured - [ ] CI workflow correctly triggers PipelineRun - [ ] Error handling for test failures - [ ] Logs and status properly captured - [ ] Documentation is clear ## Testing After merge: 1. ArgoCD syncs and installs Tekton Pipelines 2. Next git push triggers CI 3. CI creates PipelineRun 4. Tekton runs integration tests 5. Results show in CI workflow --------- Co-authored-by: poimen <[email protected]> Reviewed-on: #25 Co-authored-by: poimen <[email protected]> |
||
|
|
d82cc5a697 |
fix: gateway authentik port from 80 to 9000 (#22)
Fix gateway authentik port from 80 to 9000 NetworkPolicy allows gateway→iam only on ports 9000/9443. Gateway config was using port 80, causing JWT validation failures. Changes made: - auth.jwksUrl: port 80 → 9000 - auth.tokenUrl: port 80 → 9000 - iam.upstream: port 80 → 9000 This fixes JWKS refresh and JWT token validation. --------- Co-authored-by: rock <[email protected]> Reviewed-on: #22 Co-authored-by: poimen <[email protected]> |
||
|
|
1c64d8ff0e |
feat(config): add tokenUrl, clientId, clientSecret to auth config (#17)
Closes homelab#12 (P3.7) ## Changes - `AuthConfig`: added `TokenURL`, `ClientID`, `ClientSecret` fields - `loader.go`: reads `tokenUrl`/`clientId` from YAML, `ClientSecret` from `AUTH_CLIENT_SECRET` env - `deployment.yaml`: `AUTH_CLIENT_SECRET` from `api-gw-client-secret` Secret (optional) - `gateway-config-secret.enc.yaml` + `configmap.yaml`: added `tokenUrl` and `clientId` ## Secret never in YAML `clientSecret` deliberately omitted from YAML struct. Loaded from env only. ## Tests 3 tests: full config load, env-only secret, backward compat (missing fields = zero). Co-authored-by: poimen <[email protected]> |
||
|
|
74ecfe7107 |
feat(serviceadapter): enforce JWT auth on X-Service dispatch (#16)
SQS dispatcher hardcoded a JWT validator pointing at authentik.riotpiao.com/application/o/sqs/jwks/ — provider doesn't exist. Every SQS request got 403 regardless of token. Co-authored-by: poimen <[email protected]> |