fix(helm): route /debug/memory/summary to the gateway so the memory gate reads the serving workers

On the release gate the e2e tests only see the nginx router, and the chart's
ingress sent /debug/memory/summary to the backend catch-all, so the RSS check
measured the backend pod instead of the gateway workers that serve the failing
requests. Render it as an Exact gateway path next to /test, name the host in the
summary response so workers behind one origin never collide on pid alone, and
key the harness readings by (origin, hostname, pid)
This commit is contained in:
mateo-berri 2026-09-10 19:02:37 -07:00
parent e4f958d7cf
commit 5fdb0860ec
8 changed files with 66 additions and 9 deletions

View file

@ -89,7 +89,7 @@
at "/" Prefix would swallow the whole backend management API) instead of
adding to it.
*/}}
{{- $builtinPathKeys := list "/test|Exact" "/|Prefix" -}}
{{- $builtinPathKeys := list "/test|Exact" "/debug/memory/summary|Exact" "/|Prefix" -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@ -129,6 +129,8 @@ spec:
# --- Gateway data plane ---
# Exact /test only (see the $gatewayPrefixes comment above);
# /test/* MCP management endpoints fall to the backend catch-all.
# Exact /debug/memory/summary reads a serving worker's RSS (the e2e memory
# gate); the rest of /debug/* stays on the backend.
- path: /test
pathType: Exact
backend:
@ -136,6 +138,13 @@ spec:
name: {{ $gatewayName }}
port:
number: {{ $gatewayPort }}
- path: /debug/memory/summary
pathType: Exact
backend:
service:
name: {{ $gatewayName }}
port:
number: {{ $gatewayPort }}
{{- range $gatewayPrefixes }}
{{- $pathType := include "litellm.ingress.pathType" (dict "controller" $controller "path" . "pathType" "Prefix") }}
{{- $builtinPathKeys = append $builtinPathKeys (printf "%s|%s" . $pathType) }}

View file

@ -97,6 +97,16 @@ tests:
name: RELEASE-NAME-litellm-gateway
port:
number: 4000
- contains:
path: spec.rules[0].http.paths
content:
path: /debug/memory/summary
pathType: Exact
backend:
service:
name: RELEASE-NAME-litellm-gateway
port:
number: 4000
- equal:
path: spec.rules[0].http.paths[-1]
value:

View file

@ -288,6 +288,17 @@ tests:
- failedTemplate:
errorMessage: "ingress.extraPaths[0]: path /test with pathType Exact is already routed by this chart, and a duplicate would take it over rather than add to it"
- it: rejects an entry that would take over the exact /debug/memory/summary route
set:
ingress.enabled: true
ingress.extraPaths:
- path: /debug/memory/summary
pathType: Exact
service: backend
asserts:
- failedTemplate:
errorMessage: "ingress.extraPaths[0]: path /debug/memory/summary with pathType Exact is already routed by this chart, and a duplicate would take it over rather than add to it"
- it: allows a built-in path under a different pathType, which is a distinct rule
set:
ingress.enabled: true

View file

@ -3,6 +3,7 @@ import asyncio
import gc
import json
import os
import socket
import sys
import tracemalloc
from collections import Counter
@ -241,6 +242,7 @@ async def get_memory_summary(
Returns:
- worker_pid: Process ID
- hostname: Host (the pod on Kubernetes) the worker runs on
- status: Overall health based on memory usage
- memory: Process memory usage and RAM info
- caches: Cache item counts and descriptions
@ -340,6 +342,7 @@ async def get_memory_summary(
return {
"worker_pid": os.getpid(),
"hostname": socket.gethostname(),
"status": health_status,
"memory": process_memory,
"caches": {

View file

@ -1282,9 +1282,11 @@ class ProcessMemory(BaseModel):
class MemorySummaryResponse(BaseModel):
"""GET /debug/memory/summary (master key). One worker's resident memory, keyed by
its pid so readings behind a load balancer can be told apart per pod."""
its hostname (the pod name on Kubernetes) and pid so readings behind a load
balancer can be told apart per worker; older proxies omit the hostname."""
worker_pid: int
hostname: str | None = None
status: str
memory: ProcessMemory

View file

@ -79,15 +79,19 @@ class FailedCall:
call_id: str | None
WorkerKey = tuple[str, str | None, int]
@dataclass(frozen=True, slots=True)
class RssReading:
replica: str
hostname: str | None
worker_pid: int
ram_usage_mb: float
@property
def worker(self) -> tuple[str, int]:
return (self.replica, self.worker_pid)
def worker(self) -> WorkerKey:
return (self.replica, self.hostname, self.worker_pid)
@dataclass(frozen=True, slots=True)
@ -135,14 +139,14 @@ def _fail_many(proxy: ProxyClient, key: str, model: str, override: RouterSetting
def _read_rss_everywhere_after_pause(proxy: ProxyClient) -> tuple[RssReading, ...]:
time.sleep(MEMORY_RSS_SAMPLE_INTERVAL_SECONDS)
return tuple(
RssReading(replica, body.worker_pid, body.memory.ram_usage_mb)
RssReading(replica, body.hostname, body.worker_pid, body.memory.ram_usage_mb)
for replica, result in proxy.memory_summary_everywhere().items()
for body in (unwrap(result),)
if body.memory.ram_usage_mb is not None
)
def _settled_rss_per_worker(proxy: ProxyClient) -> Mapping[tuple[str, int], RssReading]:
def _settled_rss_per_worker(proxy: ProxyClient) -> Mapping[WorkerKey, RssReading]:
readings: Final = tuple(
reading for _ in range(MEMORY_RSS_SETTLE_SAMPLES) for reading in _read_rss_everywhere_after_pause(proxy)
)
@ -156,7 +160,7 @@ def _settled_rss_per_worker(proxy: ProxyClient) -> Mapping[tuple[str, int], RssR
def _heaviest_worker_growth(
warm: Mapping[tuple[str, int], RssReading], after: Mapping[tuple[str, int], RssReading]
warm: Mapping[WorkerKey, RssReading], after: Mapping[WorkerKey, RssReading]
) -> WorkerGrowth:
growths: Final = tuple(WorkerGrowth(warm[worker], after[worker]) for worker in warm.keys() & after.keys())
assert growths, (
@ -232,8 +236,9 @@ def test_failing_requests_do_not_grow_rss_or_stored_request(
assert heaviest.growth_mb <= MEMORY_RSS_BUDGET_MB, (
f"proxy RSS grew {heaviest.growth_mb:.1f} MB over a second batch of {MEMORY_REQUESTS_PER_PHASE} failing "
f"requests ({MEMORY_RETRIES_PER_REQUEST} retries each plus a fallback) after an identical warmup batch, "
f"past the {MEMORY_RSS_BUDGET_MB:.0f} MB budget: worker pid {heaviest.warm.worker_pid} at "
f"{heaviest.warm.replica} settled at {heaviest.warm.ram_usage_mb:.1f} MB warm and "
f"past the {MEMORY_RSS_BUDGET_MB:.0f} MB budget: worker pid {heaviest.warm.worker_pid} on "
f"{heaviest.warm.hostname or 'an unnamed host'} behind {heaviest.warm.replica} settled at "
f"{heaviest.warm.ram_usage_mb:.1f} MB warm and "
f"{heaviest.after.ram_usage_mb:.1f} MB after; failing requests are leaking memory the way the "
f"v1.100.0 retry breadcrumbs did"
)

View file

@ -0,0 +1,16 @@
import os
import socket
import pytest
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.debug_utils import get_memory_summary
@pytest.mark.asyncio
async def test_memory_summary_names_the_host_and_worker_that_answered() -> None:
summary = await get_memory_summary(UserAPIKeyAuth())
assert summary["hostname"] == socket.gethostname()
assert summary["worker_pid"] == os.getpid()
assert summary["memory"]["ram_usage_mb"] > 0

View file

@ -4136,6 +4136,7 @@ export interface paths {
*
* Returns:
* - worker_pid: Process ID
* - hostname: Host (the pod on Kubernetes) the worker runs on
* - status: Overall health based on memory usage
* - memory: Process memory usage and RAM info
* - caches: Cache item counts and descriptions