Є у нас сервер, на якому будемо запускати self-hosted LLM. Довго вибирали на чому саме запускати моделі – vLLM, SGLang, або llama.cpp, і врешті-решт зупинились саме на llama.cpp – принаймні на даний момент.
А сьогодні тема теж по моніторингу – але моніторингу саме llama.cpp.
Сервер ще не в production, реального трафіку нема, тому і графіки не дають якоїсь картини – але для початку просто розберемось з метриками і познайомимось з тим, що ми можемо побачити по роботі llama.cpp.
Що будемо робити:
розберемось з деякими нюансами того, як llama.cpp віддає метрики
глянемо які основні метрики вона повертає
налаштуємо збір метрик до VictoriaMetrics – бо тут теж є нюанс
і глянемо приклад Grafana dahshboard
Аби llama.cpp почала генерацію метрик – при запуску додаємо опцію --metrics, ендпоінт – стандартний, /metrics.
Але далі є один важливий момент в тому, як саме ці метрики отримувати.
llama.cpp: метрики в single-model vs router mode
llama.cpp може працювати в двох режимах:
single-model: один інстанс llama.cpp обслуговує запити до одної моделі
router mode: в такому режимі вона має декілька сконфігурованих моделей, які запускає при потребі
При роботі в single-model всі метрики доступні стандартно при запитах до /metrics.
Але при роботі в router mode просто запит до /metrics поверне помилку 400:
# curl 'http://127.0.0.1:31000/metrics'
{"error":{"code":400,"message":"model name is missing from the request","type":"invalid_request_error"}}
Бо в цьому режимі llama.cpp очікує параметр ?model=<MODEL_NAME>:
# curl 'http://127.0.0.1:37259/metrics?model=gemma-4-26b-a4b-it-q8'
# HELP llamacpp:prompt_tokens_total Number of prompt tokens processed.
# TYPE llamacpp:prompt_tokens_total counter
llamacpp:prompt_tokens_total 26
# HELP llamacpp:prompt_seconds_total Prompt process time
# TYPE llamacpp:prompt_seconds_total counter
llamacpp:prompt_seconds_total 0.093
...
Є і інший варіант: при роботі в router mode кожна модель запускається окремим інстансом (процесом) llama.cpp і слухає окремий порт:
Тут на порту 31000 маємо “master process”, який, власне, займається роутингом запитів до моделей, а другий процес на порту 37259 приймає запити до Gemma-4.
Тому можна просто отримувати метрики через 127.0.0.1:37259/metrics:
# curl -s http://127.0.0.1:37259/metrics
# HELP llamacpp:prompt_tokens_total Number of prompt tokens processed.
# TYPE llamacpp:prompt_tokens_total counter
llamacpp:prompt_tokens_total 83
# HELP llamacpp:prompt_seconds_total Prompt process time
# TYPE llamacpp:prompt_seconds_total counter
llamacpp:prompt_seconds_total 0.36
...
Втім, цей варіант не дуже надійний, бо для кожної моделі треба знати порти, які можуть змінюватись – тому краще використовувати саме “мастер-інстанс”.
Бо чисто імхо – це, звісно, доволі дивне рішення: чому не можна було просто зробити ім’я моделі в лейблі і повертати метрику типу llamacpp:prompt_tokens_total{model_name="gemma-4-26B"}?
Але розробникам llama.cpp видніше, якісь причини зробити саме так, як є зараз явно були, тому маємо те, що маємо.
Метрики llama.cpp
Звісно, краще було б спочатку поганяти llama.cpp локально і подивитись на її налаштування взагалі, бо я з локальних рантаймів грався тільки з Ollama, і це було давно (див. AI: знайомство з Ollama для локального запуску LLM), але зараз на це нема часу – тому розберемось з метриками “на льоту”.
llamacpp:prompt_tokens_total: загальна кількість токенів вхідних промптів, оброблених від моменту запуску сервера
llamacpp:prompt_seconds_total: сумарний час у секундах, витрачений на обробку вхідних промптів від моменту запуску сервера
llamacpp:prompt_tokens_seconds: середня швидкість обробки промптів в токенах на секунду (більше значення – швидше обробка)
llamacpp:tokens_predicted_total: скільки токенів згенеровано для відповідей
llamacpp:tokens_predicted_seconds_total: скільки часу витрачено на генерацію відповідей
llamacpp:predicted_tokens_seconds: аналогічно до prompt_tokens_seconds – середня швидкість генерації відповіді в токенах на секунду (більше токенів в секунду згенеровано – швидше генерація)
llamacpp:requests_processing: скільки запитів від клієнтів оброблюється зараз
корисно було б порівнювати із загальною кількістю слотів (задається в --parallel – максимальна кількість паралельної обробки запитів), але окремої метрики на це (поки що?) нема
можна отримати значення з GET /props і поля total_slots, або GET /slots – порахувати кількість елементів, або просто читати значення --parallel з конфігу
llamacpp:requests_deferred: кількість запитів, які зараз очікують на початок обробки (наприклад, через нестачу ресурсів чи зайнятість слотів)
llamacpp:n_tokens_max: найбільша кількість токенів в контекстному вікні
тут теж було б цікаво порівнювати з розміром самого контексту (значення з --ctx-size) – але в метриках теж нема
llamacpp:n_decode_total: кількість запусків обчислень моделі (виклики функції llama_decode()) для обробки вхідних або згенерованих токенів
або, умовно кажучи – скільки раз llama.cpp “прогнав” дані через модель
маємо на увазі, що один виклик llama_decode() може обробляти одразу кілька токенів і запити з кількох слотів – а тому ця метрика не дорівнює кількості токенів або запитів
якщо n_busy_slots_per_decode ~1 – запити оброблюються послідовно, якщо більше – то запити об’єднуються в batches, які потім передаються до моделі через виклик llama_decode()
llamacpp:spec_decode_num_draft_tokens_total: кількість токенів, запропонованих draft-моделлю при використанні Speculative Decoding
при використанні speculative decoding запит спочатку передається простішій, “допоміжній” моделі, яка швидко генерує кілька наступних токенів, а основна модель потім перевіряє їх одним batch і приймає ті, що підходять
задається опцією --spec-type, але у нас (поки?) не використовується
llamacpp:spec_decode_num_accepted_tokens_total: кількість токенів, запропонованих draft-моделлю, і які були прийняті основною LLM
чим вище значення тут – тим ефективніша робота draft model і speculative decoding
llamacpp:spec_decode_num_drafts_total: кількість кроків перевірки припущень draft-моделі
llamacpp:spec_decode_num_accepted_tokens_per_pos_total: кількість прийнятих токенів для кожної позиції у припущені, які пропонує draft model
Збір метрик llama.cpp до VictoriaMetrics
Тут основна складність як раз в тому, що нам треба передавати конкретну модель для отримання метрик.
Опція 1: VMAgent та static_configs
Найпростіший варіант – просто описати кілька targets в scrape job, і кожній вказати власний параметр з іменем моделі.
Тут значення в params формує query-параметри, які додаються до URI, тобто весь запит буде виглядати як /metrics?autoload=false&model=gemma-4-26b-a4b-it-q8, див. How to modify scrape URLs in targets.
З autoload вказуємо llama.cpp не завантажувати модель, якщо вона зараз не запущена.
А в labels додаємо статичну лейблу з іменем моделі, аби потім простіше було робити графіки і алерти (власне саме те, чого я очікував від самої llama.cpp).
Деплоїмо, перевіряємо targets:
І перевіряємо метрики – запит sum({job="matrix-llama-cpp"}) by (__name__):
Опція 2: VMAgent та http_sd_configs
В PR є варіант автоматизації через додавання локального до llama.cpp сервісу, який опитує ендпоінт /models і повертає JSON з моделями (див. приклад в коментах до PR).
Тоді для VMAgent можна було б створити job із http_sd_configs типу такого:
Але, по-перше, це має сенс коли моделей багато або вони часто змінюються, по-друге – сподіваюсь, що сам PR таки приймуть, і колись будемо мати простіший спосіб отримання метрик, а тому зараз робити якусь автоматизацію сенсу нема. Тим більш, що конкретно в моєму випадку буде 2-3 моделі максимум.
Grafana dashboard
Є вже готові дашборди, наприклад llama.cpp Monitoring, і для початку можна просто взяти якусь таку і трохи змінити під себе.
Власне, я так і зробив, але додав можливість вибору моделі:
Відповідно, треба було оновити queries і додати sum by (model) (лейбла, яка задається в нашій scrape_job) та фільтр по змінній $model_name.
Наприклад, запит для графіку “Tokens per Decode”:
sum by (model) (
rate(llamacpp:tokens_predicted_total{model=~"$model_name"}[$__rate_interval])
)
/
on(model)
sum by (model) (
rate(llamacpp:n_decode_total{model=~"$model_name"}[$__rate_interval])
)
І вся дашборда зараз виглядає так – тут, знов-таки, реального трафіку ще нема, але загальна ідея поки така:
Є сервер з Ubuntu, на якому запущений один з наших сервісів. Сам сервіс ще в стадії Proof of Concept, тому в Kubernetes не переносимо – але ганяти трафік plain-text, тим більш коли це запити до AI Agents з реквестами та відповідями – ідея так собі.
Тому є задача додати NGINX як проксі до сервісів в Docker та налаштувати TLS/SSL.
Для отримання сертифіката використаємо Let’s Encrypt, а аби не морочитись кожні три місяці (чи вже місяць?) з renew – додамо автоматизацію апдейтів через AWS Route 53, бо сервер живе в AWS і домен ми тримаємо там жеж.
A short note: взагалі, правильно казати TLS, а не SSL – бо остання версія SSL 3.0 була опублікована ще у 1996 році, а TLS – сучасна його заміна з актуальною на сьогодні версією 1.3 від 2018 року – див. RFC 8446. Але я більше звик до виразу “SSL”, тому буду казати або SSL, або TLS/SSL.
SSMInstanceProfile – дефолтна AWS IAM Role для AWS Systems Manager, яка, очікувано, не дає доступу до AWS Route 53:
# aws route53 list-hosted-zones-by-name \
--query "HostedZones[?Name=='ops.example.co.' || Name=='example.co.'].[Name,Id]" \
--output table
aws: [ERROR]: An error occurred (AccessDenied) when calling the ListHostedZonesByName operation: User: arn:aws:sts::492***148:assumed-role/SSMInstanceProfile/i-02569635c6284bb35 is not authorized to perform: route53:ListHostedZonesByName because no identity-based policy allows the route53:ListHostedZonesByName action
Тому створимо власну IAM Role із доступом до SSM та окремою DNS Zone.
Створення AWS IAM Role
Переходимо в AWS IAM > IAM Roles, додаємо нову роль.
В Trusted entity залишаємо дефолтне значення AWS service, в Use case – теж нічого не міняємо, залишаємо EC2:
Аби SSM продовжував працювати, відразу додаємо AWS Managed IAM Policy AmazonSSMManagedInstanceCore – а потім додамо власну для AWS Route 53:
Зберігаємо:
Створення IAM Policy для Route 53
Аби обмежити доступ окремою DNS-зоною – бо certbot виконує і додавання, і видалення записів, і не варто давати йому доступ всюди – в Route 53 знаходимо Hosted Zone ID:
Переходимо в IAM > Policies, створюємо нову.
Міняємо дефолтне значення:
На нашу власну політику, яка дозволяє читання всіх зон в Route 53, але обмежує права на редагування тільки конкретною <HOSTED_ZONE_ID>:
Стандартно – для запитів на порт 80 виконуємо редірект на 443/SSL, задаємо шляхи до сертифікату, в proxy_pass задаємо порт 3001 до нашого сервісу в Docker:
Завжди не любив цю схему в Ubuntu з ручними сімлінками, і зазвичай для себе роблю нормальну просто через файли конфігурації в /etc/nginx/conf.d – але тут нехай буде “дефолтний” варіант.
Створюємо symlink з /etc/nginx/sites-available/ на /etc/nginx/sites-enabled/:
# nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
# systemctl reload nginx
Перевіряємо вже з HTTPS:
# curl -I https://morpheus-agent.ops.example.co
HTTP/1.1 404 Not Found
404 тут – нам ок, головне, що нема помилок SSL.
Ще можемо глянути з openssl – побачити більше інформації по нашому сертифікату:
Виникла на днях цікава ситуація з LiteLLM, коли з одного боку метрики показують, що було багато помилок “від провайдера”, а з іншого – в трейсах і алертах помилка була тільки один раз.
Довелось трохи покопатись і розібратись з деякими нюансами того, як LiteLLM взагалі рахує метрики і що вона дійсно пише в метрики і трейси.
Плюс вийшов непоганий гайд для себе самого на майбутнє по тому, як такі проблеми дебажити.
The Issue
Отже, що маємо:
о ~22:00 за UTC маємо перший спайк алертів за метрикою litellm_deployment_failure_responses_total з exception_class="ValueError"
о ~04:00 ранку – другий спайк з тою самою проблемою
Обидва спрацювали на один і той самий API Key конкретного сервісу, і в Grafana це виглядало так:
При цьому алерт спрацював тільки один раз близько 22:00, теж з exception_class="ValueError".
В результаті виявилось, що було дві проблеми з різними помилками – хоч і пов’язані.
І, що заплутало більше всього – це те, що в трейсах і метриках обидві проблеми відображались саме з exception_class="ValueError".
Поїхали розбиратись – треба знайти причину і, головне, зрозуміти чому ж клієнт отримав тільки один failed response, хоча на графіках помилок багато?
Debugging
Перше, що прийшло в голову: раз маємо помилки – то повинні мати і трейси з status_code:="2", логічно? Бо згадаємо OTel специфікацію otel/status_code:
Name of the code, either “OK” or “ERROR”. MUST NOT be set if the status code is UNSET.
В цифрах це:
0: UNSET
1: OK
2: ERROR
Єдине, що тут варто уточнити один момент: зазвичай інструментарії OTel не задають значення “OK”, і якщо запит завершився без помилок – то він буде саме з 0/UNSET, див. Set Status.
Шукаємо всі трейси по цьому ключу з кодом “2”, запит для VictoriaMetrics:
exception.message: “Not allowed to access model due to tags configuration. Passed model=gpt-5.6-luna and tags=None“
exception.type: “ValueError“
І ту саму одну єдину помилку бачимо в самій LiteLLM в 01:11:06 – але тут Kyiv, UTC+3, тобто це 22:11:06 по UTC:
А 4 секунди різниці (22:11:02 в VictoriaTraces та 22:11:06 в LiteLLM) – бо в VictoriaTraces вказаний час початку запиту (з моменту отримання HTTP-запиту), а в LiteLLM > Logs подія записується по завершенню.
Тобто – трейси показують, що помилка була один раз – але метрика litellm_deployment_failure_responses_total відображає два спайки помилок в різний час.
Запит в VictoriaMetrics – те саме, що бачили в Grafana, тільки тут increase() замість rate():
Але! Якщо зробити той самий запит з litellm_deployment_failure_responses_total до VictoriaMetrics, тільки вже з групуванням по лейблам requested_model, litellm_model_name та api_provider і api_base – то побачимо цікаву картину:
В лейблі requested_model значення є – але всі інші пусті.
А значить коли ми отримали реквест від клієнта – то не змогли знайти Deployment/model, якому цей запит передати і, відповідно, в рамках цього routing attempt запит до провайдеру не потрапив.
При цьому зрештою запит клієнта все ж може бути оброблений через fallback routing – але про це трохи далі.
Зараз цікаво розібратись з іншим – значення метрики litellm_deployment_failure_responses_total інкрементиться навіть тоді, коли deployment ще не обраний і запиту до провайдера взагалі не було і, значить, litellm_deployment_failure_responses_total – це не просто “помилки, які виникли при запиті до або від провайдеру/моделі” і API провайдера – але і про роботу самої LiteLLM.
Аби зрозуміти на що ж саме спрацьовує litellm_deployment_failure_responses_total – глянемо в код LiteLLM.
LiteLLM та код метрики litellm_deployment_failure_responses_total
litellm_deployment_failure_responses – це counter, створюється стандартною Prometheus-бібліотекою prometheus-client.
Ось, де відбувається її інкремент в коді LiteLLM – файл prometheus.py:
...
def set_llm_deployment_failure_metrics(self, request_kwargs: dict):
...
exception: Final = request_kwargs.get("exception", None)
...
if exception is not None:
PrometheusLogger._inc_labeled_counter(
self,
self.litellm_deployment_failure_responses,
"litellm_deployment_failure_responses",
enum_values,
label_context=_deployment_label_ctx,
)
...
async def get_deployments_for_tag(
...
if len(new_healthy_deployments) == 0 and len(default_deployments) == 0:
raise ValueError(
f"{RouterErrors.no_deployments_with_tag_routing.value}."
f" Passed model={model} and tags={request_tags}"
)
...
Де “RouterErrors.no_deployments_with_tag_routing.value” якраз має те саме повідомлення – router.py:
class RouterErrors(enum.Enum):
...
no_deployments_with_tag_routing = "Not allowed to access model due to tags configuration"
...
Тобто значення метрики litellm_deployment_failure_responses_total інкрементиться не тільки при помилках від провайдера – але і при помилках у внутрішніх механізмах самого LiteLLM, в цьому випадку – при помилках роутингу/вибору моделей.
І якщо ми глянемо логи:
То весь цей час це була одна і та ж сама проблема з тегами – а не з запитами до провайдера.
Але… При цьому у нас реальних помилок повернених клієнту була тільки одна, яку ми бачимо в трейсах, бо в усіх інших випадках спрацьовував fallback – і клієнт врешті-решт отримував свій респонс:
І ці fallbacks ми бачимо по litellm_deployment_successful_fallbacks_total:
Але тоді…
Звідки взявся той єдиний трейс зі status_code="2"?
OpenAI та “Invalid prompt: your prompt was flagged as potentially violating our usage policy”
Якщо глянемо на метрику litellm_deployment_failed_fallbacks_total – то якраз о 22:12 побачимо невдалий fallback:
І саме він потрапив в трейси з ValueError – але цього разу з іншої причини, яка знайшлась в логах з повідомленням від OpenAI “Invalid prompt: your prompt was flagged as potentially violating our usage policy“:
Тобто:
~22:11 UTC ми отримали від клієнта запит
LiteLLM перевірив tag based routing, виявив, що нема моделей, на які можна відправити цей запит – і повернув ValueError
значення litellm_deployment_failure_responses_total{exception_class="ValueError"} збільшилось на 1
не знайшовши деплойментів, на які можна відправити запит – LiteLLM перейшов до fallback опцій і все ж відправив цей запит до OpenAI
але OpenAI відхилив його з помилкою “Invalid prompt: your prompt was flagged as potentially violating our usage policy” (ContentPolicyViolationError в exception_mapping_utils.py)
fallback сфейлився – бо інших опцій не було
в результаті ми повернули помилку клієнту, яку бачимо в метриках litellm_deployment_failed_fallbacks_total та litellm_proxy_failed_requests_metric_total
LiteLLM та exception_class “ContentPolicyViolationError” vs “ValueError”
Але якщо ми отримали ContentPolicyViolationError – то чому в exception_class метрик litellm_deployment_failed_fallbacks_total та litellm_proxy_failed_requests_metric_total все одно відображалась ValueError?
...
async def async_function_with_fallbacks_common_utils(
...
original_exception = e
...
А ContentPolicyViolationError, отриманий під час fallback, потрапляє в окремий new_exception і записується в лог – тоді як Router повертає original_exception зі значенням ValueError, яке було задане при помилках роутингу:
...
except Exception as new_exception:
...
raise original_exception
...
В результаті ми всюди бачимо ValueError – що, в принципі, логічно, бо саме це стало першопричиною проблеми – але фактично помилка клієнту була викликана саме ContentPolicyViolationError – яку ми бачимо тільки в логах.
І якби tag routing відпрацював без помилок – то ми б мали саме ContentPolicyViolationError як exception_class.
Замість висновків
Отже, що маємо в результаті:
метрика litellm_deployment_failure_responses_total – це не лише помилки API провайдера, і ця метрика може інкрементитись через внутрішні помилки LiteLLM ще до вибору deployment, наприклад на етапі routing
exception_class="ValueError" повідомляє лише клас помилки, але не її конкретну причину, а ValueError може виникати в різних частинах коду, тому кожен такий випадок варто досліджувати окремо – принаймні на початку використання LiteLLM
фінальний trace не завжди містить усю історію обробки запиту – в нашому випадку він показував початковий ValueError, тоді як причина провалу fallback – ContentPolicyViolationError від OpenAI залишилася лише в логах
варто створити recording rules для основних помилок та окремі log-based alerts для відомих повідомлень про помилки, бо самих метрик і трейсів може бути недостатньо, щоб швидко знайти справжню причину проблеми
Bonus: приклад VictoriaLogs RecordingRule для “ContentPolicyViolationError”
З’явився у нас новий інстанс, hardware server, на якому будуть наші self-hosted LLM.
На сервері буде NixOS – вибір не мій, але система цікава, давно про неї чув – і зараз є чудова нагода з нею познайомитись.
Що треба зробити особисто мені – це поки що тільки налаштувати моніторинг: стандартні CPU/RAM/диски, метрики NVIDIA і пізніше – LLM runtime, в ролі якого, мабуть, буде SGLang.
Ну і перше, з чим треба розібратись – це як встановлювати пакети в NixOS і взагалі виконувати різні налаштування.
Не буду детально заглиблюватись в деталі того, як це все в NixOS організовано – цей пост більше суто практичний з прикладами, ну і HowTo для мене самого потім.
Що будемо робити:
створимо віртуалку в VirtualBox, встановимо туди NixOS
там подивимось на основні конфіги і як встановлюються нові пакети
потім напишемо конфіги для нашого вже існуючого реального серверу
і подивимось, як деплоїти ці зміни по SSH
Забігаючи наперед – система дійсно прикольна: мати всі налаштування в GitHub, всі зміни робити через пул-реквести, деплоїти на remote host по SSH – виглядає дуже круто. Такий собі “Ansible з коробки”, ще і з всякими rollback-опціями.
[root@nixos:~]# lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
loop0 7:0 0 1.5G 1 loop /nix/.ro-store
sda 8:0 0 20G 0 disk
sr0 11:0 1 1.6G 1 rom /iso
Створюємо розділ на /dev/sda:
[root@nixos:~]# parted /dev/sda -- mklabel msdos
Information: You may need to update /etc/fstab.
[root@nixos:~]# parted /dev/sda -- mkpart primary ext4 1MiB 100%
Information: You may need to update /etc/fstab.
[root@nixos:~]# parted /dev/sda -- set 1 boot on
Information: You may need to update /etc/fstab.
Тут жеж в users.users можемо описати і юзерів в системі – додаємо в кінець файлу, перед закриваючою “}“, відступи ролі не грають – просто для зручності:
# nixos-install
...
setting root password...
New password:
Retype new password:
passwd: password updated successfully
installation finished!
Задаємо пароль юзеру setevoy – хоча можна було задати прямо в конфігу з опціями initialPassword або initialHashedPassword:
[root@nixos:~]# nixos-enter --root /mnt
setting up /etc...
[root@nixos:/]# passwd setevoy
New password:
Retype new password:
passwd: password updated successfully
Перезавантажуємо віртуалку – і система готова до роботи:
Установка пакетів – nixos-rebuild та nix-shell
В конфігу vim не додав, тому окремо – заодно глянути, як взагалі додавати нові пакети в систему.
З nix-shell можемо запустити окремий тимчасовий шел відразу з пакетом vim (хоча nix-shell це наче legacy, і можна робити з nix shell nixpkgs#vim):
[root@nixos:~]# nix-shell -p vim
these 2 paths will be fetched (12.4 MiB download, 43.2 MiB unpacked):
/nix/store/b0ga0wazy69p0264ki9jaxd5wqxyvcm4-stdenv-linux
/nix/store/39shwxhbkgnfr9qfysmwby7ma1rcckp0-vim-9.2.0541
copying path '/nix/store/b0ga0wazy69p0264ki9jaxd5wqxyvcm4-stdenv-linux' from 'https://cache.nixos.org'...
copying path '/nix/store/39shwxhbkgnfr9qfysmwby7ma1rcckp0-vim-9.2.0541' from 'https://cache.nixos.org'...
[nix-shell:~]#
Установка vim
Аби vim був в системі постійно – редагуємо /etc/nixos/configuration.nix і з environment.systemPackages додаємо все, що треба для роботи:
environment.systemPackages = with pkgs; [
vim
git
curl
];
Так як робимо на вже інстальованій системі – то для застосування змін запускаємо nixos-rebuild – він прочитає всі зміни в файлах конфігурації і встановить пакети:
[root@nixos:~]# nixos-rebuild switch
building the system configuration...
...
Done. The new configuration is /nix/store/q7m7yygrwgr710iyhhi7yq01hjm753r1-nixos-system-nixos-26.05.6503.21ea275a7c46
Історія змін в системі та rollback
Опція switch для nixos-rebuild створює новий generation системи – окремий набір конфігів на кожну зміну, яку робимо в системі.
З nix-env --list-generations можна подивитись історію змін:
А з nixos-rebuild switch --rollback – відкотитись до попередньої.
Виглядає круто, хоча rollback не тестив.
Установка node_exporter
vim ми встановлювали з environment.systemPackages – але в такому випадку Nix просто встановлює сам бінарник і додає його до $PATH.
Якщо треба додати саме системний сервіс – з systemd-конфігами, портами, фаерволом, автостартом – для Node Exporter є окремий сабмодуль services.prometheus.exporters.node.
Всі Prometheus-експортери описані в exporters.nix, а сам node_exporter імпортується з файлу node.nix.
Редагуємо /etc/nixos/configuration.nix – включаємо сам node_exporter, дозволяємо до нього доступ на фаерволі, включаємо поки один колектор systemd (див. сам Node Exporter > Collectors):
Ще раз запускаємо nixos-rebuild switch – але цього разу білд впав з “SIGKILL 9“:
[root@nixos:~]# nixos-rebuild switch
building the system configuration...
evaluation warning: `boot.zfs.forceImportRoot` is using the default value of `true`. It is highly recommended to set it to `false`, the new default from 26.11 on, to reduce the risk of data loss. Alternatively, you can silence this warning by explicitly setting it to `true`.
Command 'nix-build '<nixpkgs/nixos>' --attr config.system.build.toplevel --no-out-link' died with <Signals.SIGKILL: 9>.
Дивимось логи:
[root@nixos:~]# journalctl -k -b | grep -iE 'out of memory|oom|killed process' | tail -20
Jul 31 13:35:49 nixos systemd[1]: Listening on Userspace Out-Of-Memory (OOM) Killer Socket.
...
Jul 31 13:42:56 nixos kernel: Out of memory: Killed process 1048 (nix-build) total-vm:1072896kB, anon-rss:876128kB, file-rss:292kB, shmem-rss:256kB, UID:0 pgtables:1912kB oom_score_adj:0
Додаємо пам’яті до віртуалки, бо я задав 2 гігабайти – не вистачило ¯\_(ツ)_/¯
Після установки – перевіряємо сервіс:
[root@nixos:~]# systemctl status prometheus-node-exporter
● prometheus-node-exporter.service
Loaded: loaded (/etc/systemd/system/prometheus-node-exporter.service; enabled; preset: ignored)
Active: active (running) since Fri 2026-07-31 14:42:44 UTC; 44s ago
І метрики:
[root@nixos:~]# curl -s http://localhost:9100/metrics | grep node_ | head
# HELP node_arp_entries ARP entries by device
# TYPE node_arp_entries gauge
node_arp_entries{device="enp0s3"} 2
# HELP node_boot_time_seconds Node boot time, in unixtime.
# TYPE node_boot_time_seconds gauge
node_boot_time_seconds 1.785505763e+09
# HELP node_context_switches_total Total number of context switches.
# TYPE node_context_switches_total counter
node_context_switches_total 344789
# HELP node_cooling_device_cur_state Current throttle state of the cooling device
Власне – з віртуалкою все.
Можна готувати деплої на реальний хост.
NixOS Flakes та реальний проект
Що треба буде зробити – це додати Node Exporter на наш реальний сервер і змінити параметри мережі, бо там зараз DHCP – а мені треба static.
Аби розуміти де робити зміни в репозиторії – коротко про структуру файлів в репозиторії:
В файлі flake.nix є блок inputs (зовнішні залежності як Git-репозиторії), а інші можуть мати imports – додаткові локальні файли, в яких ми будемо додавати свої конфіги.
Що треба буде зробити – це створити файл hosts/matrix/monitoring.nix, підключити його в imports файлу hosts/matrix/default.nix, перевірити та задеплоїти.
Створення monitoring.nix
Додаємо файл monitoring.nix, в ньому описуємо той самий prometheus.exporters.node, як робили в тестах вище:
Оскільки це Flake з типом git, то новий файл monitoring.nix має бути доданий до Git index, інакше Nix поверне помилку “error: Path ‘hosts/matrix/monitoring.nix’ … is not tracked by Git“.
[setevoy@setevoy-work ~] $ nix store info
Store URL: daemon
Version: 2.35.1
Trusted: 0
І запускаємо тест наших конфігів:
[setevoy@setevoy-work ~] $ nix flake check --no-build
warning: Git tree '/home/setevoy/Work/hOS/atlas-llm' is dirty
“Git tree .. is dirty” – то я файл до Git додав, але не комітив – поки можна ігнорувати.
NixOS та налаштування Static IP
Node Exporter додали, ще треба змінити налаштування мережі.
Тут в цілому нічого нового – знайти поточні параметри, змінити параметри у вже існуючому файлі networking.nix.
На реальному хості перевіряємо що у нас в мережі – нам треба ім’я інтерфейсу, Gateway IP, адреса DNS.
Знаходимо поточний IP, MAC та ім’я інтерфейсу:
[neo@matrix:~]$ ip -4 address show dev enp36s0f0
4: enp36s0f0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
altname enx04421ae605fb
inet 192.168.124.14/24 metric 1024 brd 192.168.124.255 scope global dynamic enp36s0f0
valid_lft 5624sec preferred_lft 5624sec
Знаходимо поточний Gateway, тут це 192.168.124.1:
[neo@matrix:~]$ ip -4 route
default via 192.168.124.1 dev enp36s0f0 proto dhcp src 192.168.124.14 metric 1024
...
І DNS:
[neo@matrix:~]$ resolvectl dns enp36s0f0
Link 4 (enp36s0f0): 192.168.128.254
Зараз там був описаний один конфіг DHCP з іменем “10-x550-primary“, де “10” – пріоритет, а “x550” – ім’я карти (“Ethernet controller [0200]: Intel Corporation Ethernet Controller X550“):
І, трохи забігаючи наперед, після деплою перевіряємо, що тепер дійсно static.
Самий простий варіант – глянути роути, має бути static:
[root@matrix:~]# ip -4 route
default via 192.168.124.1 dev enp36s0f0 proto static
172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1 linkdown
192.168.124.0/24 dev enp36s0f0 proto kernel scope link src 192.168.124.14
...
Власне, далі про деплой.
Nix on Amazon Linux та deploy по SSH
Деплоїти буду з окремого EC2 в AWS, бо там WireGuard тунель на наш “дата-центр” в США.
В принципі – все аналогічно тому, як робили на Arch Linux – встановити Nix, перевірити, і задеплоїти.
Тільки на Arch Linux деплой не робили – а тут зробимо, ще і по SSH.
Єдиний нюанс тут був в тому, що інстанс я робив t3.micro – бо там планувався тільки VPN/WG, а для Nix краще мати запас пам’яті – тому збільшимо до t3.medium.
Ну і сама установка – для Fedora є пакет nix і можна встановити з dnf, але на Amazon Linux ставив інсталятором самого Nix.
[ec2-user@ip-10-0-13-13 ~]$ cd atlas-llm/
[ec2-user@ip-10-0-13-13 atlas-llm]$ git status --short --branch
## master...origin/master
Запускаємо перевірку конфігів:
[ec2-user@ip-10-0-13-13 atlas-llm]$ nix flake check --no-build
Stack size hard limit is 10485760, which is less than the desired 62914560. If possible, increase the hard limit, e.g. with 'ulimit -Hs 61440'.
[ec2-user@ip-10-0-13-13 atlas-llm]$ echo $?
0
Завершилось успішно – але маємо warning про ліміти.
Linux ulimits для Nix
Редагуємо /etc/security/limits.d/90-nix.conf, додаємо user ulimits:
ec2-user soft stack 65536
ec2-user hard stack 65536
Ще раз re-login, перевіряємо, що ліміти застосовані:
[ec2-user@ip-10-0-13-13 atlas-llm]$ nix run github:NixOS/nixpkgs/nixos-26.05#nixos-rebuild -- \
test \
--flake .#matrix \
--build-host [email protected] \
--target-host [email protected]
...
starting the following units: systemd-tmpfiles-resetup.service
the following new units were started: prometheus-node-exporter.service
Done. The new configuration is /nix/store/5x016qjx58fkq4dnk8k1vn54xz7fma5y-nixos-system-matrix-26.05.20260726.8623c4c
Перевіряємо Node Exporter на хості з NixOS:
[root@matrix:~]# systemctl status prometheus-node-exporter.service
● prometheus-node-exporter.service
Loaded: loaded (/etc/systemd/system/prometheus-node-exporter.service; enabled; preset: ignored)
Active: active (running) since Tue 2026-08-04 10:18:01 UTC; 50s ago
І метрики:
[root@matrix:~]# curl -s localhost:9100/metrics | head
# HELP go_gc_duration_seconds A summary of the wall-time pause (stop-the-world) duration in garbage collection cycles.
# TYPE go_gc_duration_seconds summary
go_gc_duration_seconds{quantile="0"} 8.777e-06
go_gc_duration_seconds{quantile="0.25"} 1.09e-05
go_gc_duration_seconds{quantile="0.5"} 1.1713e-05
go_gc_duration_seconds{quantile="0.75"} 1.2955e-05
go_gc_duration_seconds{quantile="1"} 1.3205e-05
go_gc_duration_seconds_sum 5.755e-05
go_gc_duration_seconds_count 5
Якщо все ок – запускаємо ще раз nixos-rebuild, але вже з опцією switch замість test:
Стало цікаво як саме впевнитись, що зараз застосовані саме ті зміни, які бачили в результаті виконання nixos-rebuild -- switch.
В кінці роботи nixos-rebuild показаний шлях до конфігурації – /nix/store/ll36l8ml5znscmppa2rpwnp5s8k7rp86[...]:
...
restarting the following user units: nixos-activation.service
restarting sysinit-reactivation.target
Done. The new configuration is /nix/store/ll36l8ml5znscmppa2rpwnp5s8k7rp86-nixos-system-matrix-26.05.20260726.8623c4c
Це директорія зі згенерованими конфігами:
[root@matrix:~]# ll /nix/store/ll36l8ml5znscmppa2rpwnp5s8k7rp86-nixos-system-matrix-26.05.20260726.8623c4c
total 256
-r-xr-xr-x 1 root root 4977 Jan 1 1970 activate
dr-xr-xr-x 1 root root 110 Jan 1 1970 bin
-r--r--r-- 1 root root 1232 Jan 1 1970 boot.json
-r-xr-xr-x 1 root root 1971 Jan 1 1970 dry-activate
lrwxrwxrwx 1 root root 51 Jan 1 1970 etc -> /nix/store/z8jq92jch9rk9r354rj598q52rdf4b9b-etc/etc
...
І там можемо побачити файл /nix/store/ll36l8[...]/etc/systemd/network/10-x550-primary.network з тими змінами, які ми описували вище:
“ll36l8ml5znscmppa2rpwnp5s8k7rp86” – це hash part імені, який використовується в /nix/store/ll36l8ml5znscmppa2rpwnp5s8k7rp86-nixos-system-matrix-26.05.20260726.8623c4c (Closures – A Nix package’s dependency tree):
Далі ще треба буде налаштувати моніторинг NVIDIA та метрик SGlang, зробити всякі дашборди та алерти, ну і багато чого ще. Так що, скоріш за все, у цієї історії будуть наступні пости 🙂
Переключення будемо робити на LiteLLM, на яку вже йдуть запити від всіх наших сервісів – тому переключення буде доволі простим.
До OpenRouter в LiteLLM налаштуємо fallbacks напряму до OpenAI – на ті випадки, коли реквести до OpenRouter фейляться.
Підключення OpenRouter до LiteLLM
Взагалі все, що треба – це описати новий LiteLLM Deployment, модель, і для неї вказати api_base == OpenRouter та власний API Key.
Єдина відмінність – це ім’я моделі: якщо при прямих викликах до OpenAI ми вказуємо model = openai/gpt-4.1-nano, то для OpenRouter це буде model = openrouter/openai/gpt-4.1-nano, див. OpenRouter Completion Models.
Отримати список всіх моделей можна з OpenRouter API, див. документацію Models.
OpenRouter теж підтримує Automatic failover between models, але для цього треба робити зміни в коді клієнтів – а ми хочемо максимально прозоро і з мінімум змін на клієнтах.
В документації LiteLLM трохи mess (вже не перший раз, btw), бо в одному прикладі описано як litellm_settings.fallbacks – а в іншому в router_settings.fallbacks.
Але більш коректним саме router_settings, бо він має пріорітет – router.py:
...
_fallbacks = fallbacks or litellm.fallbacks
...
А fallbacks “приходить” саме із router_params в proxy_server.py:
...
router = litellm.Router(
**router_params,
...
Формат доволі простий – “модель, для якої вказуємо fallback : список моделей, на які роутимо при проблемах”:
Для fallback routing можна задати кілька корисних опцій:
num_retries: скільки раз повторити запит в основній model group перед переходом до fallback-моделі
якщо в model group є кілька deployments, retry може піти до іншого deployment без fallback
timeout: скільки часу чекати відповіді перед тим, як перенаправити запити до fallback model
після кожного timout починається наступний num_retries, якщо там задано більше 1, і тільки потім – до fallback mode
allowed_fails: скільки failed requests до моделі допустимо перед тим, як модель перейти в cooldown
при значенні 3 cooldown спрацює на четвертій невдалій спробі
cooldown_time: скільки часу секундах модель буде “відключена” від загального роутингу
Тут знов-таки документація трохи… крива, бо, наприклад, описано “allowed_fails: 3 # cooldown model if it fails > 1 call in a minute“, ну і замість “in a minute“, мабуть, малось на увазі 30 секунд в прикладі з cooldown_time.
Fallbacks та Prometheus metrics
Звісно, спрацювання – і помилки – фолбеків бажано моніторити.
Створюємо новий ключ, правда при створенні ключа йому не мона відразу вказати Tags, бо “This feature is only available for LiteLLM Enterprise“.
Але можна створити ключ – а потім в його Settings вже задати потрібний Tag:
І аналогічно – ключ з тегом “direct-openrouter“.
До router_settings додаємо enable_tag_filtering=true, а в model_list описуємо model group – два деплоймента з однаковим model_name, але різними умовами в tags:
Інший варіант – не додавати теги вручну, а просто використати User Agent з headers.
В документації Regex-based tag routing (tag_regex)tag_regex описаний як “Use tag_regex on a deployment to match incoming requests by their headers” – тобто наче по будь-якому заголовку, але по факту враховується тільки User-Agent (якщо я правильно прочитав код):
...
# Build header strings for regex matching from what the proxy already stores.
# Currently we match against User-Agent; format matches "^User-Agent: claude-code/..."
user_agent = metadata.get("user_agent", "")
header_strings: list[str] = [f"User-Agent: {user_agent}"] if user_agent else []
...
Втім, в моєму випадку цього достатньо – бо один сервіс у нас це TypeScript з user-agent = "OpenAI/JS 6.26.0", а інший – Python з user-agent = "OpenAI/Python 2.45.0".
Робиться для нашого нового сервісу, який ще в експерементальній/PoC фазі, але скоріш за все піде в продакшен, тому сетап такий собі “недо-production” – робимо з розрахунком на те, що потім це все діло будемо повноцінно менеджити і моніторити, але поки і не сильно заморочуємось зі всякими security-задачами.
Як і Valkey, запускати MongoDB будемо в Kubernetes, але на відміну з Valkey тут трохи більш і складно і печально, бо:
для MongoDB є ентерпрайз-версія, і частина компонентів та документації заточено під неї, що трохи заплутує – бо документації багато
нема адекватного і простого Helm-чарту – бо і сама система трохи складніша за Redis (ну – якщо не розгортати Redis Cluster)
watchNamespace: вказуємо Kubernetes Namespace, де буде потім буде MongoDBCommunity – оператор тут створить Roles && RoleBindings, і тільки в цих NS буде моніторити свої Custom Resources
watchedResources: будемо користуватись тільки MongoDBCommunity CRD, без Enterprise, Ops Manager, Search тощо – тому тут обмежуємо ресурси
enableClusterMongoDBRoles: CRD ClusterMongoDBRole не використовуємо, ролі для MongoDB будемо задавати на рівні MongoDBCommunity
enablePVCResize: включаємо можливість збільшення PVC (StorageClass має бути з allowVolumeExpansion)
resources: відразу додамо, потім в production підтюнимо значення
Helm-чарт оператора встановить потрібні CRD, ClusterRole та RoleBinding самого оператора в його власному неймспейсі – та RoleBinding і потрібні ServiceAccounts в неймспейсі нашого тестового інстансу MongoDB.
Створюємо неймспейси:
$ kk create ns test-mongodb-operator-ns
$ kk create ns test-mongodb-service-ns
$ kk get pod
NAME READY STATUS RESTARTS AGE
mongodb-kubernetes-operator-6b4cb5955b-wk9q5 1/1 Running 0 28s
Для створення інстансів MongoDB у watchNamespace оператор створює там ServiceAccount – перевіряємо, що він є і з цим ServiceAccount є права на створення Kubernetes StatefulSet:
З ним ми вже власне описуємо саме наш MongoDB, тому це вже робимо в іншому неймспейсі test-mongodb-service-ns, в якому буде сам наш сервіс, який буде цю MongoDB використовувати.
Kubernetes Secrets та паролі поки робимо руками, потім вже нормально – з AWS Secrets Manager та External Secrets Operator.
Створюємо сікрет з паролем для root на цьому інстансі MongoDB:
$ kk -n test-mongodb-service-ns create secret generic agents-mainframe-mongodb-admin-password \
--from-literal=password='test-mongodb-admin-password'
members: кількість MongoDB-процесів у MongoDB Replica Set (не плутати з Kubernetes ReplicaSet)
authentication.modes: SCRAM – стандартна аутентифікація по логіну-паролю
users: описуємо RBAC – задаємо ім’я root user
в passwordSecretRef передаємо та ім’я Kubernetes Secret, в якому зберігається його пароль (той, що створили вище)
scramCredentialsSecretName: ім’я Kubernetes Secret для MongoDB Operator, див. нижче
в roles – задаємо значення root, всі ролі є в документації Built-In Roles
Деплоїмо:
$ kk -n test-mongodb-service-ns apply -f mongodb-community.yaml
mongodbcommunity.mongodbcommunity.mongodb.com/agents-mainframe-mongodb created
Перевіряємо Pods:
$ kk -n test-mongodb-service-ns get pod
NAME READY STATUS RESTARTS AGE
agents-mainframe-mongodb-0 2/2 Running 0 2m30s
Перевіряємо стан CustomResource:
$ kk -n test-mongodb-service-ns get mongodbcommunity agents-mainframe-mongodb
NAME PHASE VERSION
agents-mainframe-mongodb Running 8.0.13
Перевіряємо аутентифікацію:
$ kk -n test-mongodb-service-ns exec -it \
agents-mainframe-mongodb-0 \
-c mongod \
-- mongosh \
--username admin \
--password \
--authenticationDatabase admin
Enter password: ***************************
Warning: Could not access file: EACCES: permission denied, mkdir '/data/db/.mongodb'
Current Mongosh Log ID: 6a60ce6f6671cfc48fce5f46
Connecting to: mongodb://<credentials>@127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000&authSource=admin&appName=mongosh+2.5.8
...
Error: Could not open history file.
REPL session history will not be persisted.
...
agents-mainframe-mongodb [direct: primary] test>
В логах є помилка “EACCES: permission denied, mkdir ‘/data/db/.mongodb‘” – але це від MongoDB Shell, не самого демона mongod: далі і є як раз “Error: Could not open history file.“.
Тому ігноруємо.
І через db.runCommand() пробуємо запустити якусь команду, наприклад – connectionStatus:
MongoDB Operator та Kubernetes Secrets для MongoDBCommunity
Ще з цікавого, що можна глянути і корисно знати – Kubernetes Secrets, які створюються самим MongoDB Operator, коли ми деплоїмо ресурс MongoDBCommunity:
$ kk -n test-mongodb-service-ns get secret
NAME TYPE DATA AGE
agents-mainframe-mongodb-admin-admin Opaque 4 19m
agents-mainframe-mongodb-admin-password Opaque 1 26m
agents-mainframe-mongodb-admin-scram-scram-credentials Opaque 6 21m
agents-mainframe-mongodb-agent-password Opaque 1 21m
agents-mainframe-mongodb-config Opaque 1 21m
agents-mainframe-mongodb-keyfile Opaque 1 21m
Тут agents-mainframe-mongodb-admin-password ми робили руками і передавали в CR manifest в полі passwordSecretRef, а інші:
agents-mainframe-mongodb-admin-scram-scram-credentials: це Secret для самого оператору – salt, sha-ключі
ім’я створюється зі значення в scramCredentialsSecretName нашого маніфесту CR, до якого оператор додає суфікс “-scram-credentials“
agents-mainframe-mongodb-admin-admin: тут Operator генерує connection string для підключення в форматі mongodb://<username>:<password>@<host>:<port>/<database>?<options>"
ім’я генерується з <MongoDBCommunity name>-<auth database>-<username>
містить дві connection sring – connectionString.standard та connectionString.standardSrv: другий використовує Kubernetes DNS замість імен подів – користуємось ним
agents-mainframe-mongodb-agent-password: внутрішній пароль MongoDB Agent, створений оператором – агент використовує цей пароль для керування mongod на цьому інстансі MongoDB, нам не цікавий
agents-mainframe-mongodb-config: automation configuration для MongoDB Agent – бажаний стан deployment, replica set, users та інші параметри – оновлюється оператором при змінах в MongoDBCommunity, нам, в принципі, не цікавий
Запускаємо новий сервіс для проекту, і цьому сервісу треба підняти Redis та MongoDB.
Сама “технічна задача” виглядає приблизно так:
Redis: task queue + some other stuff (e.g. we stream the agent response from LLM to Redis, and if the user is connected on a websocket – then we stream data from the Redis to user)
MongoDB: similar purpose as the PostgreSQL for our Backend API – main storage
Що з неї зрозуміло:
для Redis нам не дуже важливий data persistence – він більше грає роль проксі/кеша
а для MongoDB як раз дані важливі, а значить треба мати і бекапи
Довго думав як їх запускати – з AWS managed сервісами типу ElastiCache або MemoryDB замість Redis, та DocumentDB чи Atlas для MongoDB, але врешті-решт вирішили запускати в Kubernetes:
менша latency: колись робив порівняння часу відповіді від Redis в EKS vs AWS ElastiCache – різниця була дуже відчутною (хоча це було ще у 2020)
не хочеться тягнути зайвий Terraform код: менеджити з Helm все ж простіше
моніторинг: “всередині” Kubernetes простіше збирати метрики (не треба тягнути з CloudWacth Metrics/Logs, ще і платити за це), є метрики і вже налаштовані алерти по контейнерам, Pods, EC2
але головне – новий сервіс ще в експерементальній стадії, а тому просто нема сенсу піднімати щось прям “навєка”, ще і платити за це додаткові гроші AWS
Починати будемо з Redis, в цьому пості про нього, а вже десь далі – про MongoDB.
Note: я тут буду Valkey і Redis використовувати як синоніми, да пробачать мені розробники Valkey.
Redis vs Valkey
Дуже давно не мав справу з Redis, і коли спитав людей в чатику RTFM що нині актуально для Redis in Kubernetes – то майже всі сказали Valkey.
На своєму сайт Redis, звісно, описує різницю як “Redis вміє все, а Valkey – обрізана подєлка” (див. Fast data you can trust: Redis vs. Valkey), але це більше про Redis Enterprise vs Valkey. А от різниця між саме Redis OSS та Valkey вже не така відчутна, див. ще документацію від AWS – Compare Redis OSS and Valkey.
Головна відмінність – ліцензія, бо Valkey повністю open-source з BSD-ліцензією. Але особисто для мене питання ліцензії не надто важливі, бо міняють, як перчатки (привіт, Hashicorp, MongoDB, Elasticsearch, ну і сам Redis).
Ще є відмінності в технічній реалізації, як-от I/O та механізму кешування, і, скоріш за все, ще багато чого – але нічого принципового чи такого, що кардинально змінювало б роботу з ним.
План архітектури
Працювати все буде в AWS Elastic Kubernetes Service, для нового сервісу маємо окремий NodePool в Karpenter.
Що треба буде робити:
fault-tolerance: новий сервіс хоч і в експерименті, але відразу треба закласти роботу на кількох Pods
network:
доступ тільки в межах EKS/VPC, без Ingress та Load Balancer
додамо статичний Kubernetes Service з типом ExternalName – аби потім простіше було переключитись на щось AWS Managed
моніторинг: збираємо метрики, потім створимо алерти і дашборду в Grafana
аутентифікація: поки чисто мінімальний PoC-конфіг ACL
Деплой в Kubernetes
У Valkey є власні Helm charts – їх і візьмемо, але робити будемо через власний чарт та Helm dependencies – бо треба буде створювати власні ресурси.
Є і Valkey Operator, але він зараз “This operator is in active development and not ready for production use“, да і у нас не та система, щоб його тягнути – тому обійдемось без нього.
Valkey, як і Redis, вміє і в cluster mode, див. Cluster tutorial – але і чарт цього не підтримує, і в моєму випадку це зайве.
Аутентифікація буде через AWS Secrets Store або Param Store для збереження credentials, а потім з External Secrets Operator (ESO) передаємо їх до Valkey Pods.
Створення власного Helm chart
Додаємо собі локально репозиторій Valkey:
$ helm repo add valkey https://valkey.io/valkey-helm/
"valkey" has been added to your repositories
$ helm repo update
Шукаємо актуальну версію чарту valkey/valkey, на момент написання цього поста версія 0.10.0:
$ helm search repo valkey
NAME CHART VERSION APP VERSION DESCRIPTION
valkey/valkey 0.10.0 9.1.0 A Helm chart for Kubernetes
valkey/valkey-operator 0.3.2 v0.3.0 A Helm chart for the Valkey Operator
valkey/valkey-resources 0.1.0 v0.3.0 Helm chart for operator-managed Valkey resource...
Створюємо свій файл Chart.yaml, в dependencies задаємо Valkey, через alias задаємо власну назву:
apiVersion: v2
name: agents-mainframe-redis
description: >-
Helm chart for the Redis master-slave setup on the shared hOS EKS cluster.
type: application
version: 0.10.0
appVersion: "0.10.0"
dependencies:
- name: valkey
repository: https://valkey.io/valkey-helm/
version: 0.10.0
alias: valkey-redis
commonLabels: треба додати власну лейблу component (це конкретно для мого проекту)
service: лишаємо дефолтні значення – ClusterIP нам зараз підходить
resources: додамо якісь мінімальні значення, потім подивимось під час роботи і підтюнимо
extraValkeySecrets: можна підключити додаткові Kubernetes Secret (але паролі з ESO будуть не тут)
valkeyConfig: можна додати власні параметри для valkey.conf (aka redis.conf)
auth і usersExistingSecret: а оце якраз для ESO та паролів юзерів
replica: включаємо master-slave
replicas: вкажемо 2 (плюс буде 1 master)
replicationUser: поки лишимо дефолт, потім можна буде зробити окремого
disklessSync: налаштування реплікації – “традиційний” RDB (писав у RDB Persistence), або напряму через master mem bufer => network socket => replica mem bufer
minReplicasToWrite та minReplicasMaxLag: якщо в “кластері” у мастера нема healthy replicas – то нові writes будуть заблоковані, див. Write Safety Configuration (тут лінк на Cluster Mode, але то просто баг в README – неправильний зробили header для Safety Configuration)
persistence: додаємо – треба для реплікації
ще окремо треба буде погратись з valkeyConfig і appendonly та appendfsync в ньому, але це вже іншим разом – треба згадувати, як воно працює
persistentVolumeClaimRetentionPolicy: можна вказати явно, але в мене є окремий StorageClass з reclaimPolicy: Retain
tolerations, nodeAffinity, podAntiAffinity та topologySpreadConstraints: треба для повноцінного fault-tolerance, але поки тут використаємо тільки tolerations та nodeAffinity – аби Pods запускались тільки на потрібних WorkerNodes (див. Kubernetes: Pods та WorkerNodes – контроль розміщення подів на нодах)
$ tree helm/redis/
helm/redis/
├── Chart.yaml
├── Makefile
└── values
├── dev
├── prod
├── staging
└── test
В test-1-33-values.yaml будуть env-specific параметри для Testing Env на EKS-кластері v1.33.
Власне, пишемо values.
Робимо загальний файл common-values.yaml – бо поки що всі параметри для всіх environments однакові:
### ALL VALUES:
# https://github.com/valkey-io/valkey-helm/blob/main/valkey/values.yaml
valkey-redis:
podAnnotations:
reloader.stakater.com/auto: "true"
commonLabels:
component: mainframe
# TODO
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
# TODO
#extraValkeySecrets: []
# TODO
#extraValkeyConfigs: []
# TODO
# Content for valkey.conf (will be mounted via ConfigMap)
#valkeyConfig: ""
# TODO
auth:
enabled: false
replica:
enabled: true
# Number of replica instances (total pods = replicas + 1 master)
replicas: 2
replicationUser: "default"
minReplicasToWrite: 1
minReplicasMaxLag: 10
# EBS volume configuration
persistence:
size: 10Gi
storageClass: gp3-retain
tolerations:
- key: AgentsMainfraimOnly
operator: Exists
effect: NoSchedule
# TODO: enable together with podAntiAffinity when enough EC2 nodes are available.
#podLabels:
# app.kubernetes.io/component: valkey
# TODO
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: component
operator: In
values:
- mainframe
#podAntiAffinity:
# requiredDuringSchedulingIgnoredDuringExecution:
# - labelSelector:
# matchLabels:
# app.kubernetes.io/component: valkey
# topologyKey: kubernetes.io/hostname
#topologySpreadConstraints: []
podDisruptionBudget:
enabled: true
# Minimum number of pods that must be available during a disruption
#minAvailable: 1
# Maximum number of pods that can be unavailable during a disruption
maxUnavailable: 1
valkeyLogLevel: notice
metrics:
enabled: true
serviceMonitor:
enabled: true
Деплоїмо:
$ make helm-install-test-1-33
Перевіряємо Pods:
$ kk get pod
NAME READY STATUS RESTARTS AGE
agents-mainframe-redis-valkey-redis-0 2/2 Running 0 5m34s
agents-mainframe-redis-valkey-redis-1 2/2 Running 0 5m13s
agents-mainframe-redis-valkey-redis-2 2/2 Running 0 4m7s
Що у нас є з сервісів від самого Valkey Helm chart:
$ kk get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
agents-mainframe-redis-valkey-redis ClusterIP 172.20.243.142 <none> 6379/TCP 10m
agents-mainframe-redis-valkey-redis-headless ClusterIP None <none> 6379/TCP 10m
agents-mainframe-redis-valkey-redis-metrics ClusterIP 172.20.189.28 <none> 9121/TCP 10m
agents-mainframe-redis-valkey-redis-read ClusterIP 172.20.42.50 <none> 6379/TCP 10m
Створюємо файл templates/service-externalname.yaml, в ньому з {{ .Release.Namespace }} динамічно задаємо ім’я Kubernetes Namespace – бо dev/staging/prod будуть у власних Namespaces.
Нам для нашого сервісу треба створити трьох нових юзерів:
admin: “нормальний” амдін для якихось ручних задач
agents-mainframe: юзер для самого сервісу, який роблять девелопери
replication: для master-slave реплікації самого Redis
і “технічний” default – бо він повинен бути в чарті
В Redis/Valkey юзери та права доступу задаються через Access Control List, який визначає який юзер що може виконувати – аналогічно до того, як ми описуємо RBAC в Kubernetes. Тільки в Redis ми всі доступи описуємо прямо в конфігу – а в Kubernetes маємо Roles та RoleBindings.
Але є дуже суттєва відмінність: в Kubernetes RBAC простий і чіткий синтаксис, який легко читається, а Redis ACL – це якийсь окремий вид збочення. Може, діло звички – але ну дуже неявно все описується і треба витрачати час, щоб зрозуміти логіку.
Це друга частина матеріалу про моніторинг LiteLLM – у попередній ми розібрали загальну інтеграцію з VictoriaStack, а також подивилися, які метрики й трейси отримуємо від LiteLLM (див. LiteLLM: метрики, traces та інтеграція з VictoriaMetrics Stack).
Тепер перейдемо до практичної частини – що відстежувати та як це можна організувати.
Втім, хоча LiteLLM у нас уже працює в production, система все ще перебуває в процесі “допилювання” та “спостереження за поведінкою”, а тому описані в цьому пості алерти й дашборди не є фінальним варіантом – це, скоріше, демонстрація можливих підходів до контролю за LiteLLM, а також приклади запитів до VictoriaMetrics і VictoriaTraces.
Сьогодні зосередимося на двох основних компонентах моніторингу – алертах та Grafana dashboards, бо метрик у нас багато, і спочатку треба визначити базовий набір даних, з якого варто починати налаштування контролю роботи системи.
У нас кілька основних компонентів які треба моніторити і по яким треба алертити:
сама система: загальні алерти по статусу Pods, failed requests, latency, in-flight requests
Redis: статус самого Redis та його Pods, помилки
для real production можна додати Redis Prometheus експортер
PostgreSQL: взагалі, звісно, за сервером баз даних теж треба слідкувати – але в моєму випадку це вже існуючий сервер і він вже давно “покритий моніторингом”
providers/LLM: статус моделей, failed responses, latency
costs, tokens, budgets та limits: використання системи by Teams, API Keys, і взагалі сервісами та юзерами
Pods number: перевіряємо Deployment desired Pods vs current running Pods
Pods resources: CPU/RAM used – окремий алерт не робив, вистачить загальних
Failed requests: тут саме про фейли від клієнтів до LiteLLM
алертимо по метриці litellm_proxy_failed_requests_metric_total
Latency: потім зробимо алерти, поки просто в дашборді Grafana:
litellm_request_total_latency_metric: загальний час на обробку запитів – з моменту отримання реквеста від клієнта + LiteLLM overhead + відправка до LLM provider + очікування і повернення відповіді клієнту
litellm_llm_api_latency_metric: тільки час відповіді від провайдера/LLM
litellm_overhead_latency_metric: скільки часу із загального litellm_request_total_latency_metric витратив сам LiteLLM – поки спостерігаємо, потім можна додати алерт
litellm_self_latency: auth, routing, logging callbacks (prometheus, otel), обчислювання spend tracking, Redis і PostgreSQL операції – все що LiteLLM робить до і після запиту до OpenAI/Anthropic
LiteLLM HTTP requests: кількість активних запитів в обробці на uvicorn workers – метрика litellm_in_flight_requests
Алерт “Kubernetes LiteLLM Pods/Deployment status”
Взагалі це стандартний алерт, який є в більшості Helm-чартів з коробки – але я створив окремий саме на LiteLLM:
---
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
name: alerts-litellm-system
spec:
groups:
- name: LiteLLM.System.Error.rules
rules:
# Deployment not matching replicas
- alert: LiteLLM System Pods Not Ready
expr: kube_deployment_status_replicas_ready{namespace="ops-litellm-ns"} < kube_deployment_spec_replicas{namespace="ops-litellm-ns"}
for: 3m
labels:
component: devops
environment: ops
severity: critical
ilert_routingkey: devops-ops-critical
annotations:
summary: LiteLLM System Pods Not Ready
description: |-
LiteLLM System pods are not ready for more than `{{ "{{" }} $for }}`
*Namespace*: `{{ "{{" }} $labels.namespace }}`
*Deployment*: `{{ "{{" }} $labels.deployment }}`
*Ready replicas*: `{{ "{{" }} $labels.ready_replicas }}`
*Replicas*: `{{ "{{" }} $labels.replicas }}`
<https://{{ $.Values.monitoring.root_url }}/d/adtt9jj/adrmshg/litellm-system-overview |:grafana: LiteLLM System overview>
Алерти Redis
Окремо кілька алертів конкретно по Redis.
Код з метриками в самому LiteLLM – prometheus_services.py (бо іноді довелось дивитись там, а не в документації – хоча загалом документація чудова).
Алерт “LiteLLM Redis Pods Not Ready”
Алерт “Redis Pods Not Ready” – аналогічний до попереднього, просто перевіряємо кількість подів в Ready vs скільки має бути в StatefulSet:
- alert: LiteLLM Redis Pods Not Ready
expr: kube_statefulset_status_replicas_ready{namespace="ops-litellm-ns", statefulset="litellm-redis-master"} < kube_statefulset_replicas{namespace="ops-litellm-ns", statefulset="litellm-redis-master"}
for: 1s
labels:
component: devops
environment: ops
severity: critical
ilert_routingkey: devops-ops-critical
annotations:
summary: LiteLLM Redis Pods Not Ready
description: |-
LiteLLM Redis Pods are not ready for more than `{{ "{{" }} $for }}`
*StatefulSet*: `{{ "{{" }} $labels.statefulset }}`
*Ready replicas*: `{{ "{{" }} $value }}`
<https://{{ $.Values.monitoring.root_url }}/d/adtt9jj/adrmshg/litellm-system-overview |:grafana: LiteLLM System overview>
Алерт “LiteLLM Redis Failed Requests”
Алерт по litellm_redis_failed_requests_total – помилки виконання запитів від LiteLLM до Redis: інкрементиться на кожну помилку в try/except в redis_cache.py, наприклад при помилках ping інстансу Redis або помилки при роботі з кешем (читання/запис).
В документації вона описана як “litellm_redis_fails“, але в 1.91.0 вона створюється саме як litellm_redis_failed_requests_total, метрики “litellm_redis_fails” в коді вже нема взагалі.
В лейблах мають бути ["error_class", "function_name"] – подивимось, що там буде, бо поки помилок не було взагалі, тому алерт максимально простий:
Для PostgreSQL є метрики litellm_postgres_total_requests_total та litellm_postgres_latency_sum, і має бути litellm_postgres_failed_requests_total – але теж поки помилок не було, тому метрика порожня.
Втім, можна додати такий самий простий алерт як для Redis саме по litellm_postgres_failed_requests_total.
Алерт “LiteLLM Proxy Failed Requests”
Окремий алерт на failed responses від LiteLLM клієнтам – якщо вже після всіх спроб retry request або fallback ми клієнту все одно повернули помилку:
Тут зі значення api_key_alias береться остання частина і задається в лейблу environment (хоча можна просто передавати environment в metadata, див. Custom Metadata Labels, але це треба зміни в коді).
Ну а для імен ключів у нас є такий собі naming convention: <type>-<component>-<feature>-<env>, наприклад – svc-kraken-cortex-dev, де:
svc: тип API-ключа – Service Account (для звичайних юзерів – префікс usr)
kraken: наш Backend API
cortex: конкретна фіча/сервіс бекенду
dev: оточення
Поки подивлюсь що буде з цією метрикою litellm:proxy:failed_requests:environment і дефолтною litellm_proxy_failed_requests_metric_total – потім вирішу, яку залишити для використання. Чи, може, так і буде – такий собі “системний” алерт з litellm_proxy_failed_requests_metric_total, та per service – з окремою litellm:proxy:failed_requests:environment.
Тут ще є цікавий момент з помилками RateLimitError – див. нижче в частині “OpenAI та помилка RateLimitError“.
Алерт “LiteLLM In-Flight Requests”
Корисна для моніторингу навантаження системи метрика litellm_in_flight_requests: описана в документації в частині Pod Health Metrics, відображає скільки HTTP-запитів в обробці на LiteLLM прямо зараз.
Навіть є такий приклад того, як її використовувати:
– high in_flight_requests + high ALB TargetResponseTime → pod overloaded, scale out
– low in_flight_requests + high ALB TargetResponseTime → delay is pre-ASGI (event loop blocking)
Я поки зробив просто алерт на кількість запитів – далі подивимось, що тут буде в значеннях і, може, трохи перероблю цей алерт:
# In Flight Requests HTTP requests currently in-flight on uvicorn workers
- alert: LiteLLM In Flight Requests
expr: |
avg(litellm_in_flight_requests) > 5
for: 1s
...
Provider/LLM alerts
Окремою групою алерти по статусам безпосередньо провайдерів: тут шлемо нотифікації, якщо клієнти LiteLLM отримують якісь помилки від OpenAI/Anthropic.
Алерт “LiteLLM Deployment/Model Not Ready”
Метрика litellm_deployment_state повертає значення 0 = healthy, 1 = partial outage, 2 = complete outage, і інкрементиться, коли на запит клієнта провайдер повернув код != 200, тому в цілому є сенс алертити:
# Deployment/Model not ready
- alert: LiteLLM Deployment/Model Not Ready
expr: max(litellm_deployment_state) by (api_base, litellm_model_name) > 0
for: 1s
labels:
component: devops
environment: ops
severity: critical
ilert_routingkey: devops-ops-critical
annotations:
summary: LiteLLM Deployment/Model Not Ready
description: |-
LiteLLM deployment/model is not ready for more than `{{ "{{" }} $for }}`
*Model State*: `{{ "{{" }} if eq $value 1.0 {{ "}}" }}1 - Partial outage{{ "{{" }} else if eq $value 2.0 {{ "}}" }}2 - Complete outage{{ "{{" }} else {{ "}}" }}Unknown{{ "{{" }} end {{ "}}" }}`
*Model name*: `{{ "{{" }} $labels.litellm_model_name }}`
*API base*: `{{ "{{" }} $labels.api_base }}`
<https://{{ $.Values.monitoring.root_url }}/d/adtt9jj/adrmshg/litellm-system-overview |:grafana: LiteLLM System overview>
Хоча більш корисним буде наступний алерт, а метрику litellm_deployment_state просто використовувати в Grafana.
Є окрема метрика litellm_deployment_failure_responses_total – помилки від провайдера.
Вона схожа на litellm_proxy_failed_requests_metric_total і навіть має ті самі лейбли exception_class та exception_status – але litellm_deployment_failure_responses_total інкрементиться на кожну помилку від провайдера, а litellm_proxy_failed_requests_metric_total – після всіх retry/fallback самою LiteLLM.
Поки зробив обидва алерти – подивимось, від якого буде більше толку.
Окремий VMRule на бюджети, які задаємо для Teams та API Keys.
Алерт “LiteLLM Budget Low”
Шлемо алерт, якщо у Team залишилось менше 10% його бюджету:
- alert: LiteLLM Team Costs Budget Low
expr: |
(
sum by (team, team_alias) (litellm_remaining_team_budget_metric)
/
sum by (team, team_alias) (litellm_team_max_budget_metric)
) < 0.1
for: 1s
labels:
component: devops
environment: ops
severity: warning
ilert_routingkey: devops-ops-warning
annotations:
summary: LiteLLM Team Budget Low
description: |-
LiteLLM team budget is less than 10% remaining for more than `{{ "{{" }} $for }}`
*Team*: `{{ "{{" }} $labels.team_alias }}`
*Budget left percentage*: `{{ "{{" }} $value | humanizePercentage }}`%
<https://{{ $.Values.monitoring.root_url }}/d/adtt9jj/adrmshg/litellm-system-overview |:grafana: LiteLLM System overview>
І аналогічний – для API Keys, з метрикою litellm_remaining_api_key_budget_metric:
- alert: LiteLLM API Key Budget Low
expr: |
(
sum by (api_key_alias) (litellm_remaining_api_key_budget_metric)
/
sum by (api_key_alias) (litellm_api_key_max_budget_metric)
) < 0.1
for: 1s
labels:
component: devops
environment: ops
severity: warning
ilert_routingkey: devops-ops-warning
annotations:
summary: LiteLLM API Key Budget Low
description: |-
LiteLLM API Key budget is less than 10% remaining for more than `{{ "{{" }} $for }}`
*API Key*: `{{ "{{" }} $labels.api_key_alias }}`
*Budget left percentage*: `{{ "{{" }} $value | humanizePercentage }}`%
<https://{{ $.Values.monitoring.root_url }}/d/adtt9jj/adrmshg/litellm-system-overview |:grafana: LiteLLM System overview>
Limits
Ще одна група – по Requests та Tokens per minute.
Тут у нас можуть бути ліміти як від провайдера – так і ті, що ми задаємо самі для API Key, і, може, є сенс їх розділити – але поки зробив в одному VMRule, тільки в різних groups.
Особливість OpenAI та помилки “RateLimitError”
Виявився цікавий нюанс: OpenAI повертає 429 “RateLimitError” в двох випадках:
коли дійсно вперлись в ліміт TPM/RPM
і коли на акаунті закінчились гроші
Тому алерти по litellm_proxy_failed_requests_metric_total або litellm_deployment_failure_responses_total будуть спрацьовувати в обох випадках – але на різні речі, і я кілька годин провів, намагаючись зрозуміти чому у нас значення в litellm_remaining_requests_metric не падає – але при цьому ми ловили RateLimitError.
А документація самого OpenAI Example rate limit error ще більш заплутує, бо показує приклад про RateLimitError з 'code': 'insufficient_quota' – при цьому в тексті пояснення пишуть “when API requests are sent too quickly“.
Може пізніше LiteLLM якось це змінить, бо з трейсів різниця видна, і можна створювати окремі метрики (див. приклад нижче).
Алерт “LiteLLM OpenAI Quota Exhausted”
Тому замість використання litellm_proxy_failed_requests_metric_total і litellm_deployment_failure_responses_total (алерт з ними поки залишається) зробив інакше: додав окремий RecordingRule, який в traces перевіряє значення поля event:event_attr:exception.message і інкрементить метрику vmtraces:litellm:openai:insufficient_quota:rate тільки тоді, коли в тексті є code: "insufficient_quota":
- name: LiteLLM.VictoriaTraces.OpenAI.rules
type: vlogs
interval: 1m
rules:
# OpenAI returns HTTP 429 both for actual rate limiting and exhausted quota.
# Match the structured error code in the LiteLLM exception to distinguish them.
- record: vmtraces:litellm:openai:insufficient_quota:rate
expr: |
{resource_attr:service.name="litellm"} "event:event_attr:exception.message:0":~"\"code\":\\s*\"insufficient_quota\"\\s*\\}\\s*\\}"
| stats by ("resource_attr:service.name") rate() errors_per_second
А далі вже з цією метрикою – окремий алерт конкретно на “Not enough gold, my Lord!” (c):
- name: LiteLLM.Provider.Quota.rules
rules:
# This is separate from rate-limit alerts because OpenAI uses HTTP 429 for
# both rate limiting and an exhausted account/project quota.
- alert: LiteLLM OpenAI Quota Exhausted
expr: vmtraces:litellm:openai:insufficient_quota:rate{stats_result="errors_per_second"} > 0
for: 1s
labels:
component: devops
environment: ops
severity: critical
ilert_routingkey: devops-ops-critical
annotations:
summary: LiteLLM OpenAI Quota Exhausted
description: |-
OpenAI rejected a LiteLLM request with `insufficient_quota`. The account or project has run out of credits; please add funds.
*Service name*: `{{ "{{" }} index $labels "resource_attr:service.name" }}`
<https://platform.openai.com/settings/organization/billing/overview |:openai: OpenAI billing overview>
Аналогічно можна зробити метрику і алерт саме на реальні RPM/TPM ліміти провайдера, щось типу такого:
Але його треба перевірити, коли отримаємо помилки саме по лімітам RPM/TPM – поки що не було.
Алерти “Provider RPM та TPM Rate Limits”
Додав такий алерт – слати повідомлення, якщо скоро впремось RPM/TPM ліміт від провайдера:
- alert: LiteLLM Provider RPM Rate Limit Low
# less then 10% of the limit, limit taken by the max_over_time which is reseted every minute
expr: |
(
sum by (api_key_alias, api_provider, model_group, litellm_model_name) (litellm_remaining_requests_metric)
/
sum by (api_key_alias, api_provider, model_group, litellm_model_name) (max_over_time(litellm_remaining_requests_metric[1h]))
) < 0.1
for: 1s
labels:
component: devops
environment: ops
severity: warning
ilert_routingkey: devops-ops-warning
annotations:
summary: LiteLLM Provider RPM Rate Limit Low
description: |-
LLM Provider rate limit is less than 10% of the limit for more than `{{ "{{" }} $for }}`
*API key alias*: `{{ "{{" }} $labels.api_key_alias }}`
*API provider*: `{{ "{{" }} $labels.api_provider }}`
*Model group*: `{{ "{{" }} $labels.model_group }}`
*Model name*: `{{ "{{" }} $labels.litellm_model_name }}`
*Rate limit left percentage*: `{{ "{{" }} $value | humanizePercentage }}`%
<https://{{ $.Values.monitoring.root_url }}/d/adtt9jj/adrmshg/litellm-system-overview |:grafana: LiteLLM System overview>
Тут ідея в тому, що значення в litellm_remaining_requests_metric має скидатись кожну хвилину (бо ліміт жеж per minute), і від неї рахуємо “відсоток поточної кількості запитів від максимально доступної”, тобто:
в max_over_time(litellm_remaining_requests_metric[1h]) беремо максимальне значення – тут має бути загальний ліміт від провайдера
а в litellm_remaining_requests_metric маємо поточний залишок ліміту
Але насправді, не впевнений, що це спрацює – бо evaluation interval алертів раз на хвилину і скоріш за все “вікно” для алерту буде пропускатись.
Можна зробити менший interval для LiteLLM VMPodScrape:
Якщо зменшуємо interval для збору метрик – то зменшуємо і evaluation interval конкретно для цієї для VMRules group:
...
- name: LiteLLM.Provider.Limits.rules
interval: 10s
rules:
# by (api_key_alias, api_provider, model_group, litellm_model_name)
# RPM Rate limit low
- alert: LiteLLM Provider RPM Rate Limit Low
# less then 10% of the limit, limit taken by the max_over_time which is reseted every minute
expr: |
(
sum by (api_key_alias, api_provider, model_group, litellm_model_name) (litellm_remaining_requests_metric)
/
sum by (api_key_alias, api_provider, model_group, litellm_model_name) (max_over_time(litellm_remaining_requests_metric[1h]))
) < 0.1
...
Подивимось, може пізніше перероблю чи приберу взагалі, хоча в цілому алерт виглядає корисним.
Аналогічно – алерт по TPM limit, з метрикою litellm_remaining_tokens_metric:
- alert: LiteLLM Provider TPM Rate Limit Low
# less then 10% of the limit, limit taken by the max_over_time which is reseted every minute
expr: |
(
sum by (api_key_alias, api_provider, model_group, litellm_model_name) (litellm_remaining_tokens_metric)
/
sum by (api_key_alias, api_provider, model_group, litellm_model_name) (max_over_time(litellm_remaining_tokens_metric[1h]))
) < 0.1
for: 1s
...
Алерти “LiteLLM API Key RPM та TPM Rate Limit”
Аналогічні алерти – але вже по лімітам, які ми задаємо самі для ключів в самій LiteLLM, ну і з тою самою проблемою з evaluation interval.
Алерт на RPM:
- alert: LiteLLM API Key RPM Rate Limit Low
expr: |
(
sum by (api_key_alias, model) (litellm_remaining_api_key_requests_for_model)
/
sum by (api_key_alias, model) (max_over_time(litellm_remaining_api_key_requests_for_model[1h]))
) < 0.1
for: 1s
labels:
component: devops
environment: ops
severity: warning
ilert_routingkey: devops-ops-warning
annotations:
summary: LiteLLM API Key RPM Rate Limit Low
description: |-
LiteLLM API key rate limit is less than 10% of the limit for more than `{{ "{{" }} $for }}`
*API key alias*: `{{ "{{" }} $labels.api_key_alias }}`
*Model*: `{{ "{{" }} $labels.model }}`
*Rate limit left percentage*: `{{ "{{" }} $value | humanizePercentage }}`%
І на TPM:
- alert: LiteLLM API Key TPM Rate Limit Low
expr: |
(
sum by (api_key_alias, model) (litellm_remaining_api_key_tokens_for_model)
/
sum by (api_key_alias, model) (max_over_time(litellm_remaining_api_key_tokens_for_model[1h]))
) < 0.1
for: 1s
...
Зараз в них великого сенсу нема, описані нижче робив ці на самому початку, коли тільки переключали наш Backend API – аби на додачу до метрик самої LiteLLM мати алерт на помилки в самому Backend API, але як приклад нехай буде.
Тут з логів самого Backend API грепаються помилки “LLM request failed” та “Invalid model name passed” і генеруються метрики vmlogs:litellm:logs:kraken:llm_request_failed:rate та vmlogs:litellm:logs:kraken:invalid_model_name_passed:rate:
{{- range .Values.alerts.litellm.backend }}
{{- $ns := . }}
{{- range .severities }}
- alert: LiteLLM Kraken LLM Error Rate High
expr: avg(vmlogs:litellm:logs:kraken:llm_request_failed:rate{namespace="{{ $ns.namespace }}"}) by (container, namespace, error_code, rate_error_msg, auth_error_msg) > 0
for: 1s
labels:
severity: {{ . }}
component: backend
environment: {{ $ns.env }}
ilert_routingkey: backend-{{ $ns.env }}-{{ . }}
annotations:
summary: "LiteLLM Kraken LLM Error Rate High"
description: |-
LiteLLM Kraken LLM error rate has been above 0 for more than `{{ "{{" }} $for }}`
*Error code*: `{{ "{{" }} $labels.error_code }}`
*Error message*: {{ "{{" }} $labels.rate_error_msg }}
*Auth error message*: `{{ "{{" }} $labels.auth_error_msg }}`
*Container*: `{{ "{{" }} $labels.container }}`
*Namespace*: `{{ "{{" }} $labels.namespace }}`
<https://{{ $.Values.monitoring.root_url }}/d/adtt9jj/litellm-services-overview?orgId=1&from=now-2d&to=now&timezone=browser&var-datasource_vm=VictoriaMetrics|:grafana: Litellm Overview>
{{- end }}
{{- end }}
...
- alert: LiteLLM Kraken Invalid Model Name Passed Error Rate High
expr: avg(vmlogs:litellm:logs:kraken:invalid_model_name_passed:rate{namespace="{{ $ns.namespace }}"}) by (container, namespace, error_code, error_msg) > 0
for: 1s
...
Grafana dashboards
Цей пост і так вже вийшов довгий – але виносити Grafana dashboards окремим не бачу сенсу, тому нехай буде тут – але якось коротко.
В репозиторії LiteLLM є власні дашборди – але вони прям зовсім… ніякі.
Тому, як зазвичай і роблю – створив власні такі собі “overview dashboards”, дві штуки – одна більше “системна” по стану самого сервісу, друга – більше product oriented з інформацією по Costs різними сервісами і клієнтами.
Хоча в якійсь нещодавньому апгрейді Grafana завезла підтримку Tabs, коли на одній борді можна зробити два окремих набори візуалізацій – але я по-старінкє зробив окремі борди.
100% буду їх перероблювати по ходу використання – але поки так, мінімально корисна інформація.
“LiteLLM System Overview” dashboard
Тут просто загальні дані по використанню та стану самої системи – це більше для мене, суто “технічна”, тому навіть не додавав якихось фільтрів.
Панель “Total Spent”
Stats панель з інформацією по витратам за обраний в Grafana time range період часу:
Просто загальна картина ситуації з помилками провайдерів та моделей:
max(litellm_deployment_state) by (litellm_model_name)
Графіки по Latency
Тут у нас є лейтенсі загальна – і всякі TTFT та TPOT.
Загальні:
litellm_request_total_latency_metric_bucket: скільки реально чекав клієнт – повний час на обробку запиту від отримання від клієнта + LiteLLM overhead + відправка провайдеру + очікування відповіді + повернення відповіді клієнту
litellm_llm_api_latency_metric_bucket: скільки LiteLLM чекав на відповідь від провайдера
litellm_overhead_latency_metric_bucket: скільки додав сам LiteLLM до litellm_llm_api_latency_metric_bucket
Що ми можемо побачити з них:
росте Total та Provider, але Overhead стабільний: можлива проблема у провайдера (або нетворк)
росте Total та Overhead, але Provider стабільний: проблема на самому LiteLLM, треба глянути ресурси Pods
Плюс маємо метрики по Time To First Token (TTFT) та Time Per Output Token (TPOT):
litellm_llm_api_time_to_first_token_metric_bucket (TTFT): час очікування до отримання першого токена від провайдера (тільки для streaming requests)
litellm_deployment_latency_per_output_token (TPOT): latency per output token по конкретній моделі – скільки в середньому займає генерація кожного токена у відповіді
Але з TPOT (litellm_deployment_latency_per_output_token) є питання в тому, як воно рахується, бо метрика заповнюється як “latency_per_token = _latency_seconds / output_tokens” (див. prometheus.py).
А тому:
в non-streaming: модель згенерувала відповідь на 1000 токенів за 10 000 мс – маємо ~10 мс на генерацію одного токена.
при streaming: TTFT фіксується в момент першого токена стріму, і він не залежить від того, скільки токенів вийде у відповіді (output_tokens) – цих токенів на момент TTFT ще навіть не існує, а оскільки формула ділить саме TTFT на кількість токенів відповіді – то для streaming так рахувати TPOT немає сенсу
Тому поки зробив дві візуалізації – перша по “загальним” latency:
histogram_quantile(
0.95,
sum(
rate(litellm_request_total_latency_metric_bucket[$__rate_interval])
) by (le)
)
І аналогічно запити для litellm_llm_api_latency_metric_bucket і litellm_overhead_latency_metric_bucket:
І друга панель – по TTFT/TPOT, тільки streaming у нас зараз нема – тому маємо картину тільки для TPOT:
histogram_quantile(
0.50,
sum by (le) (
rate(litellm_llm_api_time_to_first_token_metric_bucket[$__rate_interval])
)
)
Панель “Success Responses”
Тут просто рахуємо % успішних відповідей від загальної кількості:
sum by (api_provider) (rate(litellm_deployment_success_responses_total[5m]))
/
sum by (api_provider) (rate(litellm_deployment_total_requests_total[5m]))
* 100
Панель “Gateway Failed Requests”
Графік помилок від LiteLLM клієнтам:
sum(rate(litellm_proxy_failed_requests_metric_total[5m])) by (exception_class)
/
sum(rate(litellm_proxy_total_requests_metric_total[5m]))
* 100
Панель “Provider Failed Requests Rate”
Окремо помилки від провайдера (але пам’ятаємо, що тут Openai.RateLimitError спрацьовує і на “закінчились гроші”):
sum(rate(litellm_deployment_failure_responses_total[5m])) by (exception_class)
Resulted dashboard
В результаті вся дашборда зараз виглядає так:
“LiteLLM Services Overview” dashboard
Ця борда багато в чому схожа на System dashboard – але більш заточена під менеджерів та девелоперів, яким цікаве використання LLM та гроші.
Dashboard variables
Тут додав фільтри, аби була можливість подивитись дані по конкретним Teams або API Keys:
Запит для отримання списку API Keys:
sum(litellm_spend_metric_created{api_key_alias=~".*$environment"}) by (api_key_alias)
І регуляркою вирізаємо саме ім’я ключа:
Total Spend by Feature
Тут “Feature” – це більше для менеджерів, бо фактично це “spent by API Key”:
sort_desc(sum(increase(litellm_spend_metric_total[$__range])) by (api_key_alias))
Тут з MetricsQL та функцією sort_desc() сортуємо результат, що перший йшов той ключ, у якого найбільше витрат.
Тип візуалізації – Bar gauge:
І аналогічний запит для візуалізації Pie chart – але тут % витрат кожним ключем від загальних витрат:
Панель “Spent Rate by User”
У нас через metadata в реквестах додається end_user (див. Customers / End-Users) – поки проект невеликий, то можна собі дозволити.
І є окремий графік використання кожним юзером: можемо подивитись який юзер “скільки нам коштує” по кожній фічє/API Key:
sum(increase(litellm_spend_metric_total{api_key_alias=~"$api_key", team_alias=~"$team"}[$__interval])) by (end_user)
and on(end_user)
topk(5, sum(increase(litellm_spend_metric_total{api_key_alias=~"$api_key", team_alias=~"$team"}[$__range])) by (end_user))
Панель “Remaining Budget by Team та API Key”
Рахуємо % використаних бюджетів by (team_alias):
sum by (team_alias) (litellm_remaining_team_budget_metric{team_alias=~"$team"})
/
sum by (team_alias) (litellm_team_max_budget_metric{team_alias=~"$team"})
* 100
Та by (api_key_alias):
sum by (api_key_alias) (litellm_remaining_api_key_budget_metric)
/
sum by (api_key_alias) (litellm_api_key_max_budget_metric)
* 100
Resulted dashboard
І вся борда разом виглядає так:
Власне, на цьому поки і все.
Потроху переключаємо сервіси на LiteLLM, ще багато чого цікавого і корисного будемо робити. В найближчих планах – протестувати parallel requests одночасно до кількох провайдерів (див. Batch Completions – pass multiple models) – бо вже хочемо мати self-hosted LLM, і треба буде робити порівняння моделей OpenAI/Anthropic та наших власних моделей.
Але про це вже to be continued в наступних постах.
Тепер у нас є трейси в VictoriaTraces, є метрики в VictoriaMetrics – можна будувати моніторинг, благо у LiteLLM з цим все чудово.
Що будемо робити сьогодні:
ще раз поговоримо про інтеграцію LiteLLM з VictoriaStack – метрики та трейси
пройдемось по основним метрикам, які LiteLLM віддає, глянемо основні можливості по налаштуванням LiteLLM в тому, як і які метрики віддавати
трохи розберемось з трейсами і атрибутами – бо всього багато, спершу було складно скласти все до купи
Приклади алертів та дашборди для Grafana вирішив винести окремим постом – бо цей і так вийшов доволі довгим.
І, мабуть, буде ще одна частина – по SLI/SLA/SLO для LLM з використанням метрик, які маємо від LiteLLM.
Щодо алертів: LiteLLM має нативну підтримку відправки алертів в Slack – але в моєму випадку алерти йдуть стандартним для флоу vmalert > Alertmanager > iLert > Slack.
В частині Monitoring та метрики до VictoriaMetrics попереднього посту розбирав кілька варіантів налаштування збору метрик, але ще раз коротко – як зробив зараз, бо тут все ж пост вже виключно по моніторингу.
Аутентифікацію на /metrics відключив взагалі: у нас невеликий стартап, який ще навіть не в маркеті – тому ми поки що не витрачаємо час на всякий серйозний security. До того ж всі ендпоінти доступні тільки в межах AWS VPC/Kubernetes, в “світ” нічого голими портами не відкрито – тому базово більш-менш сервіси прикриті.
В litellm_settings включаємо callbacks з prometheus:
В Helm-чарті LiteLLM є можливість включити serviceMonitor – тоді VictoriaMetrics Operator створить VMServiceScrape, і пізніше, мабуть, перероблю – але поки просто зробив через VMPodScrape:
Робимо якісь запити через LiteLLM, перевіряємо в ній самій – дивимось в Logs:
І той самий трейс маємо в VictoriaTraces – шукаємо по {resource_attr:service.name="litellm"}, можна додати фільтр "span_attr:llm.openai.id":="chatcmpl-NNN":
LiteLLM Prometheus Metrics
Метрик багато, метрики класні.
Взагалі, з коробки метрики покривають 95% відсотків того, що потрібно для базового моніторингу.
В налаштування LiteLLM можна вибрати які метрики будуть віддаватись – див. Enable Specific Metrics and Labels (в документації криво працюють anchores, тому можна Ctrl+F по сторінці), але я поки обмеження по метрикам не робив – збираємо все, що є – потім розберусь, що нам не треба.
Для моніторингу Redis варто в litellm_settings включити service_callback: ["prometheus_system"] – будуть додані метрики litellm_redis_latency_bucket і litellm_redis_fails, див. Monitor System Health.
System Metrics та perfomance: статус подів самого LiteLLM, всякі latency, кількість задач в черзі, статус Redis, загальна кількість токенів, failed requests, дані по конкретним моделям і провайдерам, використання кешу самого LiteLLM
Costs and usage: тут все про кости і використання токенів клієнтами, використання кешу LLM-провайдерів
Budgets and Limits: дані по обмеженням, які задаємо для Teams та API Keys
Коротко по найбільш корисним (imho) метрикам.
System Metrics
litellm_in_flight_requests: корисна метрика про навантаження на систему – скільки HTTP-запитів оброблюється unicorn-воркерами прямо зараз, див. Pod Health Metrics
litellm_proxy_failed_requests_metric: кількість помилок від LiteLLM клієнтам після всіх retry/fallbacks (по LLM/провайдерам є окрема група метрик, див. нижче)
litellm_proxy_total_requests_metric: кількість запитів від клієнтів
litellm_requests_metric: в документації не сказано, але ця метрика deprecated, це явно сказано в коді – повертає те саме, що і litellm_proxy_total_requests_metric
по latency:
litellm_request_total_latency_metric: загальний час на відповідь клієнту, в секундах
litellm_overhead_latency_metric: час, витрачений на обробку запиту самим LiteLLM
litellm_llm_api_time_to_first_token_metric: час до повернення клієнту першого токену відповіді (тільки для streaming запитів, див. LLM Streaming)
Latency – взагалі трохи окреме питання, бо там є свої цікаві нюанси, будемо про це говорити в частині Alerts/Grafana чи взагалі в третій по моніторингу LiteLLM – там де SLI/SLO/SLA.
Per Provider/LLM Metrics
litellm_deployment_success_responses: успішні запити до конкретного провайдера/моделі
на відміну від “системної” або “глобальної” метрики litellm_proxy_failed_requests_metric – в метриці litellm_deployment_failure_responses рахуються всі невдалі запити до провайдера/моделі, і набір labels у них трохи різний – будемо дивитись в наступному пості про Alerts
litellm_deployment_total_requests: загальна кількість запитів по провайдерам/моделям
litellm_deployment_state: статус провайдеру/LLM, якщо запит до моделі повернув помилку – буде відображено тут
litellm_remaining_requests_metric та litellm_remaining_tokens_metric: дуже цікава метрика, бо ми часто впираємось в ліміти – RPM та TPM ліміти провайдера, див. Rate limits in headers, і далі будемо робити алерти по ним (хоча там є свої нюанси)
Costs && Usage Metrics
litellm_spend_metric: скільки грошів витрачено, тип counter, тому рахувати будемо з rate() або increase()
litellm_total_tokens_metric: input + output використання разом
litellm_input_tokens_metric і litellm_output_tokens_metric: і окремо
Budgets && Limits
Дуже корисні метрики по бюджетам і лімітам, які задаємо для Teams або API Keys, див. Team – Budget:
litellm_team_max_budget_metric та litellm_remaining_team_budget_metric: загальний бюджет Team та скільки залишилось
litellm_api_key_max_budget_metric та litellm_remaining_api_key_budget_metric: аналогічно, але по ключам
litellm_remaining_api_key_requests_for_model та litellm_remaining_api_key_tokens_for_model: аналогічно, але по RMP/TPM
Цікаво, що метрик по лімітам RPM/TMP для Teams нема – хоча задавати обмеження на рівні груп можна.
LiteLLM Traces
Взагалі думав, що буду робити метрики з трейсів – але в цілому метрики дійсно закривають більшість задач по моніторингу.
Useful Traces options
Для трейсів можна включити LITELLM_OTEL_INTEGRATION_ENABLE_METRICS – тоді на додачу до дефолтних метрик LiteLLM почне писати багато нових метрик (див. Metrics Reference) зі статистикою, яку збирає з трейсів.
Але по факту там ті самі дані, які ми і так маємо в метриках:
Є підтримка опції Traceparent (див. Context propagation (W3C traceparent)) – тоді спани від LiteLLM будуть додаватись до власних спанів нашого сервісу, але це поки не тестив, бо навпаки хочеться тримати їх окремими сутностями.
З доволі важливих налаштувань – Capturing Message Content: можна включити зберігання в трейсах юзерських промптів та відповідей LLM (але пам’ятаємо про privacy).
Є змінна USE_OTEL_LITELLM_REQUEST_SPAN яка має включати створення окремих спанів конкретно по запитам до LLM, щоб не змішувати їх з системними спанами самого LiteLLM (див. Service-hook spans (a.k.a. “infrastructure” spans)), але тут є питання до того, як це працює, див. нижче.
Структура Traces
Root span запитів до LLM від клієнтів завжди “Received Proxy Server Request”:
Він в себе включає системні спани – запити до бази даних, кеш Redis і т.д.
З OTEL_SEMCONV_STABILITY_OPT_IN можна переключити те, як створюються імена спанів – замість litellm_request будуть спани по запитам до конкретних моделей:
Власне, по спану litellm_request: в документації в частині “Change in v1.81.0” явно сказано, що починаючи з версії 1.81.0 його не має бути (якщо явно не задана опція USE_OTEL_LITELLM_REQUEST_SPAN=true), але в мене на v1.87.1 вона все одно є, навіть якщо задати USE_OTEL_LITELLM_REQUEST_SPAN=false явно.
Anyway, поки це не дуже важливо, і загальна структура спанів по дефолту доволі чітка:
“Received Proxy Server Request“: рутовий спан, з основними атрибутами типу атрибути http.response.status_code, http.route, metadata.user_api_key_team_alias тощо
“litellm_request“: основний спан для даних по запиту клієнта до LiteLLM (якого наче не має бути – але в мене є)
Якщо з “Received Proxy Server Request” все більш-менш просто – це такий собі “інфраструктурний спан”, який містить інформацію по HTTP-запиту, коду відповіді тощо, то з litellm_request та raw_gen_ai_request ситуація інша – бо вони дуже схожі.
Знов-таки – пропустимо момент, що litellm_request по-дефолту не має бути взагалі 🙂
Як особисто я для себе розумію:
litellm_request: це “погляд на запит зі сторони LiteLLM” – що отримали від клієнта, скільки грошей запит має коштувати, які Guardrails застосовані на стороні LiteLLM, що було повернуто у відповіді клієнту
raw_gen_ai_request: а це “погляд на запит зі сторони LLM provider” – що реально пішло до провайдера, що провайдер повернув у відповіді
І, наприклад, запит може виглядати так:
litellm_request (1 спан) – отримано запит від клієнта, передається до провайдера
raw_gen_ai_request: перша спроба запиту до API провайдеру, але OpenAI повернув 429
raw_gen_ai_request: друга спроба запиту – LiteLLM виконує retry/fallback, OpenAI повернув відповідь і 200 – ми повернули відповідь клієнту
До того ж – не перевіряв, чисто моє припущення, але, мабуть, має бути так – в duration будуть різні значення, бо:
litellm_request.duration: час LiteLLM overhead + час на запит до OpenAI
приклади: metadata.requester_ip_address, metadata.requester_metadata (якщо end_user робити через metadata), metadata.user_api_key_alias
hidden_params.*: додається самим LiteLLM, містить додаткові дані по сформованому запиту до провайдера:
приклади: hidden_params.api_base, hidden_params.api_key (саме провайдера, а не клієнта, і хеш ключа, а не сам ключ), hidden_params.model, той самий x_ratelimit_limit_requests по RPM/TMP,
Шукаємо як {resource_attr:service.name="litellm"} name:="raw_gen_ai_request".
Тут маємо:
llm.openai.error, llm.openai.model
значення “openai“, тобто провайдера, додається тільки при Completions API, якщо використовується старий Responses API – то тут буде значення None (моделі Anthropic ще не використовували, не знаю, що буде там)
llm.openai.id: response ID від провайдера – з префіксом chatcmpl_ для Completions API або resp_ – для Responses API
span_attr:llm.openai.messages – промпт, відправлений до LLM (аналог атрибутів gen_ai.input.messages та gen_ai.output.messages в спані litellm_request)
VictoriaTraces та приклади запитів з LiteLLM Traces
В частині VMAlert та Recording Rules з VictoriaTraces поста по трейсам в VictoriaTraces вже писав про створення метрик зі спанів – зараз просто приклади того, що цікавого можемо отримати з трейсів від LiteLLM.
Більшість корисних даних вже є в метриках – але іноді може знадобитися витягнути щось напряму зі спанів.
Спани “Received Proxy Server Request”
Requests та Errors Rate
Кількість запитів по кодам відповіді:
{resource_attr:service.name="litellm"} name:="Received Proxy Server Request"
| stats by ("span_attr:http.response.status_code") rate()
Або тільки помилки – додаємо фільтр "span_attr:http.response.status_code":!="200":
{resource_attr:service.name="litellm"} name:="Received Proxy Server Request"
| "span_attr:http.response.status_code":!="200"
| stats by ("span_attr:http.response.status_code") rate()
Або по "span_attr:error.code" і "span_attr:error.type":
{resource_attr:service.name="litellm"} name:="Received Proxy Server Request"
| "span_attr:http.response.status_code":!="200"
| stats by ("span_attr:error.type", "span_attr:error.code") rate()
Пам’ятаємо про дефолтний OTEL span атрибут status_code:
0 (UNSET): статус не виставлений (дефолт)
1 (OK): span завершився успішно
2 (ERROR): span завершився з помилкою
Requests Rate by Provider endpoints
Отримати статистики по ендпоінтам запитів до провайдера – атрибут span_attr:http.route:
{resource_attr:service.name="litellm"} name:="Received Proxy Server Request"
| stats by ("span_attr:http.route") rate()
Stats by custom fields
Ще приклад: в коді нашого Backend API є json_schema для запитів до LLM:
class ChallengeSuitabilityResponse(BaseModel):
"""LLM response for whether a challenge is suitable for a user."""
is_suitable: bool
Ця схема використовується конкретною фічею (parent-класом в коді) ChallengeWidgetGenerator – а тому можна отримати статистику запитів до LLM кожною конкретною фічею:
{resource_attr:service.name="litellm"}
| extract "'name': '<schema_name>'" from "span_attr:llm.openai.response_format"
| stats by (schema_name) count() as requests
| sort by (requests desc)
Це, звісно, костиль ще той – але чисто для прикладу.
{resource_attr:service.name="litellm"} name:="litellm_request"
| stats by ("span_attr:gen_ai.cost.total_cost") rate()
Або використання токенів на секунду:
{resource_attr:service.name="litellm"} name:="litellm_request"
| stats by ("span_attr:gen_ai.usage.total_tokens") rate()
hidden_params та LogsQL unpack_json
Ще приклад зі span_attr:hidden_params: це вкладений JSON, тому використовуючи unpack_json із VictoriaLogs LogsQL ми можемо розпарсити його окремими полями:
{resource_attr:service.name="litellm"} name:="litellm_request" "span_id":="6a6940892541b361"
| unpack_json from "span_attr:hidden_params"
І тепер маємо api_base або model_id як звичайні лейбли:
Або для зручності можна додати власний префікс:
{resource_attr:service.name="litellm"} name:="litellm_request" "span_id":="6a6940892541b361"
| unpack_json from "span_attr:hidden_params" result_prefix "hp_"
І будуємо собі дані по, наприклад, RPM та TMP провайдера:
{resource_attr:service.name="litellm"} name:="litellm_request" "span_id":="6a6940892541b361"
| unpack_json from "span_attr:hidden_params" result_prefix "hp_"
| stats min("hp_additional_headers.x_ratelimit_remaining_requests") as min_remaining_requests, min("hp_additional_headers.x_ratelimit_remaining_tokens") as min_remaining_tokens
Але з "span_attr:gen_ai.input.messages" так не вийде – бо там не JSON object, а масив з [...].
Заодно перевіримо інтеграцію із вже існуючим стеком моніторингу – поки тільки метрики до VictoriaMetrics. Логи в VictoriaLogs будуть по дефолту, а VictoriaTraces вже підключимо в наступній частині – хоча це робиться дуже просто.
Що маємо – AWS Elastic Kubernetes Service, вже існуючий сервер AWS RDS з PostgreSQL, вже існуючий AWS Application Load Balancer.
Для LiteLLM окрім PostgreSQL рекомендується мати Redis – для Caching і синхронізації лімітів TPM та RPM, теж додам – подивимось, як воно працює і що дає.
Є Terraform providers, і доволі багато, наприклад scalepad/litellm, але я буду робити без Terraform – трохи clickops в AWS і чистий Helm для деплою.
$ helm show chart oci://ghcr.io/berriai/litellm-helm
Pulled: ghcr.io/berriai/litellm-helm:1.87.1
...
version: 1.87.1
Качаємо чарт, розпаковуємо:
$ helm pull oci://ghcr.io/berriai/litellm-helm --version 1.87.1 --untar
$ cd litellm-helm/
Дивимось, що є в чарті і що нам варто змінити під себе.
Корисні Helm values
Що треба буде змінити:
replicaCount: для Production варто поставити 2 чи 3
image.tag: задаємо конкретну версію, а не юзаємо latest
serviceAccount.name: якщо використовується AWS RDS з IAM Database Authentication (див. AWS: RDS з IAM database authentication, EKS Pod Identities та Terraform) або треба видати доступ до AWS Secrets Manager, то можна передати власний ServiceAccount – але в моєму випадку RDS без IAM, а в AWS Secrets Manager ходить External Secrets Operator, у якого налаштовані власні доступи
environmentSecrets: можемо створити власний Kubernetes Secret і передати його тут – так і буде з сікретами від ESO
environmentConfigMaps: можемо створити окремий ConfigMap зі змінними оточення – корисно, можемо передати параметри типу max_requests_before_restart, див. CLI Arguments
ingress: налаштуємо Ingress з AWS ALB
masterkeySecretName та masterkeySecretKey: передамо параметри для отримання $LITELLM_MASTER_KEY
proxy_config: можна прямо в values описати параметри самого LiteLLM – але я зробив через окремий ConfigMap і передав в values через proxyConfigMap
autoscaling та keda: добре, що є – але для нас поки не актуально
tolerations, affinity: нам треба, бо критичні сервіси працюють на виділеній групі WorkerNodes
db: опишемо підключення до PostgreSQL
так як у нас сервер зовнішній – то відключимо deployStandalone
логін-пароль передамо через Secret, який буде створювати ESO
redis: включимо деплой дефолтного із саб-чарту, хоча можна підключити зовнішній
Отже, окрім чарту у власному templates/ треба буде описати тільки два ресурси – ConfigMap з параметрами LiteLLM та ExternalSecret для External Secrets Operator.
Підготовка до деплою
Значення всяких ключів і паролів треба мати до деплою Helm chart – тому починаємо з них.
Створення LLM Providers API Key
У нас для тесту будуть Anthropic та OpenAI – створюємо ключі для них:
Генеруємо $LITELLM_MASTER_KEY:
$ echo "sk-$(openssl rand -hex 16)"
sk-b75***630
Зберігаємо їх, потім разом з даними для PostgreSQL додамо до AWS Secrets Manager.
Створення PostgreSQL User && Database
Генеруємо пароль юзера:
$ pwgen 12 1
bai***vah
Створюємо юзера і базу:
ops_grafana_db=> CREATE USER ops_litellm_user WITH PASSWORD 'bai***vah';
CREATE ROLE
ops_grafana_db=> CREATE DATABASE ops_litellm_db OWNER ops_litellm_user;
CREATE DATABASE
ops_grafana_db=> GRANT ALL PRIVILEGES ON DATABASE ops_litellm_db TO ops_litellm_user;
GRANT
З dataFrom.extract ESO отримає JSON з AWS Secrets Manager і запише його до Kubernetes Secret litellm-secrets в data у вигляді $KEY:VALUE, а Pods змонтують цей сікрет собі з envFrom.secretRef і передать ці $KEY:VALUE як змінні оточення в контейнерах з LiteLLM.
ConfigMap для LiteLLM Proxy Config
Робимо окремим ресурсом – простіше читати values, простіше менеджити і апдейтити.
Поки мінімальний – треба просто запустити сервіс, тюнити будемо потім.
Створюємо файл templates/proxy-config.yaml з двома моделями – тут в мене вже трохи параметрів для моніторингу, про це в наступному пості:
Не знаю, як нині модно-маладьоджно крутити Redis в Kubernetes, бо я це останній раз робив років 5 тому і що до того, як Bitnami скурвилась внесла зміни.
Але з дефолтним мінімальним “redis.enabled=true” все завелось, єдиний нюанс був з Docker tag, бо по-дефолту тягнуло docker.io/bitnami/redis:7.2.4-debian-12-r9, якого чи то нема взагалі, чи він “закритий paywall” – я Bitnami давно не користуюсь, тому не дуже в курсі які там саме зміни були.
Тому зараз просто взяв @latest – поки система більше в PoC то можна і так. А якщо вже будемо йти в повноцінний продакшен – то копну в Redis окремо, або просто візьму AWS ElastiCache.
Створення Makefile
Спрощуємо собі локальне життя (сюди ж можна додати і таргети для CI/CD) – додаємо Makefile аби не писати кожен раз команди:
Логінимось з admin та $LITELLM_MASTER_KEY, перевіряємо доступні моделі – обидві задані в proxy-config.yaml на місці:
Перевірка роботи LiteLLM з Claude Code
Цікаво було глянути як проксювати доступ до Anthropic через LiteLLM у Claude Code.
Вже десь далі, може, опишу як налаштовував наш Backend API та інші сервіси, хоча там все доволі просто – треба просто додати води додати base_url та замінити API-ключі.
Створення LiteLLM User та Virtual API Key з LiteLLM API
В документації говориться, що “By default /metrics endpoint is unauthenticated” – але при деплої з цього чарту при доступі до метрик пише що “Unauthorized access to metrics endpoint” – хоча в values ніде параметрів не побачив.
Можна відключити явно з require_auth_for_metrics_endpoint=false – бо все одно Internal ALB, але можемо скористатись нагодою постворювати юзерів ще.
Створюємо нового read-only admin user – роль proxy_admin_viewer (див. User Roles), бо звичайному юзеру доступу до метрик все одно не дало:
Зберігаємо отриманий ключ до $LITELLM_RO_ADMIN_KEY в AWS Secret Store, оновлюємо деплой, перевіряємо метрики з header “Authorization: Bearer $LITELLM_RO_ADMIN_KEY“:
$ curl -s -H "Authorization: Bearer $LITELLM_RO_ADMIN_KEY" https://aigw.ops.example.co/metrics/ | tail
# HELP litellm_check_batch_cost_jobs_processed_total Total number of batches successfully cost-tracked by CheckBatchCost
# TYPE litellm_check_batch_cost_jobs_processed_total counter
# HELP litellm_check_batch_cost_errors_total Total number of errors in CheckBatchCost by error type
# TYPE litellm_check_batch_cost_errors_total counter
# HELP litellm_check_batch_cost_last_run_timestamp Unix timestamp of the last CheckBatchCost job run
# TYPE litellm_check_batch_cost_last_run_timestamp gauge
litellm_check_batch_cost_last_run_timestamp 0.0
# HELP litellm_in_flight_requests Number of HTTP requests currently in-flight on this uvicorn worker
# TYPE litellm_in_flight_requests gauge
litellm_in_flight_requests 1.0
При inlineScrapeConfig нам static_configs не дуже підходить – бо LiteLLM має кілька подів, і метрики збирати треба з усіх, але просто для перевірки можна поки зробити так.
Додаємо новий job_name, bearer_token поки вказуємо явно, потім зробимо через сікрети включив require_auth_for_metrics_endpoint=false в параметрах LiteLLM:
Працює вже з тиждень, потроху переключаємо наші production сервіси – поки що політ нормальний.
Далі треба буде додати моделей, налаштувати метрики і трейси, подивитись які цікаві алерти та дашборди в Grafana можна створити – наступний пост вже в чорнетках.