feat(proxy): engine-wide LLM admission control via vLLM gauges (#65)
CI / CI (push) Successful in 41m20s
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]>
This commit was merged in pull request #65.
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
|
||||
)
|
||||
|
||||
// Metric names published by the vLLM engine. Admission is gated on these
|
||||
// rather than on a per-pod semaphore: every gateway replica reads the same
|
||||
// engine, so the limit is global by construction (#64).
|
||||
const (
|
||||
metricRequestsRunning = "vllm:num_requests_running"
|
||||
metricRequestsWaiting = "vllm:num_requests_waiting"
|
||||
metricKVCacheUsage = "vllm:kv_cache_usage_perc"
|
||||
|
||||
// enginePollInterval is how often each backend's /metrics is scraped.
|
||||
enginePollInterval = 2 * time.Second
|
||||
|
||||
// engineStaleThreshold is the number of consecutive failed polls after
|
||||
// which the state is marked unhealthy and admission fails open.
|
||||
engineStaleThreshold = 3
|
||||
|
||||
// engineMetricsTimeout bounds a single scrape.
|
||||
engineMetricsTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// EngineState is the gateway's view of one vLLM engine, mirrored from its
|
||||
// own metrics endpoint.
|
||||
type EngineState struct {
|
||||
Running int64
|
||||
Waiting int64
|
||||
KVUsage float64
|
||||
UpdatedAt time.Time
|
||||
Healthy bool
|
||||
}
|
||||
|
||||
// InFlight returns the number of requests the engine currently holds:
|
||||
// running plus waiting.
|
||||
func (s EngineState) InFlight() int64 {
|
||||
return s.Running + s.Waiting
|
||||
}
|
||||
|
||||
// EngineObserver polls one vLLM backend's /metrics and publishes the gauges
|
||||
// admission is gated on. Healthy is false until the first successful poll and
|
||||
// after engineStaleThreshold consecutive failures.
|
||||
type EngineObserver struct {
|
||||
addr string
|
||||
interval time.Duration
|
||||
client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
state EngineState
|
||||
failures int
|
||||
degraded bool
|
||||
tick chan struct{}
|
||||
}
|
||||
|
||||
// NewEngineObserver creates an observer for the backend at addr.
|
||||
func NewEngineObserver(addr string, interval time.Duration) *EngineObserver {
|
||||
return &EngineObserver{
|
||||
addr: addr,
|
||||
interval: interval,
|
||||
// Own client: the proxy transports carry socket options and connect
|
||||
// timeouts tuned for inference, not for a short metrics scrape.
|
||||
client: &http.Client{Timeout: engineMetricsTimeout},
|
||||
tick: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the polling goroutine. It returns immediately; the goroutine
|
||||
// exits when ctx is cancelled.
|
||||
func (o *EngineObserver) Start(ctx context.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(o.interval)
|
||||
defer ticker.Stop()
|
||||
// The first scrape waits one interval rather than firing at Start().
|
||||
// Scraping immediately makes the gateway contact every observed backend
|
||||
// before any request needs one, which is wasted work and a surprising
|
||||
// side effect for anything asserting on upstream traffic. State is
|
||||
// unknown until the first poll, and unknown fails open, so the deferral
|
||||
// costs at most one interval of ungated admission. The startup
|
||||
// self-check (decision Q21) still runs, on that first tick.
|
||||
startup := true
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
o.poll(ctx, startup)
|
||||
startup = false
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Get returns the most recent engine state.
|
||||
func (o *EngineObserver) Get() EngineState {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
return o.state
|
||||
}
|
||||
|
||||
// Tick returns a channel that is closed and replaced after every successful
|
||||
// poll, waking requests parked on engine admission.
|
||||
func (o *EngineObserver) Tick() <-chan struct{} {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
return o.tick
|
||||
}
|
||||
|
||||
// Degraded reports whether the startup self-check found the metrics feed
|
||||
// unusable (parse failure or missing series).
|
||||
func (o *EngineObserver) Degraded() bool {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
return o.degraded
|
||||
}
|
||||
|
||||
// poll scrapes /metrics once. startup marks the first poll, whose failure is a
|
||||
// self-diagnosis signal rather than a transient blip.
|
||||
func (o *EngineObserver) poll(ctx context.Context, startup bool) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+o.addr+"/metrics", nil)
|
||||
if err != nil {
|
||||
o.recordFailure()
|
||||
logging.Warnf("engine metrics request build failed", map[string]string{
|
||||
"address": o.addr,
|
||||
"err": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp, err := o.client.Do(req)
|
||||
if err != nil {
|
||||
o.recordFailure()
|
||||
logging.Warnf("engine metrics scrape failed", map[string]string{
|
||||
"address": o.addr,
|
||||
"err": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
o.recordFailure()
|
||||
logging.Warnf("engine metrics scrape returned non-200", map[string]string{
|
||||
"address": o.addr,
|
||||
"status": strconv.Itoa(resp.StatusCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
running, waiting, kvUsage, err := ParseEngineMetrics(resp.Body)
|
||||
if err != nil {
|
||||
if startup {
|
||||
// Startup self-check (decision Q21): metric-name drift must be
|
||||
// loud, never a silent fall-back to fail-open admission.
|
||||
o.mu.Lock()
|
||||
o.degraded = true
|
||||
o.mu.Unlock()
|
||||
observability.Global().SetObserverDegraded(true)
|
||||
logging.Errorf("engine metrics unusable at startup", err, map[string]string{
|
||||
"address": o.addr,
|
||||
})
|
||||
} else {
|
||||
logging.Warnf("engine metrics parse failed", map[string]string{
|
||||
"address": o.addr,
|
||||
"err": err.Error(),
|
||||
})
|
||||
}
|
||||
o.recordFailure()
|
||||
return
|
||||
}
|
||||
|
||||
o.recordSuccess(running, waiting, kvUsage)
|
||||
}
|
||||
|
||||
// recordSuccess stores a fresh reading and wakes parked waiters.
|
||||
func (o *EngineObserver) recordSuccess(running, waiting int64, kvUsage float64) {
|
||||
o.mu.Lock()
|
||||
o.state = EngineState{
|
||||
Running: running,
|
||||
Waiting: waiting,
|
||||
KVUsage: kvUsage,
|
||||
UpdatedAt: time.Now(),
|
||||
Healthy: true,
|
||||
}
|
||||
o.failures = 0
|
||||
close(o.tick)
|
||||
o.tick = make(chan struct{})
|
||||
o.mu.Unlock()
|
||||
|
||||
m := observability.Global()
|
||||
m.SetVLLMRequestsRunning(running)
|
||||
m.SetVLLMKVUsagePerc(kvUsage)
|
||||
}
|
||||
|
||||
// recordFailure counts a failed poll and, on the third consecutive failure,
|
||||
// marks the state unhealthy and counts the staleness transition once — not
|
||||
// once per failed poll. Last known values are kept.
|
||||
func (o *EngineObserver) recordFailure() {
|
||||
o.mu.Lock()
|
||||
o.failures++
|
||||
stale := o.failures == engineStaleThreshold
|
||||
if o.failures >= engineStaleThreshold {
|
||||
o.state.Healthy = false
|
||||
}
|
||||
failures := o.failures
|
||||
o.mu.Unlock()
|
||||
|
||||
if stale {
|
||||
observability.Global().IncrEngineStateStale()
|
||||
logging.Warnf("engine metrics stale; admission is failing open", map[string]string{
|
||||
"address": o.addr,
|
||||
"failures": strconv.Itoa(failures),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ParseEngineMetrics extracts the running, waiting and KV-cache-usage gauges
|
||||
// from a vLLM Prometheus exposition body. Comment lines, unrelated series and
|
||||
// non-finite samples are ignored; values are summed across repeated label sets.
|
||||
// It returns an error naming any of the three series that is absent.
|
||||
func ParseEngineMetrics(r io.Reader) (running int64, waiting int64, kvUsage float64, err error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
// The real exposition body is large; give the scanner room per line.
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
var hasRunning, hasWaiting, hasKV bool
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
name, value, ok := splitMetricSample(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case metricRequestsRunning, metricRequestsWaiting, metricKVCacheUsage:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
f, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case metricRequestsRunning:
|
||||
running += int64(f)
|
||||
hasRunning = true
|
||||
case metricRequestsWaiting:
|
||||
waiting += int64(f)
|
||||
hasWaiting = true
|
||||
case metricKVCacheUsage:
|
||||
kvUsage += f
|
||||
hasKV = true
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("read engine metrics: %w", err)
|
||||
}
|
||||
|
||||
var missing []string
|
||||
if !hasRunning {
|
||||
missing = append(missing, metricRequestsRunning)
|
||||
}
|
||||
if !hasWaiting {
|
||||
missing = append(missing, metricRequestsWaiting)
|
||||
}
|
||||
if !hasKV {
|
||||
missing = append(missing, metricKVCacheUsage)
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return 0, 0, 0, fmt.Errorf("engine metrics missing series: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return running, waiting, kvUsage, nil
|
||||
}
|
||||
|
||||
// splitMetricSample splits one Prometheus exposition line into its metric name
|
||||
// and its first sample value. Lines without a value are reported as not ok.
|
||||
func splitMetricSample(line string) (name, value string, ok bool) {
|
||||
end := strings.IndexAny(line, "{ \t")
|
||||
if end <= 0 {
|
||||
return "", "", false
|
||||
}
|
||||
name = line[:end]
|
||||
rest := line[end:]
|
||||
if rest[0] == '{' {
|
||||
closeBrace := strings.LastIndex(rest, "}")
|
||||
if closeBrace < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
rest = rest[closeBrace+1:]
|
||||
}
|
||||
fields := strings.Fields(rest)
|
||||
if len(fields) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return name, fields[0], true
|
||||
}
|
||||
Reference in New Issue
Block a user