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:
- Node-level config (recommended)
- Init container с elevated privileges
- SecurityContext с
SYS_RESOURCEcapability
При исчерпании 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 tile —
kubectl get pod -n common-bifrost-proxy -o jsonpath='{.items[0].status.containerStatuses[0].restartCount}'. Тренд за окно 24ч — slow leak растёт линейно, burst — спайками. /healthendpoint —kubectl 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 картину
Источники
Сноски
-
Bifrost README, accessed 2026-05-19, https://github.com/maximhq/bifrost. Цит. по сессии
ildar/4e9b6d1a. ↩ -
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. ↩ -
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. ↩ -
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