Робиться для нашого нового сервісу, який ще в експерементальній/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 можна створити – наступний пост вже в чорнетках.
Поки роблю LiteLLM (див. LiteLLM: AI Gateway для LLM – overview можливостей), то з’явилась ідея окрім сервісів типу нашого Backend API – помоніторити і Claude Code девелоперів. Чисто інтересу заради – аби побачити що там взагалі є і хто як використовує нашу Anthropic Organization, бо у багатьох там підписка за 200 баксів, яку оплачує проект (в мене дешманська за 20 😥 )
Насправді ця ідея в мене з’явилась давно, але зараз до неї повернувся, бо активно пиляю моніторинг AI, і коли почав робити LiteLLM – то згадав і про Claude Code.
Але моніторити Claude Code з LiteLLM рішення не дуже – бо у нас subscriptions, а LiteLLM вміє тільки в API – тому роутити Claude Code через нього просто для того, аби отримати метрики і трейси така собі затєя.
Втім, Claude Code має власний вбудований моніторинг – вміє слати і трейси, і метрики, і логи в OpenTelemetry форматі, а тому можна просто скористатись ним.
Головна проблема тут була в тому, як всіх девелоперів змусити налаштувати свої інстанси – але і тут знайшлось рішення.
Хоча в більшості гайдів, які читав все ж використовують OpenTelemetry Collector, який отримує дані від Claude Code і передає до бекендів – але стек OpenTelemetry у нас поки на паузі, ще не дійшли руки, тому роблю простіше – і шлю дані відразу до VictoriaMetrics.
Всі дані Claude Code передає в форматі OpenTelemetry – але всі сервіси VictoriaMetrics чудово з ним працюють без додаткових налаштувань.
Claude Code Telemetry Config
Всі налаштування задаються через змінні оточення, з цікавих додаткових параметрів:
OTEL_LOG_USER_PROMPTS: логувати промпти юзерів – цікаво, але не треба 🙂
OTEL_LOG_TOOL_DETAILS та OTEL_LOG_TOOL_CONTENT: записувати додаткові дані про використання Tools, Skills, MCP, etc
OTEL_LOG_RAW_API_BODIES: зберігати повний зміст API запитів та відповідей – можна глянути, що там відбувається “під капотом”
А зі змінною OTEL_RESOURCE_ATTRIBUTES можемо додати кастомні атрибути.
Ще можна глянути параметри для OTEL_METRIC_EXPORT_INTERVAL – як часто Claude Code буде відправляти дані, по дефолту він їх накопичує 60 секунд і потім шле пачкою.
Поїхали тестити.
Testing locally
Спочатку глянемо локально, як воно працює і що там цікавого є – а потім додамо до нашої організації і розкатаємо на всіх юзерів.
Запускаємо в тому ж вікні термінала claude, і за пару хвилин маємо метрики.
Так як це OTel – то формат запиту буде {__name__="claude_code.token.usage"}:
З цікавих метрик тут:
claude_code.cost.usage: умовна вартість використання – умовна, бо у нас subscriptions, але Claude все одно рахує кількість токенів і знає, скільки б це коштувало при роботі напряму через API
claude_code.token.usage: власне, кількість токенів
claude_code.lines_of_code.count: скільки коду нагенерив Claude
claude_code.pull_request.count: скільки Pull Requests
Для мене і проекту цей моніторинг не такий важливий – тому не буду витрачати час на створення власної борди, як роблю зазвичай, а візьму щось готове і трохи поправлю під себе.
VictoriaMetrics та OpenTelemetry vs Prometheus naming
Єдиний момент з готовими дашбордами в тому, що вони використовують метрики в Prometheus-форматі, а так як в мене дані від Claude Code йдуть напряму до VictoriaMetrics – то і імена там будуть в OTel форматі.
Але можна додати опцію usePrometheusNaming – тоді VictoriaMetrics буде зберігати їх в звичайному форматі, див. Label sanitization.
Але взагалі на проекті ця тема з’явилась коли ми зрозуміли, що використання LLM стає важливою частиною нашого продукту – але на відміну від інших компонентів в мене нема ніякого моніторингу того, як взагалі LLM використовується, скільки токенів витрачає кожен із сервісів, скільки помилок ми отримуємо.
Тому почали все це діло “обмазувати” трейсингом для отримання даних від наших сервісів. На додачу у нас є окремий самописний OpenAI Exporter, який з OpenAI API збирає дані по токенам і витраченим грошам.
Втім, коли все “обмазали” і почали отримувати дані з різних систем в єдиний бекенд – VictoriaMetrics/VictoriaTraces – та робити дашборди в Grafana і метрики з VMAlert, то виявилась одна неприємна річ: різні компоненти використовують різні бібліотеки для роботи з LLM, різні бібліотеки для створення spans, по різному створюють атрибути – а тому доводиться робити різні дашборди, різні алерти.
Варіанти рішення тут, звісно, є: або переписувати код всіх систем з використанням однакових бібліотек – або створювати кастомні атрибути для спанів, щоб вони були однакові в усіх сервісах.
Але це, по-перше – багато змін в коді, по-друге – не хочеться обмежувати девелоперів правилами типу “використовуй тільки цю бібліотеку” або “завжди додавай такі атрибути до спанів”.
Тому вирішив подивитись на інший підхід: нехай всі роблять те, що хочуть – але всі запити від всіх систем відправляти через єдиний шлюз, AI Gateway – а він вже сам буде створювати і трейси, і метрики – і тоді у всіх буде загальний контекст у вигляді загальних атрибутів/лейблів/метрик.
Крім того, загальний гейтвей вирішить ще пачку задач – і централізований менеджмент доступів, і бюджети з лімітами, і failover між OpenAI/Anthropic, якщо одна система впала.
Сьогодні подивимось на те, що таке LiteLLM взагалі, поганяємо локально в Docker, а потім, якщо сподобається (а so far – подобається, хоча деякі питання виникли) – то запустимо в Kubernetes та інтегруємо з нашим існуючим стеком моніторингу – VictoriaMetrics, VictoriaLogs, VictoriaTraces, Grafana.
Отже, що таке таке LiteLLM: це система для створення єдиного шлюзу, яка через себе проксює всі запити до LLM і різних провайдерів – Backend API може слати запити до AWS Bedrock для RAG, клієнтські AI Agents можуть слати запити до OpenAI або Anthropic, і навіть Claude Code девелоперів можна зароутити через цей шлюз та отримати картину того, хто і скільки токенів використовує (правда, у випадку з Claude Code питання використання API, бо LiteLLM наче не вміє працювати через subscription – тільки API).
При цьому ми спокійно залишаємо вже існуючі метрики та трейси, які вже створюються із сервісів – бо вони вже звичні девелоперам і трохи інтегровані в наш моніторинг. А на додачу до них – отримаємо нові, з загальним контекстом для всього нашого проекту та AI/LLM в ньому.
Є також LiteLLM Python SDK – можна мати всі можливості LiteLLM прямо з коду без необхідності піднімати окремий proxy service.
З цікавих можливостей LiteLLM:
Admin Web UI: єдиний веб-інтерфейс для моніторингу і налаштувань
Alerting & Monitoring: з коробки маємо логи, метрики, алерти, інтеграцію з Prometheus/VictoriaMetrics та системами типу Phoenix/Langfuse
Cost tracking: з коробки автоматично моніторить витрати на роботу з моделями, повертає метрики та трейси з вартістю, можна налаштовувати бюджети на різні ключі, команди, юзерів
Centralized authentication: єдина система для управління доступами – групи, юзери, ключі, окремі бюджети і ліміти, та навіть обмеження доступу до LiteLLM по IP
Skills Registry: тримаємо всі скіли в одному місці – але це начебто тільки для Claude Code
MCP Gateway: можна мати всі налаштовані MCP servers на LiteLLM – і клієнти типу VSCode, Cursor, Claude Code просто звертаються до нього
Agent Gateway: можна мати проксі для agent-to-agent комунікації і моніторити всю цю активність
LLM Response caching: LiteLLM може тримати кеш відповідей від LLM – на той самий запит від клієнта повертати закешовану відповідь, а не робити новий запит до LLM
Memory: зберігання налаштувань і контексту між сесіями
Vector Store: LiteLLM може грати роль проксі до різних Vector Stores і записувати додаткові дані для моніторингу
Guardrails: захист конфіденційних даних – prompt injection, маскування даних юзерів
Policies: є набір готових політик, можна створювати власні
Load Balancing: автоматичне балансування між різними провайдерами та/або моделями в залежність від навантаження чи пріорітетів
Model Health Status: перевірка статусу LLM і виключення з роутінгу тих провайдерів, які недоступні
Fallbacks: автоматичний роутинг запитів, якщо модель чи провайдер недоступні
Traffic Mirroring: цікава можливість – відправляти запити одночасно до двох різних моделей, аби порівнювати результати їхньої роботи
Для чого це нам?
Кожного разу, коли хочеться запустити щось новеньке – треба спитати себе “А яку, власне, проблему ми вирішуємо”?
Конкретно в нашому випадку це:
менеджмент доступів: замість 100500 API ключів в OpenAI/Anthropic – мати налаштовані групи в LiteLLM, кожен з власними бюджетами і лімітами
моніторинг: мати загальні метрики, логи, трейси з загальними лейблами/атрибутами
failover: мати можливість автоматично переключитись на іншого провайдера, якщо на поточному вперлись в ліміти (або якщо Claude знову впав)
Запуск LiteLLM з Docker
Для повноцінної роботи Лайт потрібна база даних – в ній будуть зберігатись всі юзери та групи, налаштування моделей, бюджетів, витрати на LLM, див. What is stored in the DB.
Тому з Docker створимо два контейнери – сам Gateway та PostgreSQL для нього.
model_name: ім’я, яке отримуємо в запиті від клієнта (як ми його будемо вказувати в коді, наприклад client.chat.completions.create(model="gpt-4o-mini")
Тут PostgreSQL з health check, який використовується інстансом LiteLLM, а через змінну оточення DATABASE_URL для LiteLLM передаємо connection string для підключення до бази даних.
Генеруємо ключ для $LITELLM_MASTER_KEY – в OpenAI форматі, з префіксом sk- (“secret key”):
Заходимо на http://0.0.0.0:4000 – тут посилання на Admin UI та документація по API самого LiteLLM (Swagger Docs можна відключити з NO_DOCS=true, див. environment variables – Reference, але вцілому її цікаво глянути – бо можливостей в API дуже багато):
Логінимось в адмінку – дефолтний логін “admin“, пароль – $LITELLM_MASTER_KEY, який створювали вище:
І попадаємо в дуже приємний інтерфейс:
Вже маємо метрики, але ендпоінт саме /metrics/ – зі слешем в кінці (хоча в документації вказаний як /metrics):
$ curl -s http://localhost:4000/metrics/
...
# 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
По різним налаштуванням пройдемось далі – зараз давайте створимо “клієнта” – простенький скрипт, який звертається до OpenAI через LiteLLM.
Demo Python App – AI Client
Пишемо скрипт, який використовує OpenAI і передає один промпт:
#!/usr/bin/env python
import os
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:4000",
api_key=os.getenv("LITELLM_MASTER_KEY"),
)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Say hello in one sentence"}],
)
print(response.choices[0].message.content)
print(f"Tokens: {response.usage}")
Тут:
base_url для OpenAI: замість дефолтного OpenAI ендпоінта api.openai.com перевизначаємо ендпоінт нашого інстансу LiteLLM
model: ім’я моделі, як ми його задавали в параметрах LiteLLM – model_list.model_name
$ ./demo-llm.py
Hello! How can I assist you today?
Tokens: CompletionUsage(completion_tokens=9, prompt_tokens=12, total_tokens=21, completion_tokens_details=CompletionTokensDetails(accepted_prediction_tokens=0, audio_tokens=0, reasoning_tokens=0, rejected_prediction_tokens=0), prompt_tokens_details=PromptTokensDetails(audio_tokens=0, cached_tokens=0))
Переходимо в адмінку > Usage – і вже маємо дані по запитам:
Трейси дивимось в Logs:
Та нові метрики:
$ curl -s http://localhost:4000/metrics/ | grep "# HELP lite"
# HELP litellm_in_flight_requests Number of HTTP requests currently in-flight on this uvicorn worker
# HELP litellm_proxy_failed_requests_metric_total Total number of failed responses from proxy - the client did not get a success response from litellm proxy
# HELP litellm_proxy_total_requests_metric_total Total number of requests made to the proxy server - track number of client side requests
# HELP litellm_proxy_total_requests_metric_created Total number of requests made to the proxy server - track number of client side requests
...
# HELP litellm_total_users Total number of users in LiteLLM
# HELP litellm_teams_count Total number of teams in LiteLLM
Тепер, як маємо сам LiteLLM та клієнта – можна подивитись що ж можемо з LiteLLM цікавого робити, і перше, що цікавить особисто мене – це моніторинг.
Monitoring, OpenTelemetry та Traces
Метрики будемо збирати з VMAgent або OTel Collector, логи – просто аутпут, який, якщо в Kubernetes, то збираємо з Promtail, vlagent, OTel filelog, whatever.
Цікаві метрики розберемо далі, але сьогодні їх збирати не будемо – бо зараз все локально в Docker, документація по всім доступним метрикам – Prometheus metrics.
А от глянути як можна писати трейси до VictoriaTraces можемо.
Замість або на додачу до “otel” можна вказати “langfuse” або “arize” для Phoenix (див. Arize Phoenix: сервіс моніторингу LLM – запуск в Kubernetes) – тоді трейси будуть відправлятись в кілька сервісів – тестив, працює, зручно, прикольно.
Перезапускаємо контейнери, в логах маємо побачити, що експортери активні:
$ ./demo-llm.py
Hello! How can I assist you today?
Tokens: CompletionUsage(completion_tokens=9, prompt_tokens=12, total_tokens=21, completion_tokens_details=CompletionTokensDetails(accepted_prediction_tokens=0, audio_tokens=0, reasoning_tokens=0, rejected_prediction_tokens=0), prompt_tokens_details=PromptTokensDetails(audio_tokens=0, cached_tokens=0))
І в VictoriaTraces шукаємо спани по {"resource_attr:service.name"="litellm"}:
Або відразу в Grafana з групуванням:
LiteLLM Span Attributes
Атрибутів прям дуже багато – просто відразу маємо все, без всяких інструментаріїв в коді.
gen_ai.response.finish_reasons: чому зупинилась генерація
Access Management
Основна концепція – можемо мати різні Organizations (але це Enterprise feature), кожна Organization може містити кілька Teams, в кожній Team – тримаємо Users, а кожен User може створювати власні API Keys – див. User Management Hierarchy.
Users логіняться у Web UI або API, а API Keys використовуємо для сервісів.
Аутентифікація та доступ
Основний метод – API Keys для сервісів або юзерів, які працюють з LiteLLM через API – там звичайні паролі для юзерів, які користуються Web UI.
Є підтримка аутентифікації з JWT – але це Enterprise фіча.
З коробки маємо SSO – “SSO is now Free for up to 5 users“, більше юзерів тільки в Enterprise, див. SSO for Admin UI.
Втім, можемо автоматизувати це з litellm-operator – може якось спробую, але не впевнений, бо оператор не офіційний.
І дуже прикольна штука – обмеження по IP, див. IP Address Filtering – але знов-таки Enterprise фіча 🙁
В результаті з Free-опцій маємо тільки власне, Teams, юзерів та API Keys.
Teams та Users
Кожній Team можна налаштувати параметри того, до яких моделей із model_list юзери і ключі цієї групи будуть мати доступ, максимальний бюджет Costs, яка група може витратити на день/тиждень/місяць, можемо задати ліміти на Tokens per minute Limit (TPM) та Requests per minute Limit (RPM) – див. Budgets, Rate Limits та Setting Team Budgets.
Крім того, є Access Groups – групуємо списки моделей, MCP або агентів в єдиний список, який потім можна підключати до Teams або Users.
Ну і в моніторингу, як бачили вище, маємо атрибути з іменами груп та юзерів – тому потім можемо будувати графіки та алерти по ним.
Бюджети та ліміти на Tokens/Requests Per Minute можна задати на рівні всього Gateway, на рівні Team, для кожного юзера в цій Team, або на окремих юзерів поза Team чи на конкретні API Keys.
Але тут є одна трохи дивна, як на мене, штука:
Team Limits застосовуються тільки для тих API Keys, які явно були створені юзером або адміном для цієї групи (мають team_id)
при цьому User з Global Proxy Role == Internal User (Create/Delete/View) може створювати власні ключі (не прив’язані до Team), не задавати їм ніяких лімітів – і спокійно спамити LLM запитами
єдине обмеження, яке ми можемо задати в Web UI при створенні нового юзера – це які моделі йому будуть доступні (хоча в API docs для /user/new є параметри max_budget, rpm_limit та tpm_limit – нижче буде приклад)
Тобто з одного боку – начебто є User, який є членом Team, і в Team ми задаємо, наприклад, Team Member RPM Limit – але при цьому цей юзер може створювати ключі, на які цей ліміт ніяк не впливає.
А єдине обмеження, яке ми можемо задати при створенні юзера в Web UI – це те, які моделі йому будуть доступні – хоча через конфіг-файл ще можна задати upperbound_key_generate_params, див. All Settings for Self Serve.
Виглядає так, наче UI просто ще не має всіх опцій, які доступні в API.
Взагалі, мабуть, треба буде писати окремий пост на тему доступів і лімітів, бо тут “нє всьо так однозначно”.
RBAC та System Roles
Простенький (принаймні на зараз, у версії v1.82.6), з кількома дефолтними ролями – але є RBAC.
Ролі поділені на три основних групи:
глобальні всього LiteLLM – admin та admin_read_only
Ну і давайте глянемо як це все виглядає на практиці: створимо Team з бюджетом та лімітами на Requests per minute, потім в цю групу додамо юзера, юзеру створимо API Key – і використаємо його в нашому Demo App.
Створення Team
Переходимо в Teams > Create Team:
Створюємо групу:
Тут задаємо доступ до всіх моделей, вказуємо загальний бюджет групи в 100 долларів на день (Reset Budget: daily), і для перевірки задамо жорсткий ліміт в 1 запит на хвилину.
Бюджети і ліміти в Team задаються на двох “рівнях” – самої групи і всіх юзерів в ній, та окремо на кожного юзера (точніше – його ключів, як створені в цій групі – див. далі), тобто:
Max Budget (USD): це бюджет всіх разом, а Team Member Budget (USD) – на кожного юзера в групі
Requests per minute Limit (RPM): на всю Team, а Team Member RPM Limit – кожного юзера в групі
Бюджети створюються як окремі об’єкти, доступні в Budgets:
І тут ловив ще одну чи то багу, чи то фічу, що після зміни значень в Team Budget значення бюджету для юзера не змінились, поки не зробив це руками саме в Budgets.
Нижче в параметрах нової Team у Router Settings можна налаштувати власні параметри для Load Balancing та Fallbacks:
Створення User у Web UI
Юзери в UI створюються через Invite, який відправляється на пошту – тому треба мати SMTP, але після створення Invite у нас буде показаний лінк, за яким можемо зареєструватись.
Клікаємо Invite User:
Задаємо роль з правами на створення ключів, вибираємо створену вище групу, в Personal Key Creation можна обмежити доступ до моделей – і це, власне, єдине обмеження, яке ми тут можемо встановити для юзера:
Ба більше: під час створення юзера в Team – йому не можна відразу задати Team Role, і він буде створений з дефолтною роллю User – але це можна змінити потім.
Клікаємо на Invite User – отримуємо посилання, яке було відправлено на пошту:
Відкриваємо його в Incognito, задаємо пароль нового юзера, і попадаємо в Web UI – але тут вже, звісно, набагато менше доступів:
Team Permissions
Вже після інвайту можемо змінити роль юзера в цій групі – бо без Admin ролі він не зможе створювати ключі в групі, і навіть встановити йому власні Member Limits/Budget:
Інший варіант дозволити створення ключів для групи – задати через Member Permissions:
Створення User API Key для Team у Web UI
Тепер під цим юзером створюємо ключ – вказуємо групу, але не задаємо RPM:
В коді Demo App міняємо назву змінної з LITELLM_MASTER_KEY на LITELLM_USER_KEY, і можна додати max_retries – аби ловити Exception відразу, як LiteLLM поверне клієнту 429:
Запускаємо скрипт – і спокійно спамимо LiteLLM запитами:
$ ./demo-llm.py
Hello! How can I assist you today?
...
$ ./demo-llm.py
Hello! How can I assist you today?
...
Тобто, якщо ми даємо юзерам можливість створювати ключі – то вони спокійно можуть робити ключі без всяких обмежень (окрім тих, що ми задамо глобально у upperbound_key_generate_params).
Але при створенні юзера чи ключа через API ми відразу можемо задавати всі потрібні ліміти.
Створення User та API Key з LiteLLM API з Rate Limit
Запускаємо скрипт два рази – і на другий знов отримуємо 429:
$ ./demo-llm.py
Hello! How can I assist you today?
...
$ ./demo-llm.py
...
openai.RateLimitError: Error code: 429 - {'error': {'message': 'Rate limit exceeded [...] }
Замість висновків
Система виглядає дійсно круто в плані того, що дозволяє мати загальний моніторинг LLM – з коробки маємо купу корисних метрик, маємо трейси. Ну і 50,000 зірок на GitHub далеко не всі збирають.
Трейси чудово інтегруються із зовнішніми системами, і дуже зручно, що з коробки можемо їх відправляти в кілька різних бекендів одночасно.
Але от з юзер менеджментом в мене виникли питання – бо якось не дуже інтуїтивно зроблено. Місцями, на перший погляд, заплутано, місцями з чимось, що виглядає як баги. Хоча вцілому можливостей по менеджменту доступів дійсно багато.
Все ж спробуємо її запустити у нас і подивимось вже в реальній роботі – благо, коли проект в MVP то можна собі дозволити експерименти.
Ну і коли буду запускати в Kubernetes – мабуть, ще раз окремо пройдусь по доступам і юзерам, бо тут треба розібратись додатково.
В RouterOS не надто гнучка система для управління доступами – далеко не RBAC в Kubernetes, але, звісно, є можливість створювати групи і юзерів з різними правами доступу.
В комплекті маємо 3 дефолтні групи:
full: повний доступ – дефолтний юзер admin саме в цій групі
write: можна змінювати більшість налаштувань – але без доступу до user management
read: тільки читати конфіг – але нічого змінювати
хоча дозвіл на reboot тут є
Всі user permissions кожного юзера в кожній групі визначаються набором політик цієї групи.
Політики дефолтні, вони вже є в системі – редагувати чи створювати нові ми не можемо.
Подивитись список груп та політики кожної групи:
/user group print
Policies
Політики діляться на дві основні групи – Login і Config.
З того, що цікаво прямо зараз:
login policies:
ssh, web, winbox: як можемо підключатись
password: зміна пароля (тільки для себе)
config policies:
read та write: читання чи редагування конфігурації роутера
policy: управління групами та юзерами
test: використання утиліт типу ping, traceroute, etc
sensitive: чи будуть відображатись ключі, сертифікати
Створення Group
Напряму підключити політики до юзера не можна – тому робимо через окрему групу, до якої потім додамо юзера.
Маємо на увазі, що один юзер може мати тільки одну групу (да, зовсім не Kubernetes RBAC…).
Створюємо групу, задаємо дозвіл на SSH чи читання конфігурації:
/user group add name=setevoy-ro policy=read,ssh
Перевіряємо список:
/user group print where name=setevoy-ro
Видалити групу – з /user group remove <ID>.
Редагування Policies для Group
Додати або видалити пермішени – /user group set.
Важливий нюанс: при set треба задавати весь новий список. Тобто, якщо передати просто “policy=web” – то група залишиться без SSH, тому передаємо всі:
/user group set [find name=setevoy-ro] policy=ssh,read,web
Або якщо треба відключити політику – вказуємо через !policy-name, або просто set без цієї політики:
/user group set [find name=setevoy-ro] policy=ssh,read,!web
Створення Users
Отримати список всіх юзерів:
/user print detail
Для кожного юзера задаються його параметри, основні:
address: звідки дозволено підключення
group: група юзера
name: ім’я
password: пароль
group=full та новий Admin User
MikroTik рекомендує видаляти дефолтного юзера admin – бо всім відоме ім’я, див. Securing your router.
Тому робимо нового адміна з дефолтною групою full та обмежуємо мережі, з яких буде доступ.
Так як тут передається пароль – то команда в історії не зберігається і відразу після Enter зникне з консолі:
/user add name=gw-setevoy-root password="change-me-now" group=full address=192.168.0.0/24,192.168.100.0/24,10.100.0.0/24 comment="New root user for RB4011"
Відключаємо дефолтного admin:
/user disable admin
Потім можна видалити взагалі з /user remove admin.
Окремо варто відключити сервіси, якими не користуємось, ну і, звісно, налаштувати фаервол – це десь в наступних частинах буде, теж в чорнетках давно лежить.
Custom Group і новий юзер
Додаємо нового read-only user з групою setevoy-ro, яку створили вище, доступ дозволяємо тільки з локальної мережі:
/user add name=gw-setevoy-ro password="change-me-now" group=setevoy-ro address=192.168.0.0/24 comment="Read only user for RB4011"
А раз є Recording Rules – то ми можемо з логів створювати метрики для алертів та Grafana dashboards.
Хоча насправді це не найкращий варіант, тим більш для якогось high load проекту, бо vmalert з Recording Rule постійно робить запити до VictoriaTraces – і тут краще підійшов би якийсь otelcol.connector.spanmetrics, який можна було б додати в pipeline.traces – але якщо OTel стеку нема або, як в моєму випадку, проект невеликий – то цілком робочий варіант робити метрики з Recording Rules.
Це вже третя частина по OpenTelemetry та VictoriaTraces, попередні тут:
Пара прикладів з моїх власних “хотєлок” на проекті – чому я почав робити таке рішення.
AWS ALB response time
У нас є метрика AWS ALB response time: тригерить алерт, коли якийсь ендпоінт починає довго відповідати. Метрика генериться з логів ALB, рахуючи поля з log record:
- record: vmlogs:alb:logs:alb_response_time:p95
expr: |
{namespace="ops-monitoring-ns"} app:="alb-logs-exporter" -"DEBUG" -"SQS" -"VMLOGS" -"PARSER"
| filter not `{"date"`*
| extract "<_> <_> <elb_id> <_> <_> <request_processing_time> <target_processing_time> <response_processing_time> <elb_status_code> <target_status_code> <received_bytes> <sent_bytes> <request_line> <user_agent> <_> <_> <_> <trace_id> <domain_name> <_> <_> <_> <_> <_> <error_reason> <_> <_> <_> <_> <conn_trace_id> <_> <_> <_>"
| extract_regexp `.*:443(?P<uri_path>/[^/?]*).* HTTP` from request_line
| filter target_processing_time :! "-1" and request_processing_time :! "-1" and response_processing_time :! "-1"
| filter request_processing_time :! "" and target_processing_time :! "" and response_processing_time :! ""
| math request_processing_time + target_processing_time + response_processing_time as total_response_time
| rename domain_name as domain
| stats by (domain, uri_path) quantile(0.95, total_response_time) as alb_response_time
І з нею відразу кілька проблем.
Перша: знов-таки – навантаження на бекенд, VictoriaLogs: якщо логів багато – то vmalert робить запит кожну хвилину (interval: 1m), до того ж в запиті є regex – який сам по собі доволі важкий в плані CPU/RAM.
Друга: лейбла uri_path в значенні має тільки першу частину URI. Тобто, якщо запит прийшов на /user/<name>/orders – то в метрику vmlogs:alb:logs:alb_response_time:p95 буде збережено тільки uri_path="/user".
Відповідно, коли приходить алерт, ми бачимо тільки дуже загальну інформацію – по самому ендпоінту, а не конкретного юзера.
Ну і головне, чому я поліз копати в цю тему – це те, що алерти ніяк не прив’язані до трейсів.
Зараз, якщо приходить алерт типу:
Все, що ми можемо зробити – це піти в Grafana dashboard для Kubernetes Pods і WorkerNodes, і там дивитись навантаження CPU/RAM. Якщо там все ок – то йти в дашборду по RDS, і розбиратись там.
А маючи метрики з трейсів – я можу прямо в алерті створити лінк на всі пов’язані трейси, і тоді відразу в Grafana та VictoriaTraces побачити де проблема.
Загалом дані в лейблах більш цікаві, ніж в прикладі з AWS ALB, бо є і частина SQL-запиту, і “connection ID” у вигляді “<db_user>@<db_host>:<PID>” – але знову-таки дебажити такі алерти складніше, бо треба брати цей connection ID, шукати його в логах RDS, потім ці логи якось пов’язувати з логами самого Backend API.
Натомість – можна мати аналогічну метрику з трейсів і генерити пряму лінку на Grafana/VictoriaTraces.
Отже, що будемо сьогодні робити – подивимось на метадані, які використовуються в OTLP для spans, опишемо кілька метрик з трейсів та напишемо кілька алертів.
Метрики HTTP
Наш Backend API “обмазаний” OTel auto-instrumentation (сподіваюсь, таки допишу пост по OTel та Python).
Трейси генеряться з усіх FastAPI та AWS викликів – і, використовуючи їх, можемо собі написати метрик та алертів.
В трейсах бекенду від FastAPI маємо поля (атрибути) http.route, http.status_code, duration – тому ми можемо створити зручні метрики з яких потім можемо створити зручні алерти.
Корисні span та resource attributes
Спершу на прикладі HTTP span від FastAPI подивимось в VictoriaTraces – що цікавого у нас є в span та resource attributes.
duration: час виконання запиту (в наносекундах) – корисно для метрик HTTP latency та PostgreSQL query duration
kind: визначає роль span – чи це наш сервіс оброблює запит від клієнта (тип 2 – SERVER), чи сервіс робить запит до зовнішнього ресурсу (тип 3 – CLIENT), див. Span Kind та самі значення в коді trace.proto
в прикладах нижче буде AWS буде span.kind=3, тобто “CLIENT” – наш сервіс є клієнтом, бо виконує вихідний виклик до AWS API
name: ім’я span, згенероване SDK – корисне, бо включає в себе відразу кілька інших атрибутів – простіше використовувати в фільтрах для Grafana (про це далі, коли будемо робити алерт)
k8s.namespace.name, node.name, pod.name: resource-level атрибути, які задаються або OTel Collector, або, як в моєму випадку, з Downward API в Kubernetes Deployment для Backend API (костиль, поки нема OTel Collector)
дуже корисні атрибути, бо дозволять будувати зв’язки між Pod-level metrics, Node-level, etc – дають загальний контекст для observability
span_attr:http.method та http.route: корисно відобразити і в алерті, і мати фільтр в Grafana для VictoriaMetrics
span_attr:http.target: якщо http.route вище – це саме роут у FastAPI (з плейсхолдером типу /chats/{chat_id}) – то в http.target вже маємо конкретний URI, який був викликаний
в цьому прикладі не дуже очевидна різниця, бо роут статичний, але в інших спанах вони виглядають як route="/chats/{chat_id}" – а в target="/chats/john-789?limit=10"
span_attr:http.status_code: не путати з status_code нижче – тут маємо саме HTTP-код відповіді від сервера клієнту
status_code: дуже корисний атрибут – OTel Span Status code (не HTTP status вище), який вказує на результат виконання операції – Success (1) або Error (2), див. Set Status
в цьому прикладі як раз добре видно, що HTTP-запит завершився з 503 – Service Unavailable, і, відповідно, статус цього span == Error
status_message: description до status_code – FastAPI/OTel, коли задавав значення status_code=2 додав текстовий опис помилки
trace_id: ну і ID самого трейсу, до якого відноситься конкретно цей span
Тепер, маючи список атрибутів – можемо подумати над метрикою.
Тут вибираємо всі трейси від service.name=~"kraken-.*" (Kraken – ім’я нашого бекенду), вибираємо тільки ті, які відносяться до HTTP – "span_attr:http.route":!"", вибираємо тільки з помилками 5хх.
Далі рахуємо per second rate, агрегуючи по Kubernetes Namespace, OTel Service Name, HTTP route (URI) та коду помилки.
Трохи забігаючи наперед: прикольно було б в алерті відразу створювати лінк на конкретний trace по його ID – але писати тут в метрику лейблу trace_id не варто, бо це мільйон різних значень – заб’ємо базу VictoriaMetrics.
Деплоїмо, перевіряємо, що метрика в VictoriaMetrics є:
Алерт “Backend HTTP 5xx Errors”
Аби не писати окремі алерти на Dev/Staging/Prod – використаємо Helm range.
Описуємо новий VMRule (я для зручності тримаю окремо Recording Rules та алерти) – вже з самим алертом:
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
name: alerts-kraken-traces-http
spec:
groups:
##########################
### Kraken Traces HTTP ###
##########################
- name: Kraken.Traces.HTTP.rules
rules:
##############################
### Kraken HTTP 5xx Errors ###
##############################
{{- range .Values.alerts.traces.backend }}
{{- $ns := . }}
{{- range .severities }}
{{- if not (eq . "critical") }}
- alert: Kraken HTTP 5xx Errors
expr: vmtraces:kraken:http:request_5xx:rate{"resource_attr:k8s.namespace.name"="{{ $ns.namespace }}",stats_result="requests_per_sec"} > 0
for: 1s
labels:
severity: {{ . }}
component: backend
environment: {{ $ns.env }}
ilert_routingkey: backend-{{ $ns.env }}-{{ . }}
annotations:
summary: "Kraken service is returning HTTP 5xx errors"
description: |-
HTTP 5xx error rate has been above 0 for more than `{{ "{{" }} $for }}`
*Namespace*: `{{ "{{" }} index $labels "resource_attr:k8s.namespace.name" }}`
*HTTP route*: `{{ "{{" }} index $labels "span_attr:http.route" }}`
*HTTP status*: `{{ "{{" }} index $labels "span_attr:http.status_code" }}`
*5xx rate*: `{{ "{{" }} printf "%.3f" $value }}` req/s
<https://{{ $.Values.monitoring.root_url }}/explore?orgId=1&left=%7B%22datasource%22:%22{{ $.Values.monitoring.victoria_traces_uid }}%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22jaeger%22,%22uid%22:%22{{ $.Values.monitoring.victoria_traces_uid }}%22%7D,%22queryType%22:%22search%22,%22service%22:%22{{ "{{" }} index $labels "resource_attr:service.name" }}%22,%22tags%22:%22http.route%3D{{ "{{" }} index $labels "span_attr:http.route" }}%22,%22limit%22:100%7D%5D,%22range%22:%7B%22from%22:%22now-1h%22,%22to%22:%22now%22%7D%7D|:grafana: VictoriaTraces>
{{- end }}
{{- end }}
{{- end }}
Тут з {{- range .Values.alerts.traces.backend }} проходимось в циклі по всім значенням з values і для кожного оточення створюємо окремий алерт.
З {{- if not (eq . "critical") }} не створюю алерти CRITICAL, бо він приходить з @channel в Slack – поки це в тесті, подивитись, як буде працювати.
OTel names формат та Helm templating
Тут є цікавий момент, пов’язаний з OTel форматом імен метрик і лейбл.
Якщо в Prometheus format вони задаються через “_” – тобто у вигляді “resource_attr_k8s_namespace_name“, то OTel використовує крапки і двокрапки – і це ломає шаблонізатор Helm/Go.
Є варіант використовувати Label sanitization – писати метрики до VictoriaMetrics відразу в Prometheus-форматі, але це (поки що) не можна робити для трейсів і логів, і тоді в різних бекендах будемо мати різні імена лейбл.
Тому я поки що вирішив не включати usePromCompatibleNaming і писати дані як є, а коли вже цю опцію додадуть до VictoriaLogs та VictoriaTraces – можна буде оновити алерти.
Тут, в принципі, все аналогічно – тільки рахується не rate() – а 95 перцентиль по полю duration.
Чому 95 перцентиль, а не якийсь avg(): бо average дасть загальну “розмазану картину” по всім запитам – але може пропустити проблеми у невеликої кількості юзерів.
Recording Rule вийшов таким:
- record: vmtraces:kraken:http:request_duration:p95
expr: |
{resource_attr:service.name=~"kraken-.*"} "span_attr:http.route":!"" kind:=2
| stats by ("resource_attr:k8s.namespace.name", "resource_attr:service.name", "span_attr:http.route") quantile(0.95, duration) value
Тут в фільтрах додаємо kind=2, аби рахувати дані тільки від спанів з типом SERVER – бо нам цікавий результат від самого FastAPI на Backend API.
Інакше в результати міг б попасти спан із дочірніх спанів з цього трейсу – запити від Backend API до, наприклад, DynamoDB або RDS (хоча фільтр з "span_attr:http.route":!"" має вибрати тільки HTTP).
Деплоїмо, перевіряємо метрику:
Алерт “Backend HTTP p95 latency is high”
Тут все аналогічно до алерту по помилкам 5хх.
Тільки в expression виконуємо конвертацію наносекунд в секунди – ділимо результат на 1e9 (мільярд) і тригеримо алерт, p95 latency вище 5 секунд:
{{- range .Values.alerts.traces.backend }}
{{- $ns := . }}
{{- range .severities }}
{{- if not (eq . "critical") }}
- alert: Kraken HTTP Latency p95 High
expr: vmtraces:kraken:http:request_duration:p95{"resource_attr:k8s.namespace.name"="{{ $ns.namespace }}",stats_result="value"} / 1e9 > 5
for: 5m
labels:
severity: {{ . }}
component: backend
environment: {{ $ns.env }}
ilert_routingkey: backend-{{ $ns.env }}-{{ . }}
annotations:
summary: "Kraken HTTP p95 latency is high"
description: |-
HTTP p95 latency has been above 5s for more than `{{ "{{" }} $for }}`
*Namespace*: `{{ "{{" }} index $labels "resource_attr:k8s.namespace.name" }}`
*Service name*: `{{ "{{" }} index $labels "resource_attr:service.name" }}`
*HTTP route*: `{{ "{{" }} index $labels "span_attr:http.route" }}`
*P95 latency*: `{{ "{{" }} printf "%.2f" $value }}` seconds
<https://{{ $.Values.monitoring.root_url }}/explore?orgId=1&left=%7B%22datasource%22:%22{{ $.Values.monitoring.victoria_traces_uid }}%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22jaeger%22,%22uid%22:%22{{ $.Values.monitoring.victoria_traces_uid }}%22%7D,%22queryType%22:%22search%22,%22service%22:%22{{ "{{" }} index $labels "resource_attr:service.name" }}%22,%22tags%22:%22http.route%3D{{ "{{" }} index $labels "span_attr:http.route" }}%22,%22limit%22:100%7D%5D,%22range%22:%7B%22from%22:%22now-1h%22,%22to%22:%22now%22%7D%7D|:grafana: VictoriaTraces>
{{- end }}
{{- end }}
{{- end }}
З HTTP поки все – йдемо далі.
Метрики AWS API
Для вибору спанів, пов’язаних з AWS можемо використати фільтр по атрибуту "span_attr:rpc.system".
Тут фільтр status_code:=2 вибираємо тільки помилки:
В атрибутах спану маємо і сам stacktrace, і status_message – але їх в лейбли не пишемо, це вже можна буде глянути в Grafana.
Деплоїмо, перевіряємо метрику:
Алерт “Backend is getting errors from AWS services”
Описуємо алерт – тут все аналогічно, тільки в Grafana link робимо фільтр по name, і його ж виводимо в тексті алерта в полі Operation – бо там є і AWS service name, і тип операції:
{{- range .Values.alerts.traces.backend }}
{{- $ns := . }}
{{- range .severities }}
{{- if not (eq . "critical") }}
- alert: Kraken AWS Client Errors
expr: vmtraces:kraken:aws:client_error:rate{"resource_attr:k8s.namespace.name"="{{ $ns.namespace }}",stats_result="requests_per_sec"} > 0
for: 1s
labels:
severity: {{ . }}
component: backend
environment: {{ $ns.env }}
ilert_routingkey: backend-{{ $ns.env }}-{{ . }}
annotations:
summary: "Kraken is getting errors from AWS services"
description: |-
AWS client error rate has been above 0 for more than `{{ "{{" }} $for }}`
*Namespace*: `{{ "{{" }} index $labels "resource_attr:k8s.namespace.name" }}`
*Service name*: `{{ "{{" }} index $labels "resource_attr:service.name" }}`
*Operation*: `{{ "{{" }} index $labels "name" }}`
*Error rate*: `{{ "{{" }} printf "%.3f" $value }}` req/s
<https://{{ $.Values.monitoring.root_url }}/explore?orgId=1&left=%7B%22datasource%22:%22{{ $.Values.monitoring.victoria_traces_uid }}%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22jaeger%22,%22uid%22:%22{{ $.Values.monitoring.victoria_traces_uid }}%22%7D,%22queryType%22:%22search%22,%22service%22:%22{{ "{{" }} index $labels "resource_attr:service.name" }}%22,%22operation%22:%22{{ "{{" }} index $labels "name" }}%22,%22tags%22:%22rpc.service%3D{{ "{{" }} index $labels "span_attr:rpc.service" }}%22,%22limit%22:100%7D%5D,%22range%22:%7B%22from%22:%22now-1h%22,%22to%22:%22now%22%7D%7D|:grafana: VictoriaTraces>
{{- end }}
{{- end }}
{{- end }}
Деплоїмо, чекаємо на алерт в Slack:
І маємо лінк на Grafana з фільтром по Operation name:
Метрики RDS
І останній приклад – із запитами до RDS.
Метрика “vmtraces:backend:db:query_duration:p95”
Описуємо Recording Rule, фільтруємо по "scope_name":="opentelemetry.instrumentation.sqlalchemy":
і є просто публічні DNS-зони, які мають резолвитись через 1.1.1.1
В resolv.conf це виглядає так:
nameserver 1.1.1.1 # CloudFlare DNS, returns EKS Endpoint Public IP
nameserver 10.100.0.1 # my MikroTik with WireGuard, returns EKS Endpoint Public IP
nameserver 10.0.0.2 # AWS VPC DNS via OpenVPN, returns EKS Endpoint Private IP EKS 10.0.64.9
Файл менеджиться з openresolv, який запускається WireGuard при старті тунелю – WireGuard прописує свої DNS:
В помилці з timeout бачимо, що запит до F07***D78.gr7.us-east-1.eks.amazonaws.com йде на IP 10.0.64.9 – тобто DNS resolution був через OpenVPN і AWS VPC DNS 10.0.0.2.
Linux DNS та systemd-resolved
Перевіряємо хто в системі взагалі відповідає за DNS – грепаємо файл /etc/nsswitch.conf:
Тут опція resolve – це використання модулю nss-resolve через D-Bus до systemd-resolved.
І він йде першим, до параметрів files (nss-files та /etc/hosts) та dns (модуль nss-dns і “класичний” glibc DNS resolver) – тому спочатку запити йдуть до systemd-resolved.
systemd-resolved, DNS resolution та network interfaces
Тепер цікава частина – як саме systemd-resolved виконує DNS resolution.
systemd-resolved використовує openresolv – дивимось його параметри:
$ resolvectl status
Global
Protocols: +LLMNR +mDNS -DNSOverTLS DNSSEC=no/unsupported
resolv.conf mode: foreign
Current DNS Server: 10.0.0.2
DNS Servers: 10.0.0.2 10.100.0.1 192.168.0.1
...
Далі перевіримо що відбувається в системі – включаємо debug log для openresolv:
...
May 20 12:06:39 setevoy-office systemd-resolved[698]: varlink-28-28: Received message: {"method":"io.systemd.Resolve.ResolveHostname","parameters":{"name":"F07***D78.gr7.us-east-1.eks.amazonaws.com","flags":0,"ifindex":0}}
...
May 20 12:06:39 setevoy-office systemd-resolved[698]: varlink-28-28: Sending message: {"parameters":{"addresses":[{"ifindex":6,"family":2,"address":[10,0,64,9]},{"ifindex":6,"family":2,"address":[10,0,65,205]}],"name":"F07***D78.gr7.us-east-1.eks.amazonaws.com","flags":1048577}}
Тут:
Received message: запит прийшов з параметром "ifindex":0 – “пофіг, де шукати“
Sending message: відповідь повернулась через "ifindex":6 – це tun0, openVPN і AWS VPC DNS
Перевіряємо інтерфейси:
$ ip -o link | awk -F': ' '{print $1, $2}'
1 lo
2 enp0s31f6
4 wlan0
5 enp0s13f0u3u4u4
6 tun0
...
"ifindex":6 – це інтерфейс tun0, робочий OpenVPN, і результат, отриманий від AWS VPC DNS – "address":[10,0,64,9], бо AWS VPC DNS буде повертати приватну адресу.
Відповідь вже з ifindex":4 – wlan0, і маємо публічний IP.
Чому – бо в тому ж логу бачимо:
...
Firing regular transaction 49587 ... IN A> scope dns on */*
Firing regular transaction 59798 ... IN A> scope dns on wlan0/*
...
Тут перший запис – це запит через global pool, на всі сервери в ньому:
$ resolvectl status
Global
Protocols: +LLMNR +mDNS -DNSOverTLS DNSSEC=no/unsupported
resolv.conf mode: foreign
Current DNS Server: 10.0.0.2
DNS Servers: 10.0.0.2 10.100.0.1 192.168.0.1
...
If lookups are routed to multiple interfaces, the first successful response is returned
І в цьому випадку це був wlan0:
...
Added positive ... cache entry ... on wlan0/INET/10.0.0.1
...
Раз запит через wlan0 – то і у відповідь від AWS DNS для EKS endpoint отримали публічний IP.
А при першій спробі це був tun0:
...
Added positive ... cache entry ... on tun0/INET/10.0.0.2
...
І у відповідь отримали приватний IP 10.0.64.9.
Отже:
systemd-resolved робить запити до всіх доступних DNS
повертає результат від того, хто перший відповів
якщо запит від wlan0, офісної мережі – отримуємо публічний IP, і підключення проходить
якщо запит від tun0, OpenVPN і AWS VPC DNS – отримуємо приватний IP, і підключення падає з таймаутом
Не забуваємо повернути лог info:
$ sudo resolvectl log-level info
Тепер переходимо до маршрутизації – чому саме підключення падає з timeout error?
VPN та Linux IP routes mess
Глянемо що в маршрутах на робочому ноуті:
$ route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
0.0.0.0 10.0.0.1 0.0.0.0 UG 600 0 0 wlan0
10.0.0.0 0.0.0.0 255.255.255.0 U 600 0 0 wlan0
10.0.0.2 172.16.0.1 255.255.255.255 UGH 0 0 0 tun0
10.0.6.162 172.16.0.1 255.255.255.255 UGH 0 0 0 tun0
10.0.32.0 172.16.0.1 255.255.240.0 UG 0 0 0 tun0
10.0.48.0 172.16.0.1 255.255.240.0 UG 0 0 0 tun0
10.0.66.0 172.16.0.1 255.255.255.0 UG 0 0 0 tun0
10.0.67.0 172.16.0.1 255.255.255.0 UG 0 0 0 tun0
10.100.0.0 0.0.0.0 255.255.255.0 U 0 0 0 wg0
...
Тут:
10.0.0.0 через wlan0: бо це офісна мережа і у нас тут MacMini до яких треба доступ, ну і доступ в інтернет
10.0.0.2, 10.0.32.0, 10.0.48.0 etc – через tun0: це AWS VPC Private Subnets – сюди роутятся запити через робочий OpenVPN для доступу до AWS RDS і інших приватних ресурсів
10.100.0.0 через wg0: це мережа мого WireGuard через MikroTik для доступу в мої домашні мережі
А тепер – де виникає проблема: коли EKS ендпоінт резолвиться через AWS VPC DNS 10.0.0.2 – отримуємо приватну адресу 10.0.64.9.
Але для неї нема окремого роуту через OpenVPN – і вона роутиться через 10.0.0.1, офісний роутер і публічний інтернет:
$ kk get pod
[...] Get \"https://F07***D78.gr7.us-east-1.eks.amazonaws.com/api?timeout=32s\": dial tcp 10.0.64.9:443: i/o timeout"
...
Перевіряємо сам маршрут – бачимо, що піде через via 10.0.0.1, офісний роутер, а не tun0 і OpenVPN:
$ ip route get 10.0.64.9
10.0.64.9 via 10.0.0.1 dev wlan0 src 10.0.0.133 uid 1000
cache
можна налаштувати split-DNS на локальному Unbound чи dnsmasq – і переключити всі DNS запити на нього
Варіант з 10.0.64.9 на OpenVPN – костиль.
Note: вже коли дописав весь пост – згадав, що Control Plane EKS живе у власних VPC Subnets, і я міг тупо додати їх до OpenVPN так само, як це роблено для RDS, але вже ок – вийшло все одно цікаво 🙂
Рішення зі split-DNS через systemd-resolved виглядає якось геморно.
А Unbound я вже крутив на FreeBSD для домашнього NAS (див. FreeBSD: Home NAS, part 4 – локальний DNS з Unbound), конфіг простий і зрозумілий, ще і викинути зі схеми systemd-resolved з його складностями – нормальний варіант.
Хоча dnsmasq як рішення для ноутбука може був би кращий – бо ще простіший конфіг, але мені Unbound дуже зайшов – тому зробив з ним.
Arch Linux та Unbound
Встановлюємо сам пакет:
$ sudo pacman -S unbound
Що треба зробити:
всі запити до compute.internal (AWS EC2 etc) – роутити через OpenVPN і AWS VPC DNS
всі запити до ops.example.com – теж, бо там у нас записи для AWS RDS типу db.prod.ops.example.com
всі запити до grafana.net.setevoy – роутити через MikroTik, бо це моя локальна зона для домашніх хостів
решту – відправляємо на 1.1.1.1 та 8.8.8.8
Пишемо файл /etc/unbound/unbound.conf, описуємо три forward-zone з власними DNS і одну з публічними DNS: