CI / test (push) Failing after 50s
ctl talks to a management API on the admin listener, not the client-facing one. The two have different audiences: one serves inference to whoever holds an API key, the other lets a caller cordon nodes and read the cluster's shape. - nodes surfaces unhealthy GPUs and why they are excluded. That is usually the answer when a node looks idle but nothing will schedule onto it, and it was invisible before. - explain-placement prints every candidate considered and why it was rejected. - replicas shows the shards a replica is made of, with per-shard errors. - cordon, uncordon and drain are one endpoint with different targets, because the difference between cordon and drain is exactly what an operator needs to be sure of before running either. Draining had to be fixed first to make ctl drain worth having. A drained replica was stopped on the next pass, which cuts in-flight requests — the thing draining exists to avoid. Now a node marked Draining has its replicas drained, the model falls below its desired count, a replacement is placed elsewhere, and the original is stopped only once the replacement is serving. A grace period bounds it, because draining the only node that can host a model would otherwise never finish and the node could never be taken out of service. Verified against a two-node cluster of real binaries: draining moved a replica to the surviving node and traffic kept returning 200 throughout. ctl logs is deliberately absent. It needs a request/response pair the agent protocol does not have, and the failure path already ships an engine log tail with every status report — so the case that matters most is covered, and reading a healthy shard's output on demand is not worth a protocol change yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
129 lines
4.3 KiB
Go
129 lines
4.3 KiB
Go
package router
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/pprof"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/collectors"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
)
|
|
|
|
// AdminHandler returns the diagnostics surface: metrics, health and pprof.
|
|
//
|
|
// It is served on a separate listener so the client-facing port can be exposed
|
|
// without also exposing pprof (plan §9.1). The control plane's management API
|
|
// is mounted here too, by the caller.
|
|
func (r *Router) AdminHandler() (*http.ServeMux, error) {
|
|
reg := prometheus.NewRegistry()
|
|
reg.MustRegister(
|
|
collectors.NewGoCollector(),
|
|
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
|
)
|
|
for _, c := range r.m.collectors() {
|
|
if err := reg.Register(c); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := reg.Register(newEndpointCollector(r)); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.Handle("GET /metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
|
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
mux.HandleFunc("GET /debug/pprof/", pprof.Index)
|
|
mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
|
|
mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
|
|
mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
|
|
mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
|
|
return mux, nil
|
|
}
|
|
|
|
// endpointCollector reports live endpoint state at scrape time rather than
|
|
// mirroring it into gauges on every request; the registry already holds it.
|
|
type endpointCollector struct {
|
|
r *Router
|
|
|
|
inflight *prometheus.Desc
|
|
waiting *prometheus.Desc
|
|
running *prometheus.Desc
|
|
kv *prometheus.Desc
|
|
stale *prometheus.Desc
|
|
drain *prometheus.Desc
|
|
breaker *prometheus.Desc
|
|
|
|
queueDepth *prometheus.Desc
|
|
saturation *prometheus.Desc
|
|
}
|
|
|
|
func newEndpointCollector(r *Router) *endpointCollector {
|
|
lbl := []string{"replica", "pool"}
|
|
return &endpointCollector{
|
|
r: r,
|
|
inflight: prometheus.NewDesc("llmgw_endpoint_inflight", "Requests the router has dispatched and not yet finished.", lbl, nil),
|
|
waiting: prometheus.NewDesc("llmgw_endpoint_waiting", "Engine queue depth, as last scraped.", lbl, nil),
|
|
running: prometheus.NewDesc("llmgw_endpoint_running", "Requests running on the engine, as last scraped.", lbl, nil),
|
|
kv: prometheus.NewDesc("llmgw_endpoint_kv_cache_usage", "Engine KV cache occupancy, 0..1.", lbl, nil),
|
|
drain: prometheus.NewDesc("llmgw_endpoint_drain_rate", "Observed completions per second.", lbl, nil),
|
|
stale: prometheus.NewDesc("llmgw_endpoint_metrics_stale", "1 when the endpoint's metrics are stale.", lbl, nil),
|
|
breaker: prometheus.NewDesc("llmgw_endpoint_breaker_open", "1 when the endpoint's circuit breaker is open.", lbl, nil),
|
|
|
|
queueDepth: prometheus.NewDesc("llmgw_admission_queue_depth", "Requests waiting in each admission band.", []string{"band"}, nil),
|
|
saturation: prometheus.NewDesc("llmgw_pool_saturation", "Pool saturation per model; 1 is the healthy limit.", []string{"model"}, nil),
|
|
}
|
|
}
|
|
|
|
func (c *endpointCollector) Describe(ch chan<- *prometheus.Desc) {
|
|
ch <- c.inflight
|
|
ch <- c.waiting
|
|
ch <- c.running
|
|
ch <- c.kv
|
|
ch <- c.drain
|
|
ch <- c.stale
|
|
ch <- c.breaker
|
|
ch <- c.queueDepth
|
|
ch <- c.saturation
|
|
}
|
|
|
|
func (c *endpointCollector) Collect(ch chan<- prometheus.Metric) {
|
|
for _, s := range c.r.reg.Snapshots("") {
|
|
pool := s.Pool
|
|
if pool == "" {
|
|
pool = "none"
|
|
}
|
|
g := func(d *prometheus.Desc, v float64) {
|
|
ch <- prometheus.MustNewConstMetric(d, prometheus.GaugeValue, v, s.ID, pool)
|
|
}
|
|
g(c.inflight, float64(s.Inflight))
|
|
g(c.waiting, float64(s.Stats.Waiting))
|
|
g(c.running, float64(s.Stats.Running))
|
|
g(c.kv, s.Stats.KVCacheUsage)
|
|
g(c.drain, s.DrainRate)
|
|
g(c.stale, boolToFloat(s.Stale))
|
|
g(c.breaker, boolToFloat(s.Breaker.String() == "open"))
|
|
}
|
|
|
|
if c.r.adm != nil {
|
|
for _, d := range c.r.adm.Depths() {
|
|
ch <- prometheus.MustNewConstMetric(c.queueDepth, prometheus.GaugeValue, float64(d.Depth), d.Band)
|
|
}
|
|
}
|
|
// Saturation is reported per model, which is the granularity admission
|
|
// decisions are made at.
|
|
for _, model := range c.r.servedModels() {
|
|
if sat, ok := c.r.saturation(model, 0); ok {
|
|
ch <- prometheus.MustNewConstMetric(c.saturation, prometheus.GaugeValue, sat, model)
|
|
}
|
|
}
|
|
}
|
|
|
|
func boolToFloat(b bool) float64 {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|