Reference-карта того, куда уходит память в работающем bifrost-инстансе под production-нагрузкой, как runtime взаимодействует с container-cgroup, какие failure modes из этого следуют и как их диагностировать. Эта страница для случая «опять что-то с памятью у bifrost, с чего начать»; конкретные исправления / варианты хранения логов — в bifrost-logging.

Vendor claim vs production reality

bifrost позиционируется как «50× быстрее LiteLLM», «11 µs overhead per request», «50MB sustained while handling thousands of RPS», «68% less memory than LiteLLM»1. Эти числа технически правда — но описывают:

  • Idle / baseline — proxy без активной нагрузки. Real per-request overhead действительно минимальный.
  • Сравнение с Python-base — LiteLLM проигрывает на GIL и async overhead, Go этих проблем не имеет.
  • Optimized benchmark workload — короткие синтетические requests, не настоящие LLM streaming responses на 5-30 секунд.

В реальности на staging под algo-hub fanout мы наблюдали footprint в 5-10× выше заявленных 50MB — и это нормально, потому что claim не про этот workload. Карта ниже описывает где живёт разница.

Anatomy — что аллоцируется

Pre-allocated worker pools и buffers per provider

Bifrost держит static-allocated worker goroutines и буфер pending requests для каждого настроенного провайдера. Размеры — в <provider>.concurrency_and_buffer_size в config.json; default’ы дают разумный footprint для unknown workload, но не подстроены под наш фактический трафик.

Footprint не зависит от текущей загрузки — если 5 провайдеров × concurrency=50 × buffer_size=75, эта RAM-нагрузка существует даже при нулевом трафике. Upstream issue про dynamic scaling2 фиксирует это как known limitation:

“If no traffic routes to a provider, its pre-allocated memory and goroutines sit idle.”

Practical implication: добавление нового провайдера в configmap увеличивает baseline footprint, даже если трафик через него ещё не идёт. Под наш workload (50 concurrent peak) — формула concurrency = peak_concurrent, buffer_size = 1.5 × concurrency.

In-flight request buffering

Per active request bifrost держит в памяти:

  • Request body — full payload caller’а (system prompt + user messages + tool definitions + parameters). Для LLM-вызовов 5-100KB типично, для больших contexts (Lp(a) Lipoprotein(a) personalizer с массивом reference data) — десятки-сотни KB.
  • Accumulating response buffer — LLM streaming response chunks накапливаются до полного ответа. Для long-form responses (compute LLM с tool-calls + JSON output) — 50-500KB+ per turn.
  • Retry / fallback state — при cascade fallback (bifrost поддерживает Keywords/Selection cascade Gemini Flash → GPT-5.2 → Gemini Flash → GPT-5.2) bifrost держит copy исходного request, чтобы retry’нуть на следующий провайдер.
  • Stream-flag state — для SSE responses держится open connection + chunk buffer до closure.

При 30+ concurrent calls по 5-30 секунд × payload buffers + retry copies — десятки-сотни MB только in-flight state, без логирования и admin UI.

BifrostResponse object allocations

Upstream issue #2713 фиксирует что bifrost создаёт fresh BifrostResponse objects для каждого call без reference counter pool. То есть allocation pressure растёт линейно с RPS, GC должен collect’ить каждый объект отдельно. Open issue, не решён upstream’ом.

Practical implication: при высоком RPS GC становится hot path. Особенно заметно когда GOGC=100 (default — относительно частый GC) сталкивается с burst load — heap может временно расти, ожидая следующий GC cycle.

Goroutine stacks

Go стартует каждую goroutine с 8KB stack, рост до ~1MB на deep call chain. Bifrost spawn’ит goroutine per active request plus internal worker goroutines per provider. При 50 concurrent inference calls + plugin chain depth 3-5 уровней + provider worker pool — суммарно сотни goroutines, десятки MB stack memory.

Plugin chain clones

Каждый plugin в chain может clone request или response объект для своих преобразований (bifrost-custom-plugin-loading). Если plugin pipeline depth 3-4 (governance → schema-mocker → fhir-postprocessor → logging hook), payload copies в плагин chain’е складываются.

SQLite logs.db (когда logs_store.enabled: true)

Сам процесс sqlite держит page cache в памяти, плюс write-ahead log buffers. Сам по себе не катастрофично, но default 365-day retention без VACUUM делает файл monotonically растущим — был источником slow OOM на нашем staging до bifrost-logging Phase 1.

С logs_store.enabled: false этот источник полностью устранён, но architectural fact — bifrost умеет писать в SQLite/Postgres logs/config stores in-process, не через external sink.

Admin UI assets

Bifrost-bin embeds полный Next.js admin UI (//go:embed all:ui) — JavaScript bundles, fonts, images. При запросе на admin endpoint bifrost serve’ит из памяти (не из диска). Footprint ~5-15MB постоянно, но это static, не растёт с трафиком.

Container / runtime interaction problems

Bifrost — Go-binary, и взаимодействие Go runtime с k8s cgroup-limit имеет несколько известных gotchas.

Go без GOMEMLIMIT не знает про cgroup

Default Go runtime смотрит на host memory (через /proc/meminfo), не на cgroup limit контейнера. На GKE node с 32GB RAM Go думает что у него 32GB, allocate’ит heap до того как cgroup кикнет процесс OOM. GC реагирует на process-level memory pressure (heap growth относительно GOGC), не на «остаётся 10% до hard limit».

Fix: GOMEMLIMIT=900MiB (90% от 1Gi container limit). Это soft limit — Go становится агрессивен с GC при подходе, оставляя headroom для off-heap (goroutine stacks, syscall buffers, plugin state). Без него runtime overshoot’ит limit между GC cycles → cgroup-kill.

GOGC tradeoff — throughput vs memory

GOGC (default 100) контролирует когда запускать GC — при росте heap на N% от target. Высокий (200+) → GC реже, throughput выше, peak memory выше. Низкий (50-100) → GC чаще, throughput чуть ниже, peak memory ниже.

Upstream bifrost docs4 рекомендуют:

  • Memory-constrained → GOGC=50-100
  • High-throughput (2000+ RPS) → GOGC=200-400
  • Latency-sensitive → GOGC=50-100

Наш case — memory-constrained, тарифицируем RAM (1Gi), не CPU. Default 100 corretct, поднимать не имеет смысла.

nofile ulimit — node-level в K8s

В Kubernetes file descriptor limits ставятся на node-level, не per-container. Default в контейнере ~1024. Upstream docs4:

  • < 1000 concurrent connections → 4096
  • 1000-5000 → 16384
  • 5000-10000 → 32768
  • 10000 → 65536+

Три способа поднять в K8s:

  1. Node-level config (recommended)
  2. Init container с elevated privileges
  3. SecurityContext с SYS_RESOURCE capability

При исчерпании FD bifrost не падает по OOM — падает по too many open files, что симптоматически похоже (pod CrashLoop), но другая природа. Диагностируется через ulimit -n внутри контейнера + lsof count.

Default static config under-provisioned для production

Default concurrency_and_buffer_size в bifrost-chart рассчитаны на light traffic. При выросшем production load (algo-hub fanout 30-50 concurrent calls × cascade fallback × multi-turn agent flows) defaults становятся bottleneck — pending queue fill’ится, requests ждут worker’а, latency растёт.

Tuning формулы из upstream docs (concurrency = peak_RPS_per_provider, buffer_size = 1.5 × concurrency, client.initial_pool_size = 1.5 × total_concurrent) — practical baseline, не optimum для каждого workload.

Failure modes

Память может проявить себя одним из паттернов — каждый требует разной reaction.

Slow OOM (часы)

Pod рестартает по OOMKilled с регулярным интервалом 4-8 часов как метроном. Memory plot — monotonic growth from baseline до hard limit. Restart count растёт линейно по времени, но не моментально.

Типичные причины: persistent storage backend (SQLite logs.db без retention, накопленный state в plugin) или connection pool leak (unclosed HTTP clients к провайдерам).

Где смотреть: kubectl top pod за окно 6-12 часов (если есть Prometheus history) — увидишь growth curve. kubectl describe pod после OOMKilled показывает Reason: OOMKilled, Exit Code: 137 с длинным uptime до termination.

Burst OOM (минуты)

Pod рестартает внезапно через 10-30 минут под нагрузкой, на idle живёт стабильно. Memory plot — flat baseline, потом резкий spike до limit. Корреляция с upstream traffic peak (deploy burst, scheduled job, batch processing).

Типичные причины: concurrent in-flight buffering превышает limit при пиковом трафике. Каждый отдельный call helper, агрегат — нет.

Где смотреть: baseline memory низкий → подозрение на burst, не leak. Timestamp OOMKilled коррелирует с upstream traffic spike (логи algo-hub, b2c-dashboard). Calculation: peak_concurrent × avg_payload × ~3 (request + response + buffer) против container limit.

GC thrashing

OOM нет, но CPU spike периодически, tail latency растёт. p99 latency скачет, p50 спокоен. Memory держится у GOMEMLIMIT soft cap’а, GC работает непрерывно.

Типичные причины: GOMEMLIMIT слишком близок к real working set — Go вынужден collect’ить on каждый allocation. Или allocation pressure (BifrostResponse без pool) выше чем GC может handle.

Где смотреть: kubectl top pod показывает CPU >50% при стабильной нагрузке. Метрика типа go_gc_duration_seconds (если prometheus plugin активен) — high. Pod не рестартает, но юзеры жалуются на latency spikes.

File descriptor exhaustion

too many open files в логах. Pod может вести себя «жив но не отвечает» — accept new connections fails, existing держатся. K8s probe может pass на /health если probe-connection уже установлен, но новые connections от caller’ов отшибаются.

Типичные причины: nofile ulimit низкий, plus leaked connection (provider HTTP client не закрылся, lingering websocket к admin UI).

Где смотреть: kubectl exec deploy/bifrost-proxy -- ulimit -n показывает текущий limit. kubectl exec ... -- ls /proc/1/fd | wc -l — текущий count. Если близко к limit — exhaustion.

Diagnostic signals

Минимальный набор для триажа bifrost memory issue:

  • kubectl top pod -n common-bifrost-proxy — текущая память + CPU. Сравнить с resources.limits.memory в configmap.
  • kubectl describe pod -l app.kubernetes.io/name=bifrost-proxy | grep -A2 "Last State\|Reason:" — был ли OOMKilled, когда, после какого uptime.
  • Restart count tilekubectl get pod -n common-bifrost-proxy -o jsonpath='{.items[0].status.containerStatuses[0].restartCount}'. Тренд за окно 24ч — slow leak растёт линейно, burst — спайками.
  • /health endpointkubectl exec deploy/bifrost-proxy -- wget -q -O- http://localhost:8080/health. JSON {"components":{"db_pings":"ok"},"status":"ok"} означает HTTP server + DB up. Не покрывает GC thrashing — /health отвечает 200 даже когда GC пожирает CPU.
  • kubectl exec ... -- ls /proc/1/fd | wc -l + ulimit -n — FD count vs limit.
  • Bifrost prometheus plugin — если активирован, экспортирует bifrost_request_duration_seconds, bifrost_provider_errors_total, go_gc_duration_seconds, go_memstats_*. Capability есть в bifrost binary, плагин не активирован на нашем staging (видно в startup log: prometheus plugin not found, skipping telemetry middleware). Активация даст pull-based metrics for Grafana / VictoriaMetrics.
  • kubectl logs deploy/bifrost-proxy --tail=100 — startup log показывает actual config (worker pool sizes, log retention) + HTTP-access события admin UI; inference events в stdout не идут.

Связано

  • bifrost — entity-страница vendor’а сама по себе, операционные gotchas, наш use-case
  • bifrost-logging — конкретный decision как мы закрыли SQLite-leak источник + memory-tuning Phase 1b
  • bifrost-custom-plugin-loading — plugin chain registration, влияет на memory cost plugin-pipeline
  • bifrost-mock-plugins — наши плагины, добавляют allocation pressure на mock-flow
  • llm-observability-stack — где gateway-слой умещается в общую observability картину

Источники

Сноски

  1. Bifrost README, accessed 2026-05-19, https://github.com/maximhq/bifrost. Цит. по сессии ildar/4e9b6d1a.

  2. Upstream bifrost issue #128 «Dynamic Scaling of Worker Pool and Buffer Sizes per Provider», accessed 2026-05-19, https://github.com/maximhq/bifrost/issues/128. Фиксирует static pre-allocation как known limitation. Цит. по сессии ildar/4e9b6d1a.

  3. Upstream bifrost issue #271 «Implement Reference Counter Pool for BifrostResponse Objects to Reduce Memory Allocations», accessed 2026-05-19, https://github.com/maximhq/bifrost/issues/271. Open, no reference counter pool в текущем коде → каждый response создаёт fresh objects. Цит. по сессии ildar/4e9b6d1a.

  4. Bifrost Docker performance tuning docs, accessed 2026-05-19, https://docs.getbifrost.ai/deployment-guides/docker-tuning. Sizing tables (CPU/memory by RPS), GOMEMLIMIT/GOGC/nofile рекомендации, K8s-specific guidance. Цит. по сессии ildar/4e9b6d1a. 2