LiteLLM: AI Gateway для LLM – overview можливостей
0 (0)

4 Червня 2026

В попередніх постах по OpenTelemetry і VictoriaTraces (див. OpenTelemetry: OTel Collectors в Kubernetes та інтеграція з VictoriaMetrics stack  та VictoriaTraces: Tracing, Observability та OpenTelemetry) розбирали загальні концепції того, що таке observability та як працювати з трейсами.

Але взагалі на проекті ця тема з’явилась коли ми зрозуміли, що використання 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.

Всі пости цієї серії:

І далі буде ще – слідкуйте за апдейтами в Telegram @rtfmcoua або LinkedIn group RTFM! DevOps[at]UA.

LiteLLM – основні можливості

Сторінка проекту – litellm.ai, вся документація – docs.litellm.ai, GitHub проекту – BerriAI/litellm.

Отже, що таке таке 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
  • Budgets, Rate Limits: багато налаштувань для контролю використання провайдерів
  • 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 для нього.

Документація – Getting Started Tutorial.

Для Production setup ще варто додавати Redis – але про це будемо говорити в наступній частині. Див. Deployment Options та High Availability Setup (Resolve DB Deadlocks).

Для запуску в Kubernetes є Helm chart (beta) і неофіційний litellm-operator.

Config.yaml – налаштування LiteLLM

Перед запуском LiteLLM нам потрібен конфіг того, як він буде працювати – див. документацію Config.yaml, а всі параметри в All settings.

Створюємо файл litellm_config.yaml з мінімальними налаштуваннями:

model_list:
  - model_name: gpt-4o-mini
    litellm_params:
      model: openai/gpt-4o-mini
      api_key: os.environ/OPENAI_API_KEY

general_settings:
  master_key: os.environ/LITELLM_MASTER_KEY

litellm_settings:
  callbacks: 
  - prometheus

Тут:

  • model_list: список моделей, які будуть доступні клієнтам – див. LLM configs model_list
    • model_name: ім’я, яке отримуємо в запиті від клієнта (як ми його будемо вказувати в коді, наприклад client.chat.completions.create(model="gpt-4o-mini")
      • кожна модель в цьому списку – це окремий deployment в термінології LiteLLM – див. Quick Start та Proxy – Load Balancing
      • litellm_params: параметри провайдера для цієї моделі
  • general_settings: див. General Settings general_settings
    • master_key: головний ключ для аутентифікації і в Web UI для адміна
    • тут же можна передати і параметри для бази даних, але зараз зробимо через змінні оточення
  • litellm_settings: налаштування самого LiteLLM – моніторинг, логгінг, кешування, див. litellm_settings – Reference
    • callbacks: відкриваємо метрики (ендпоінт /metrics/), пізніше сюди додамо відправку трейсів, див. Prometheus metrics

Створюємо docker-compose.yml – описуємо запуск самого LiteLLM та PostgreSQL для нього:

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: litellm
      POSTGRES_PASSWORD: litellm
      POSTGRES_DB: litellm
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U litellm"]
      interval: 5s
      timeout: 5s
      retries: 5

  litellm:
    image: ghcr.io/berriai/litellm:main-latest
    ports:
      - "4000:4000"
    volumes:
      - ./litellm_config.yaml:/app/config.yaml
    env_file:
      - .env
    environment:
      DATABASE_URL: postgresql://litellm:litellm@postgres:5432/litellm
      LITELLM_LOG: DEBUG
    command: ["--config", "/app/config.yaml", "--port", "4000"]
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres_data:

Тут PostgreSQL з health check, який використовується інстансом LiteLLM, а через змінну оточення DATABASE_URL для LiteLLM передаємо connection string для підключення до бази даних.

Генеруємо ключ для $LITELLM_MASTER_KEY – в OpenAI форматі, з префіксом sk- (“secret key”):

$ echo "sk-$(openssl rand -hex 16)"
sk-db69761228204509fa80f934bff6e0f5

Задаємо в змінні оточення:

$ export LITELLM_MASTER_KEY=sk-db69761228204509fa80f934bff6e0f5

Створюємо файл .env з API ключем для OpenAI та самого LiteLLM:

$ echo "OPENAI_API_KEY=$OPENAI_API_KEY" > .env
$ echo "LITELLM_MASTER_KEY=$LITELLM_MASTER_KEY" >> .env

Запускаємо – перший запуск буде кілька хвилин, поки накатяться всі міграції в базу.

Чекаємо на повідомлення “Application startup complete” та “Uvicorn running on“:

$ docker compose up
...
litellm-1   | INFO:     Waiting for application startup.
litellm-1   | 
litellm-1   |    ██╗     ██╗████████╗███████╗██╗     ██╗     ███╗   ███╗
litellm-1   |    ██║     ██║╚══██╔══╝██╔════╝██║     ██║     ████╗ ████║
litellm-1   |    ██║     ██║   ██║   █████╗  ██║     ██║     ██╔████╔██║
litellm-1   |    ██║     ██║   ██║   ██╔══╝  ██║     ██║     ██║╚██╔╝██║
litellm-1   |    ███████╗██║   ██║   ███████╗███████╗███████╗██║ ╚═╝ ██║
litellm-1   |    ╚══════╝╚═╝   ╚═╝   ╚══════╝╚══════╝╚══════╝╚═╝     ╚═╝
litellm-1   | 
litellm-1   | query-engine ac9d7041ed77bcc8a8dbd2ab6616b39013829574
litellm-1   | INFO:     Application startup complete.
litellm-1   | INFO:     Uvicorn running on http://0.0.0.0:4000 (Press CTRL+C to quit)

Заходимо на http://0.0.0.0:4000 – тут посилання на Admin UI та документація по API самого LiteLLM (Swagger  Docs можна відключити з NO_DOCS=true, див. environment variables – Reference, але вцілому її цікаво глянути – бо можливостей в API дуже багато):

LiteLLM: AI Gateway для LLM - overview можливостей

Логінимось в адмінку – дефолтний логін “admin“, пароль – $LITELLM_MASTER_KEY, який створювали вище:

LiteLLM: AI Gateway для LLM - overview можливостей

І попадаємо в дуже приємний інтерфейс:

LiteLLM: AI Gateway для LLM - overview можливостей

Вже маємо метрики, але ендпоінт саме /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

Встановлюємо залежності:

$ python3 -m venv .venv
$ source .venv/bin/activate
$ pip install openai

Запускаємо скрипт:

$ ./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 – і вже маємо дані по запитам:

LiteLLM: AI Gateway для LLM - overview можливостей

Трейси дивимось в Logs:

LiteLLM: AI Gateway для LLM - overview можливостей

Та нові метрики:

 $ 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 можемо.

Документація – OpenTelemetry та OpenTelemetry – Tracing LLMs with any observability tool.

OpenTelemetry та VictoriaTraces

LiteLLM може писати дані з OpenTelemetry Protocol. Для VictoriaTraces використовуємо ендпоінт /insert/opentelemetry/v1/traces, див. Data ingestion.

Відкриваємо локальний порт до інстансу VictoriaTraces в Kubernetes, аби отримати доступ із Docker container з LiteLLM – додаємо --address=0.0.0.0:

$ kk port-forward svc/atlas-victoriametrics-vt-single-server 10428 --address=0.0.0.0

В docker-compose.yaml додаємо параметр extra_hosts:

litellm:
  image: ghcr.io/berriai/litellm:main-latest
  ...
  extra_hosts:
    - "host.docker.internal:host-gateway"

В .env для LiteLLM додаємо змінні для відправки трейсів:

OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://host.docker.internal:10428/insert/opentelemetry/v1/traces
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf

Можна задати через власні змінні LiteLLM – OTEL_EXPORTER та OTEL_ENDPOINT:

OTEL_ENDPOINT=http://host.docker.internal:10428/insert/opentelemetry/v1/traces
OTEL_EXPORTER=otlp_http

Трохи mess в документації (хоча взагалі документація дуже класна) – але обидва варіанти працюють. Див. також Exporter & resource.

В конфіг litellm_config.yaml до callbacks додаємо “otel” – включаємо відправку трейсів:

...
litellm_settings:
  callbacks: 
  - prometheus
  - otel

Замість або на додачу до “otel” можна вказати “langfuse” або “arize” для Phoenix (див. Arize Phoenix: сервіс моніторингу LLM – запуск в Kubernetes) – тоді трейси будуть відправлятись в кілька сервісів – тестив, працює, зручно, прикольно.

Перезапускаємо контейнери, в логах маємо побачити, що експортери активні:

...
 11:31:57 - LiteLLM Proxy:DEBUG: callback_utils.py:34 - initializing callbacks=['prometheus', 'otel'] on proxy
...
litellm-1 | self.OTEL_EXPORTER: otlp_http
litellm-1 | self.OTEL_ENDPOINT: http://host.docker.internal:10428/insert/opentelemetry/v1/traces
litellm-1 | self.OTEL_HEADERS: None
...

Перевіряємо, що змінні оточення з .env застосувались – при першому запуску трохи довелось подебажити:

$ docker compose exec litellm env | grep OTEL
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://host.docker.internal:10428/insert/opentelemetry/v1/traces
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf

Ще раз запускаємо наш скрипт клієнта:

$ ./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"}:

LiteLLM: AI Gateway для LLM - overview можливостей

Або відразу в Grafana з групуванням:

LiteLLM: AI Gateway для LLM - overview можливостей

LiteLLM Span Attributes

Атрибутів прям дуже багато – просто відразу маємо все, без всяких інструментаріїв в коді.

“Корневий” спан у нас буде “Received Proxy Server Request“, див. Span Hierarchy та Span name reference.

Див. також документацію самого OpenTelemetry Semantic conventions for generative client AI spans – імена спанів та атрибутів.

Всі атрибути разом простіше подивитись в самій VictoriaTraces:

LiteLLM: AI Gateway для LLM - overview можливостей

Також можна включити спани LiteLLM як дочірні до спанів клієнта, якщо там налаштований трейсінг – див. Context propagation (W3C traceparent).

Всі атрибути див. в Attributes Reference, тут коротко з основних, які можуть бути цікавими далі для моніторингу.

Costs – можна будувати графіки витрат по моделях, юзерах, командах:

  • gen_ai.cost.total_cost: скільки грошей витратили на обробку запиту
  • gen_ai.cost.input_cost / gen_ai.cost.output_cost: розбивка вартості по вхідним/вихідним токенам

Tokens Usage:

  • gen_ai.usage.input_tokens / output_tokens / total_tokens

Execution time – час обробки запиту:

  • duration: загальний час обробки
  • hidden_params => litellm_overhead_time_ms: скільки часу зайняла робота самого проксі

Model і провайдер:

  • gen_ai.request.model: яка модель використовувалась в запиті
  • gen_ai.response.model: яка модель реально відповіла (може відрізнятись, наприклад – якщо спрацював fallback)
  • gen_ai.system: провайдер

Дані по юзеру:

  • metadata.user_api_key_hash: який ключ використовувався
  • metadata.user_api_key_user_id / team_id: хто зробив запит – конкретний юзер чи група
  • metadata.requester_ip_address: адреса клієнта

Rate limits від провайдера (в hidden_params), див. Rate Limit Headers:

  • x_ratelimit_remaining_requests: скільки запитів ще залишилось до того, як провайдер почне повертати помилку 429 – Too Many Requests
  • x_ratelimit_remaining_tokens: те саме, але по токенам

Content (може містити конфіденційні дані, можна відключити, див. Redacting Messages, Response Content та Capturing Message Content):

  • gen_ai.input.messages: сам промпт
  • gen_ai.output.messages: відповідь моделі

Status запиту:

  • status_code: 1 – OK (2 = ERROR)
  • 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.

Є навіть підтримка Users Provisioning з SCIM (див Okta: інтеграція з Google Workspaces, частина 1 – Provisioning) – але і тут Premium.

Втім, можемо автоматизувати це з 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.

Ну і в моніторингу, як бачили вище, маємо атрибути з іменами груп та юзерів – тому потім можемо будувати графіки та алерти по ним.

Окрім звичайних юзерів можемо мати Service Accounts.

Бюджети та ліміти

Бюджети та ліміти на 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
  • user roles – user та user_read_only
  • Organization та Team Roles – org/team admin

Див. User Roles та Available Roles.

Ну і давайте глянемо як це все виглядає на практиці: створимо Team з бюджетом та лімітами на Requests per minute, потім в цю групу додамо юзера, юзеру створимо API Key – і використаємо його в нашому Demo App.

Створення Team

Переходимо в Teams > Create Team:

LiteLLM: AI Gateway для LLM - overview можливостей

Створюємо групу:

LiteLLM: AI Gateway для LLM - overview можливостей

Тут задаємо доступ до всіх моделей, вказуємо загальний бюджет групи в 100 долларів на день (Reset Budget: daily), і для перевірки задамо жорсткий ліміт в 1 запит на хвилину.

Бюджети і ліміти в Team задаються на двох “рівнях” – самої групи і всіх юзерів в ній, та окремо на кожного юзера (точніше – його ключів, як створені в цій групі – див. далі), тобто:

  • Max Budget (USD): це бюджет всіх разом, а Team Member Budget (USD) – на кожного юзера в групі
  • Requests per minute Limit (RPM): на всю Team, а Team Member RPM Limit – кожного юзера в групі

Бюджети створюються як окремі об’єкти, доступні в Budgets:

LiteLLM: AI Gateway для LLM - overview можливостей

LiteLLM: AI Gateway для LLM - overview можливостей

І тут ловив ще одну чи то багу, чи то фічу, що після зміни значень в Team Budget значення бюджету для юзера не змінились, поки не зробив це руками саме в Budgets.

Нижче в параметрах нової Team у Router Settings можна налаштувати власні параметри для Load Balancing та Fallbacks:

LiteLLM: AI Gateway для LLM - overview можливостей

Створення User у Web UI

Юзери в UI створюються через Invite, який відправляється на пошту – тому треба мати SMTP, але після створення Invite у нас буде показаний лінк, за яким можемо зареєструватись.

Клікаємо Invite User:

LiteLLM: AI Gateway для LLM - overview можливостей

Задаємо роль з правами на створення ключів, вибираємо створену вище групу, в Personal Key Creation можна обмежити доступ до моделей – і це, власне, єдине обмеження, яке ми тут можемо встановити для юзера:

LiteLLM: AI Gateway для LLM - overview можливостей

Ба більше: під час створення юзера в Team – йому не можна  відразу задати Team Role, і він буде створений з дефолтною роллю User – але це можна змінити потім.

Клікаємо на Invite User – отримуємо посилання, яке було відправлено на пошту:

LiteLLM: AI Gateway для LLM - overview можливостей

Відкриваємо його в Incognito, задаємо пароль нового юзера, і попадаємо в Web UI – але тут вже, звісно, набагато менше доступів:

LiteLLM: AI Gateway для LLM - overview можливостейTeam Permissions

Вже після інвайту можемо змінити роль юзера в цій групі – бо без Admin ролі він не зможе створювати ключі в групі, і навіть встановити йому власні Member Limits/Budget:

LiteLLM: AI Gateway для LLM - overview можливостей

Інший варіант дозволити створення ключів для групи –  задати через Member Permissions:

LiteLLM: AI Gateway для LLM - overview можливостей

Створення User API Key для Team у Web UI

Тепер під цим юзером створюємо ключ – вказуємо групу, але не задаємо RPM:

LiteLLM: AI Gateway для LLM - overview можливостей

 

Зберігаємо ключ:

LiteLLM: AI Gateway для LLM - overview можливостей

Задаємо нову змінну:

$ export LITELLM_USER_KEY=sk-1WezQOWNC55fyt_z6y7V7w

В коді Demo App міняємо назву змінної з LITELLM_MASTER_KEY на LITELLM_USER_KEY, і  можна додати max_retries – аби ловити Exception відразу, як LiteLLM поверне клієнту 429:

...

client = OpenAI(
    base_url="http://localhost:4000",
    api_key=os.getenv("LITELLM_USER_KEY"),
    max_retries=0,
)

...

Запускаємо скрипт два рази підряд – перший спрацював, а на другий раз ловимо 429 Rate limit exceeded – бо задавали RPM Limit в Team:

 $ ./demo-llm.py 
Hello! How can I assist you today?
...

$ ./demo-llm.py 
...
openai.RateLimitError: Error code: 429 - {'error': {'message': 'Rate limit exceeded for team: aa65abde-4a51-49ee-9271-d16b09fd2058. Limit type: requests. Current limit: 1, Remaining: 0. Limit resets at: 2026-06-04 09:13:21 UTC', 'type': 'None', 'param': 'None', 'code': '429'}}

Створення User API Key у Web UI без Team і без Limits

Тепер під тим жеж юзером створюємо ще один ключ – але вже без Team, і теж не задаємо ніяких лімітів:

LiteLLM: AI Gateway для LLM - overview можливостей

Оновлюємо змінну:

$ export LITELLM_USER_KEY=sk-K7Dvc7gSFye6RKsFBB2PyQ

Запускаємо скрипт – і спокійно спамимо 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

Див. LiteLLM API /user/new.

Створюємо юзера з rpm_limit:

$ curl -X POST http://localhost:4000/user/new \
  -H "Authorization: Bearer $LITELLM_MASTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user_email": "[email protected]",
    "rpm_limit": 1,
    "user_role": "internal_user"
  }'

В response отримуємо його ключ, там жеж бачимо, що team_id пусте – юзер не належить до групи, але має власний rpm_limit:

{..., "max_budget":null, "user_id":"7a9a6ced-ae6a-497e-a9ba-e35825139845", "team_id":null, ..., "rpm_limit":1, ..., "key":"sk-cazziUlsSKQEGTsTUGILRA", ... ,"user_email":"[email protected]","user_role":"internal_user", ... }

Задаємо цей ключ в змінну:

$ export LITELLM_USER_KEY=sk-cazziUlsSKQEGTsTUGILRA

Запускаємо скрипт два рази – і на другий знов отримуємо 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 – мабуть, ще раз окремо пройдусь по доступам і юзерам, бо тут треба розібратись додатково.

Loading

MikroTik: Users Management, права доступа та SSH
5 (2)

23 Травня 2026

Пора вже записати собі про MikroTik та Users Management – бо пост давно в чернетках лежить, ну і заодно і налаштувати аутентифікацію SSH з ключами.

Але пройдемось по основним концептам та налаштуванням Authentication, Authorization, Accounting в MikroTik – групи, політики та юзери.

Що є і що треба зробити:

  • роутер MikroTik RB4011
  • треба створити нового root user замість дефолтного admin
  • додати read-only user
  • налаштувати аутентифікацію SSH

Всі user permissions визначаються групою юзера та його properties – див. User Groups:

  • Groups: містить список Policies та описує дозволи юзерів цієї групи в системі
  • Policy: визначає який конкретний scope дозволів – підключення по SSH, редагування конфігурації тощо
  • User: і врешті-решт власні properties юзера визначають як він аутентифікується – логін-пароль, звідки доступний доступ, etc

Окрім локальної бази груп юзерів можна налаштувати інтеграцію з зовнішніми системами – FreeRADIUS, JumpCloud, див. RADIUS.

Але для домашнього роутера це точно оверкіл.

Попередні частини по налаштуванню MikroTik – MikroTik: перше знайомство та Getting Started та MikroTik: налаштування WireGuard та підключення Linux peers.

User Groups та Policies

В 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"

Перевіряємо юзерів тепер:

/user print detail  where name=gw-setevoy-ro

І пробуємо підключитись:

[setevoy@setevoy-work ~] $ ssh [email protected]
...
[gw-setevoy-ro@mikrotik-rb4011-gw] > 

Write permissions не маємо, відповідно якщо спробуємо щось змінити – отримаємо помилку “not enough permissions“:

/interface wireguard add name=wg-test
not enough permissions (9)

І в address вказували тільки одну, локальну – тому з VPN підключитись не вийде:

Аби додати ще мережу, з якої дозволяємо доступ – ще раз /user set, і, як і при group set, теж вказуємо повний список мереж – і стару, і нову:

/user set gw-setevoy-ro address=192.168.0.0/24,10.100.0.0/24

Тепер можемо підключитись – перевіряємо активні сесії з /user active print:

Policy “sensitive”

Приклад того, як працює sensitive – бо не дуже очевидна штука і не дуже розкрито в документації.

Для прикладу – під адміном створимо новий WireGuard інтерфейс:

/interface wireguard add name=wg-test

Юзер setevoy має full групу, а full група має sensitive policy – тому setevoy може побачити приватний ключ WireGuard з show-sensitive:

/interface wireguard print detail show-sensitive

Але для юзера gw-setevoy-ro, у якого група не має політики sensitive – значення буде сховане за “***”:

SSH та ключі

Ну і про чудове: раз у нас є SSH – то є і можливість використовувати ключі для аутентифікації.

Important та keep in mind: з дефолтними налаштуваннями після додавання ключу парольна аутентифікація для юзера, якому доданий ключ, більше недоступна:

Але можна задати password-authentication=yes, див. SSH Server.

На ноуті створюємо ключ:

[setevoy@setevoy-work ~] $ ssh-keygen -t ed25519 -C "gw-setevoy-ro@rb4011" -f ~/.ssh/gw-setevoy-ro

Отримуємо його pub key:

[setevoy@setevoy-work ~]  $ cat ~/.ssh/gw-setevoy-ro.pub 
ssh-ed25519 AAA***a2lM gw-setevoy-ro@rb4011

На MikroTik є два варіанти додати ключ для юзера – скопіювати туди сам файл gw-setevoy-ro.pub, або передати зміст при виклику /user ssh-keys import.

Приклад з копіюванням – передаємо з ноута на роутер, адресу задаємо з двокрапкою в кінці – “192.168.0.1:” – скопіювати в корінь системи.

З ноутбука робимо scp:

[setevoy@setevoy-work ~]  $ scp .ssh/gw-setevoy-ro.pub 192.168.0.1:

На MikroTik перевіряємо файл:

/file print where name=gw-setevoy-ro.pub

Додаємо до юзера з /user ssh-keys import та public-key-file:

/user ssh-keys import user=gw-setevoy-ro public-key-file=gw-setevoy-ro.pub

Перевіряємо ключі юзера:

/user ssh-keys print  where user=gw-setevoy-ro

Тепер можемо підключатись з ноутбука без пароля:

[setevoy@setevoy-work ~] $ ssh -i .ssh/gw-setevoy-ro [email protected]

Другий варіант – передати ключ через аргумент key:

/user ssh-keys add user=setevoy key="ssh-ed25519 AAA***SWoE gw-setevoy-root@rb4011"

Додаємо на ноутбуці використання ключа в ~/.ssh/config (в мене ключі в 1Password, тому його агент):

Host gw.net.setevoy 192.168.0.1
  IdentityAgent ~/.1password/agent.sock
  IdentityFile ~/.ssh/gw-setevoy-root.pub
  IdentitiesOnly yes

Готово.

Loading

VictoriaTraces: Recording Rules, метрики та алерти з trace spans
0 (0)

22 Травня 2026

VictoriaTraces – як і VictoriaLogs – підтримує Recording Rules (див. VictoriaMetrics: Recording Rules для логів AWS Load Balancer) для трейсів, бо по факту трейси – це ті ж самі логи, просто інакше структуровані.

А раз є 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".

Так зроблено через cardinality issue – аби не створювати багато різних значень, бо це вплине на сторейдж і ресурси (див. VictoriaMetrics: Churn Rate, High cardinality, метрики та IndexDB).

Відповідно, коли приходить алерт, ми бачимо тільки дуже загальну інформацію – по самому ендпоінту, а не конкретного юзера.

Ну і головне, чому я поліз копати в цю тему – це те, що алерти ніяк не прив’язані до трейсів.

Зараз, якщо приходить алерт типу:

VictoriaTraces: Recording Rules, метрики та алерти з trace spans

Все, що ми можемо зробити – це піти в Grafana dashboard для Kubernetes Pods і WorkerNodes, і там дивитись навантаження CPU/RAM. Якщо там все ок – то йти в дашборду по RDS, і розбиратись там.

А маючи метрики з трейсів – я можу прямо в алерті створити лінк на всі пов’язані трейси, і тоді відразу в Grafana та VictoriaTraces побачити де проблема.

AWS RDS query duration

Інший приклад – метрика з логів AWS RDS:

- record: vmlogs:aws:rds:cloudwatch_logs:explain:query_duration:sum:avg:5m
  expr: |
    logtype:="rds" "plan:"
      | extract_regexp `.*:(?P<connection>.*_kraken_user@.*kraken_db:\[\d+\])`
      | extract_regexp ".*duration: (?P<duration>.+) ms"
      | duration:~".+"
      | extract_regexp `.*Query Text: (?P<query>.+?)(?:\s+AS\s|\s+FROM\s)`
      | query:~".+"
      | stats by (environment, connection, query) avg(duration) avg_duration

Тут аналогічний підхід: AWS RDS пише в лог AUTO EXPLAIN (див. PostgreSQL: використання EXPLAIN та налаштування “auto_explain” в AWS RDS), а ми парсимо логи в VictoriaLogs та генеруємо метрику.

Загалом дані в лейблах більш цікаві, ніж в прикладі з 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": "4464509750",
    ...
    "kind": "2",
    "name": "GET /morpheus/sleep-agent/status",
    ...
    "resource_attr:k8s.namespace.name": "prod-backend-api-ns",
    "resource_attr:k8s.node.name": "ip-10-0-42-22.ec2.internal",
    "resource_attr:k8s.pod.name": "backend-api-deployment-6966566f55-khb9v",
    "resource_attr:service.name": "kraken-prod",
    ...
    "span_attr:http.method": "GET",
    "span_attr:http.route": "/morpheus/sleep-agent/status",
    "span_attr:http.scheme": "http",
    "span_attr:http.status_code": "503",
    "span_attr:http.target": "/morpheus/sleep-agent/status",
    ...
    ...
    "status_code": "2",
    "status_message": "http 503",
    "trace_id": "6a0c64013a090e5462414f3e3fba1630"
  },

Що з цього нам може бути цікаве і корисне:

  • 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

Тепер, маючи список атрибутів – можемо подумати над метрикою.

Метрика “vmtraces:backend:http:request_5xx:rate”

Створюємо новий VMRule з type: vlogs:

apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
  name: recording-rules-vmalert-traces
  labels:
    app: vmalert-traces
spec:
  groups:
    - name: Traces.VictoriaTraces.Logs.rules
      type: vlogs
      interval: 5m

      rules:

        - record: vmtraces:kraken:http:request_5xx:rate
          expr: |
            {resource_attr:service.name=~"kraken-.*"} "span_attr:http.route":!"" "span_attr:http.status_code":~"5.."
            | stats by ("resource_attr:k8s.namespace.name", "resource_attr:service.name", "span_attr:http.route", "span_attr:http.status_code") rate() requests_per_sec

Тут вибираємо всі трейси від 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 є:

VictoriaTraces: Recording Rules, метрики та алерти з trace spans

Алерт “Backend HTTP 5xx Errors”

Аби не писати окремі алерти на Dev/Staging/Prod – використаємо Helm range.

Додаємо в values.yaml чарту:

alerts:
  traces:
    backend:
      - env: dev
        namespace: dev-backend-api-ns
        severities: [warning]
      - env: staging
        namespace: staging-backend-api-ns
        severities: [warning]
      - env: prod
        namespace: prod-backend-api-ns
        severities: [warning, critical]

Описуємо новий 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 – можна буде оновити алерти.

Btw, можете лайкнути issue на GitHub – OpenTelemetry: support field names transformations 😉

Тому тут робимо з index $labels, а імена лейбл вказуємо в лапках:

index $labels "resource_attr:k8s.namespace.name"

Значення в $labels – це тип map(map[string]string), тому index проходиться по вкладеним ключам і отримує потрібну лейблу.

Grafana link до VictoriaTraces

Останнім в алерті створюється прямий лінк на Grafana – щоб ми з алерту могли відразу відкрити всі деталі.

Як писав вище – простіше тут було робити пошук по trace_id – але його ми не можемо писати в лейбли, бо знов-таки – cardinality issue.

Тому я зробив через фільтр по тегам, які підставляються з отриманих атрибутів в $labels – в данному випадку по span_attr:http.route:

VictoriaTraces: Recording Rules, метрики та алерти з trace spans

А сам алерт в Slack виглядає так:

VictoriaTraces: Recording Rules, метрики та алерти з trace spans

Метрика “vmtraces:backend:http:request_duration:p95”

Тут, в принципі, все аналогічно – тільки рахується не 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).

Деплоїмо, перевіряємо метрику:

VictoriaTraces: Recording Rules, метрики та алерти з trace spans

Алерт “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".

Перевіряємо що у нас є цікавого:

{resource_attr:service.name=~"kraken-.*"} "span_attr:rpc.system":"aws-api"

VictoriaTraces: Recording Rules, метрики та алерти з trace spans

Тут вже як раз бачимо kind=3 – наш бекенд виступає в ролі CLIENT до AWS API.

Метрика “vmtraces:backend:aws:client_error:rate”

З цікавих метрик, які тут можемо зробити – алертити, коли виникають помилки при запитах до AWS.

Описуємо новий Recording Rule:

- record: vmtraces:kraken:aws:client_error:rate
  expr: |
    {resource_attr:service.name=~"kraken-.*"} "span_attr:rpc.system":"aws-api" status_code:=2
    | stats by ("resource_attr:k8s.namespace.name", "resource_attr:service.name", "span_attr:rpc.service", name) rate() requests_per_sec

Тут фільтр status_code:=2 вибираємо тільки помилки:

VictoriaTraces: Recording Rules, метрики та алерти з trace spans

В атрибутах спану маємо і сам stacktrace, і status_message – але їх в лейбли не пишемо, це вже можна буде глянути в Grafana.

Деплоїмо, перевіряємо метрику:

VictoriaTraces: Recording Rules, метрики та алерти з trace spans

Алерт “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:

VictoriaTraces: Recording Rules, метрики та алерти з trace spans

І маємо лінк на Grafana з фільтром по Operation name:

VictoriaTraces: Recording Rules, метрики та алерти з trace spans

Метрики RDS

І останній приклад – із запитами до RDS.

Метрика “vmtraces:backend:db:query_duration:p95”

Описуємо Recording Rule, фільтруємо по "scope_name":="opentelemetry.instrumentation.sqlalchemy":

# DB query p95 latency
- record: vmtraces:kraken:db:query_duration:p95
  expr: |
    {resource_attr:service.name=~"kraken-.*"} "scope_name":="opentelemetry.instrumentation.sqlalchemy"
    | stats by ("resource_attr:k8s.namespace.name", "resource_attr:service.name", "span_attr:db.name", name) quantile(0.95, duration) p95_duration

Як і для HTTP – рахуємо 95 персентиль.

Алерт “Kraken DB Query p95 Duration High”

Аналогічно до HTTP алерта – конвертуємо value в секунди:

{{- range .Values.alerts.traces.backend }}
{{- $ns := . }}
{{- range .severities }}
{{- if not (eq . "critical") }}
- alert: "Kraken DB Query p95 Duration High"
  expr: vmtraces:kraken:db:query_duration:p95{"resource_attr:k8s.namespace.name"="{{ $ns.namespace }}",stats_result="p95_duration"} / 1e9 > 1
  for: 1m
  labels:
    severity: {{ . }}
    component: backend
    environment: {{ $ns.env }}
    ilert_routingkey: backend-{{ $ns.env }}-{{ . }}
  annotations:
    summary: "Kraken DB query p95 duration is high"
    description: |-
      DB query p95 duration has been above 1s for more than `{{ "{{" }} $for }}`
      *Namespace*: `{{ "{{" }} index $labels "resource_attr:k8s.namespace.name" }}`
      *Service name*: `{{ "{{" }} index $labels "resource_attr:service.name" }}`
      *DB name*: `{{ "{{" }} index $labels "span_attr:db.name" }}`
      *Query*: `{{ "{{" }} index $labels "name" }}`
      *P95 duration*: `{{ "{{" }} printf "%.2f" $value }}` seconds
      <https://{{ $.Values.monitoring.root_url }}/explore?schemaVersion=1&panes=%7B%22dlh%22:%7B%22datasource%22:%22dfl962zwff6yoa%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22jaeger%22,%22uid%22:%22dfl962zwff6yoa%22%7D,%22queryType%22:%22search%22,%22service%22:%22{{ "{{" }} index $labels "resource_attr:service.name" }}%22,%22limit%22:100,%22tags%22:%22%22,%22operation%22:%22{{ "{{" }} index $labels "name" }}%22%7D%5D,%22range%22:%7B%22from%22:%22now-1h%22,%22to%22:%22now%22%7D,%22compact%22:false%7D%7D&orgId=1|:grafana: VictoriaTraces>
{{- end }}
{{- end }}
{{- end }}

В Query теж робимо аналогічно до алерта AWS – виводимо значення лейбли name, бо там є частина запиту.

В результаті в Slack отримуємо такий алерт:

VictoriaTraces: Recording Rules, метрики та алерти з trace spans

І лінк в Grafana з фільтром по Operation name – “INSERT staging_kraken_db“:

VictoriaTraces: Recording Rules, метрики та алерти з trace spans

Власне, на цьому все.

Вийшло набагато краще, ніж старі алерти з логів.

А коли ще додамо OTel Collectors – буде ще краще.

Loading

Arch Linux: DNS-детектив – VPN, systemd-resolved та Unbound
0 (0)

21 Травня 2026

Давно гемороївся  з проблемою доступу до AWS EKS із офісу – нарешті психанув, і розібрався 🙂

В чому проблема: є AWS EKS кластер з Public і Private endpoint для API.

При роботі з офісного ноутбука іноді запити до нього проходять нормально – а іноді відвалюються з помилкою “i/o timeout“:

$ 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"
...

Поїхали копати – бо тут нюанси і з DNS, і з роутами в мережі.

AWS VPC DNS та мої VPN

У моєму випадку для EKS увімкнено і Public і Private endpoints – тому при DNS resolution працює split-horizon DNS:

  • на запит з “публічного інтернету” AWS VPC DNS поверне Public IP
  • на запит з середини VPC – то буде Private IP

Далі: я маю два активних VPN-підключення + офісний WiFi, а проблема починається, коли я додаю AWS VPC DNS до resolv.conf, бо:

  • є проектний Pritunl/OpenVPN і є домени в AWS, які мають резолвитись через 10.0.0.2 – AWS VPC DNS
  • є мій власний WireGuard і домени які мають резолвитись з мого домашнього MikroTik через 10.100.0.1 (див. MikroTik: налаштування WireGuard та підключення Linux peers)
  • і є просто публічні 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:

$ sudo cat /etc/wireguard/wg0.conf
...
DNS = 10.100.0.1, 10.0.0.2, 192.168.0.1
...

В помилці з 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:

$ grep hosts /etc/nsswitch.conf
hosts: mymachines resolve [!UNAVAIL=return] files myhostname dns

Тут опція resolve – це використання модулю nss-resolve через D-Bus до systemd-resolved.

І він йде першим, до параметрів files (nss-files та /etc/hosts) та dns (модуль nss-dns і “класичний” glibc DNS resolver) – тому спочатку запити йдуть до systemd-resolved.

Див. Domain name resolution на Arch Wiki.

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:

$ sudo resolvectl log-level debug

Потім в одному вікні відкриваємо логи:

$ sudo journalctl -u systemd-resolved -f | grep "F07***D78.gr7.us-east-1.eks.amazonaws.com"

Робимо kubectl get pod – і в логах бачимо:

...
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 буде повертати приватну адресу.

Повторюємо запит – і тепер результат буде інший:

...
varlink-28-28: Sending message: {"parameters":{"addresses":[{"ifindex":4,"family":2,"address":[44,216,7,46]},{"ifindex":4,"family":2,"address":[3,***,***,161]}],"name":"F07***D78.gr7.us-east-1.eks.amazonaws.com","flags":8388609}}
...

Відповідь вже з ifindex":4wlan0, і маємо публічний 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
...

А результат повернеться від того, хто перший відповідає, див. systemd-resolved.service:

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 

Ну і traceroute (btw, див. Unix: что такое traceroute – писав ще у 2016,але досі актуально):

$ traceroute 10.0.64.9
traceroute to 10.0.64.9 (10.0.64.9), 30 hops max, 60 byte packets
 1  office.example.dev (10.0.0.1)  14.261 ms  14.614 ms  14.592 ms
 2  * * *
 3  * * *
...

І звісно, що запит на адресу в приватному сабнеті відправлений через публічний інтернет тупо падає.

Варіанти вирішення

Є кілька опцій – або просто додати 10.0.64.9 до OpenVPN, або налаштувати split-DNS – і правильно резолвити домени:

  • просто додати 10.0.64.9 до OpenVPN – тоді він створить роут через tun0 на додачу до вже існуючих
  • можна налаштувати split-DNS через systemd-resolved
  • можна налаштувати 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:

server:
    interface: 127.0.0.1
    access-control: 127.0.0.0/8 allow
    do-ip6: no
    hide-identity: yes
    hide-version: yes
    prefetch: yes

# local homelab via MikroTik
forward-zone:
    name: "setevoy."
    forward-addr: 10.100.0.1
    forward-addr: 192.168.0.1

forward-zone:
    name: "compute.internal."
    forward-addr: 10.0.0.2

forward-zone:
    name: "ops.example.co."
    forward-addr: 10.0.0.2

# everything else
forward-zone:
    name: "."
    forward-addr: 1.1.1.1
    forward-addr: 8.8.8.8

Перевіряємо синтаксис:

$ sudo unbound-checkconf
unbound-checkconf: no errors in /etc/unbound/unbound.conf

Відключення systemd-resolved

В пості Arch Linux: WireGuard Peer для підключення до MikroTik описував рішення іншої проблеми, і там для NetworkManager додавав dns=systemd-resolved.

Якщо є – прибираємо в /etc/NetworkManager/NetworkManager.conf, задаємо просто dns=none:

...

[main]
dns=none

Відключаємо systemd-resolved (тут інтернет відвалиться – бо нема куди слати DNS):

$ sudo systemctl disable --now systemd-resolved systemd-resolved-monitor.socket systemd-resolved-varlink.socket

Перезапускаємо NetworkManager:

$ sudo systemctl restart NetworkManager

Перевіряємо 53 порт – якщо systemd-resolve ще живий, значить щось тригерить його запуск:

$ sudo ss -tulpn | grep ':53'
...
tcp   LISTEN 0      4096   127.0.0.53%lo:53         0.0.0.0:*    users:(("systemd-resolve",pid=723720,fd=25))
tcp   LISTEN 0      4096      127.0.0.54:53         0.0.0.0:*    users:(("systemd-resolve",pid=723720,fd=27))

Можна жорстко заблокувати запуск через systemctl mask:

$ sudo systemctl mask systemd-resolved
$ sudo systemctl stop systemd-resolved

Ще раз перевіряємо порти, і якщо на порту 53 вже нема нікого – то запускаємо unbound.service:

$ sudo systemctl stop systemd-resolved
$ sudo systemctl enable --now unbound
Created symlink '/etc/systemd/system/multi-user.target.wants/unbound.service' → '/usr/lib/systemd/system/unbound.service'.

Перевіряємо порт:

$ sudo ss -tulpn | grep ':53\b'
udp   UNCONN 0      0          127.0.0.1:53         0.0.0.0:*    users:(("unbound",pid=727532,fd=3))
tcp   LISTEN 0      256        127.0.0.1:53         0.0.0.0:*    users:(("unbound",pid=727532,fd=4))

Редагуємо /etc/resolv.conf – задаємо всі DNS через нього:

nameserver 127.0.0.1

І пробуємо щось публічне:

$ dig google.com +short
216.58.207.14

Потім ендпоінт EKS – має повернути публічні IP:

$ dig F07***D78.gr7.us-east-1.eks.amazonaws.com +short
3.***.***.161
44.***.***.46

Пробуємо RDS – має повернути приватні IP із пулу VPC:

$ dig prod.db.kraken.ops.example.com +short
kraken-ops-rds-prod.***.us-east-1.rds.amazonaws.com.
10.0.66.14

Редагуємо WireGuard /etc/wireguard/wg0.conf – міняємо параметр DNS:

[Interface]
...
DNS = 127.0.0.1

...

Виконуємо sudo resolvconf -u, бо робили зміни в /etc/resolv.conf вручну і WireGuard буде сваритись.

Перезапускаємо WireGuard:

$ sudo wg-quick down wg0 && sudo wg-quick up wg0

Перевіряємо файл:

$ cat /etc/resolv.conf
# Generated by resolvconf
nameserver 127.0.0.1

І тепер все працює, як треба.

Loading

VictoriaTraces: Tracing, Observability та OpenTelemetry
5 (2)

19 Травня 2026

На проекті потроху доросли до того, що пора вже мати повноцінний трейсінг – аби побудувати реальний observability, а не просто моніторинг.

Колись давно робив аналогічне з Jaeger – монстр, і десь він так в чорнетках 2019 чи 2020 року і залишився, ну а так як зараз у нас весь стек VictoriaMetrics – сама VictoriaMetrics для метрик та VictoriaLogs для логів – то і для трейсів будемо використовувати рішення від команди VictoriaMetrics – VictoriaTraces.

Тим більш VictoriaTraces набагато легша як в ресурсах – так і в налаштуванні. Мабуть, можна порівняти Loki та VictoriaLogs та Jaeger з VictoriaTraces – аналогічно набагато простіший сетап, набагато менше ресурсів CPU/RAM.

Цей пост планувався першим в серії по traces – тому тут спочатку буде більше теорії по Observability та OpenTelemetry, а далі вже запустимо VictoriaTraces в Kubernetes.

В попередньому пості OpenTelemetry: OTel Collectors в Kubernetes та інтеграція з VictoriaMetrics stack описаний чисто OpenTelemetry stack для метрик і логів, і в цьому пості буду на нього посилатись.

І вже в наступній, третій – подивимось як створювати трейси з Python.

Monitoring vs Observability

“Monitoring is a passive action. Observability is an active practice” – з чудової книги Learning OpenTelemetry, Setting Up and Operating a Modern Observability System.

Перше, про що хочеться написати окремо – це, власне, розібрати різницю між “monitoring” та “observability“.

Їх часто плутають або використовують як взаємозамінні – але хоча вони і дотичні, це все ж про різні концепти.

Отже, Monitoring – це коли ми заздалегідь знаємо, що може зламатись та налаштовуємо перевірки саме на це: “CPU більше 90% => алерт. Диск зайнятий більш ніж на 85% => алерт. Помилки 5xx на ALB => алерт.”

Тобто, ми відповідаємо на питання, які сформулювали наперед. По суті – це dashboard-driven підхід: дивимось на відомі метрики, реагуємо на відомі проблеми.

Observability – це коли система дозволяє відповідати на питання, які ми заздалегідь не формулювали: відбувається щось “дивне”, і ми можемо “розкопати” причину – навіть якщо ніколи раніше з таким не стикалися.

Ключове слово тут – explorability: можливість дослідити зв’язки і причини проблем.

Як приклад – виріс Backend API latency. Моніторинг просто скаже – “латенсі високий” (алерт спрацював), а observability дозволить виконати drill down – пройти весь ланцюжок і знайти root cause: latency спайк на конкретному endpoint? конкретний tenant? конкретний Kubernetes Pod? Може один upstream повільний? Тобто, ми йдемо від симптомудо причини через дані, які вже є в системі.

Власне, тому і кажуть про “three pillars of observability” – Metrics, Logs, Traces. Саме traces (distributed tracing) – це те, що зазвичай відрізняє “просто моніторинг” від observability на практиці, бо саме трейси дають можливість дослідити невідому проблему – бачити шлях запиту через сервіси та знаходити bottleneck, якого ми не передбачували.

Втім, observability – це не про якісь “магічні динамічні алерти”: ми все ще залишаємо в системі звичайні pre-defined алерти типу “якщо 5хх більше 1% – слати повідомлення в Slack”.

Але змінюється те, що відбувається після того, як алерт спрацював: ми не просто бачимо “цей домен повертає помилки” і йдемо вручну грепати логи в VictoriaLogs – а маємо можливість пройти повний шлях: від алерта в Slack – через AWS ALB – через Kubernetes Pod – до компонента в цьому Pod, і врешті-решт до конкретного method() в коді, який повертає помилку та до юзера, на запити від якого цей метод генерує помилки.

Тобто алертинг – це все ще “моніторинг” частина: observability починається в момент, коли алерт спрацював і тобі треба зрозуміти чому.

Observability  – це не про виявлення проблем, а про їх дослідження.

What is: Tracing

Tracing (або distributed tracing) – це спосіб відстежити шлях одного запиту через всю систему: від моменту, коли він прийшов на ALB – через Kubernetes Pod – до бази даних, зовнішнього API або LLM-виклику, і назад.

Далі будемо говорити про VictoriaTraces, яка побудована на VictoriaLogs – бо, власне, концепт трейсінгу той самий, що і у логів: сервіс записує кожен свій “чіх” – кожен виклик, кожну дію, кожен запит до зовнішніх систем. Різниця з “просто логгінгом” в тому, що у traces є ID, який об’єднує всі пов’язані виклики в дерево, що дозволяє будувати повний шлях запиту.

Один такий шлях називається trace. Trace складається зі spans, де кожен span це одна операція – конкретний HTTP-запит, SQL-запит, виклик іншого сервісу, обробка в черзі. Span-и зв’язані між собою в дерево через trace_id (спільний для всього trace) та parent_id (хто викликав цей span).

Виглядає це приблизно так:

trace_id: abc123

[HTTP GET /api/orders]                                        # root span (120ms)
  ├─ [auth-service: validate token]                           # child span (8ms)
  ├─ [orders-service: get orders]                             # child span (95ms)
  │    ├─ [PostgreSQL: SELECT * FROM orders WHERE user_id=42] # (80ms)
  │    └─ [Redis: GET cache:user:42]                          # (2ms)
  └─ [response serialization]                                 # child span (12ms)

Кожен span цього трейсу має поле з trace_id, кожен span має поля parent_id та span_id: у root span поле parent_id буде пусте, у другого span буде parent_id == span_id першого span цього трейсу і так далі.

Кожен span окрім часу виконання може містити атрибути – key-value пари з додатковим контекстом: частина атрибутів додається автоматично (HTTP method, status code, DB statement), частина – вручну розробниками (tenant_id, row_count, cache_hit). Чим більше контексту в атрибутах – тим більше можна дослідити не додаючи нових метрик чи логів.

Наприклад (це вже з наступного посту, там розберемо детальніше):

...
orders-api  |     "attributes": {
orders-api  |         "http.scheme": "http",
orders-api  |         "http.host": "172.25.0.3:8000",
orders-api  |         "net.host.port": 8000,
orders-api  |         "http.flavor": "1.1",
orders-api  |         "http.target": "/api/orders/by-customer/Vasya",
orders-api  |         "http.url": "http://172.25.0.3:8000/api/orders/by-customer/Vasya",
orders-api  |         "http.method": "GET",
orders-api  |         "http.server_name": "localhost:8000",
orders-api  |         "http.user_agent": "curl/8.20.0",
orders-api  |         "net.peer.ip": "172.25.0.1",
orders-api  |         "net.peer.port": 54900,
orders-api  |         "http.route": "/api/orders/by-customer/{name}",
orders-api  |         "customer.name": "Vasya",
orders-api  |         "customer.orders_count": 3,
orders-api  |         "http.status_code": 200
orders-api  |     },
...

Фактично, атрибути в traces – це labels в метриках Prometheus-формату, які ми потім можемо використовувати для пошуку трейсів і – головне – для кореляції пов’язаних метрик, логів та трейсів.

Приклад дебагу з tracing

Повернемось до прикладу, який описувався вище: маємо алерт в Slack, який каже, що Backend API latency на ендпоінт /coach виріс до 20 секунд:

VictoriaTraces: Tracing, Observability та OpenTelemetry

В алерті є лінк на Grafana dashboard зі статусом AWS Application Load Balancer, в дашборді є посилання на VictoriaLogs з логами ALB та Backend API, лінк на дашборду з Kubernetes Pods нашого Backend API та його AWS RDS.

Метрики в Grafana dashboard показують спайк, логи – нічого підозрілого. Без трейсів далі починається гадання – йдемо дивитись CPU/RAM на Kubernetes WorkerNodes, навантаження на пов’язані Pods, Grafana dashboard для AWS RDS з PostgreSQL, намагаємось побудувати картину – де у нас виникає проблема.

З трейсами ми відкриваємо повільні traces для цього endpoint і одразу бачимо: із загальних 120ms на обробку всього реквесту – 80ms йде на виконання одного SQL-запиту. Дивимось атрибути цього span-у – db.statement: SELECT * FROM orders WHERE user_id=42, індекс не використовується: root cause знайдено за хвилину.

What is: the OpenTelemetry

OpenTelemetry (OTel) – це перш за все набір загальних “правил” по тому як дані мають збиратись і які метадані в них мають бути присутні.

Вище згадувались “three pillars of observability” –  Metrics, Logs, Traces: кожна дія сервісу та його компоненту – це events, або Signals в термінології OTel.

OpenTelemetry та його OpenTelemetry Protocol (див. OTLP Specification 1.10.0) описують яким чином дані повинні передаватись (HTTP/gRPC), та які поля і заголовки вони повинні мати, таким чином уніфікуючи метрики, логи та трейси в єдиний формат.

З OTel ми збираємо ці сигнали на рівні коду, з Kubernetes Pods/Nodes або з AWS API, обробляємо, додаючи атрибути та об’єднуючи їх в загальний контекст, і передаємо до бекенду, в якому ці дані зберігаються – метрики до VictoriaMetrics, логи до VictoriaLogs, трейси до VictoriaTraces.

OpenTelemetry vs Prometheus

Коли ми працюємо VictoriaMetrics або Prometheus – у нас є звичний підхід до метрик: exporter виставляє endpoint /metrics, VictoriaMetrics з VMAgent ходить на цей ендпоінт та збирає метрики (PULL-модель). Формат метрик – простий текстовий типу metric_name{label="value"} 123.45.

В OpenTelemetry (OTel) інший підхід, бо зазвичай він працює за PUSH-моделлю: сервіс сам відправляє дані в OTel Collector, а той вже роутить їх куди треба – у VictoriaMetrics, VictoriaLogs, VictoriaTraces чи будь-який інший бекенд.

Втім, OTel Collector receivers можуть і самі виконувати запити до якихось API, наприклад – k8s_cluster робить запити до Kubernetes API /apis/apps/v1/deployments для отримання додаткової інформації по Kubernetes Pods.

Навіщо OpenTelemetry, якщо є Prometheus

Для метрик Prometheus формат і справді працює чудово, але Prometheus – це тільки метрики: він не вміє в трейси, не вміє в structured logs, і головне – не вміє зв’язати метрику, лог і трейс між собою: у нас вже є VictoriaLogs для логів, VictoriaMetrics для метрик – але все це окремі системи зі своїми форматами, тому зв’язати конкретну метрику з конкретним логом і конкретним трейсом – складно, бо в них немає спільного контексту.

OTel вирішує саме цю проблему: коли метрики, логи і трейси проходять через один SDK, то вони автоматично отримують спільний контекст – trace_id, service.name, deployment.environment, kubernetes.pod.name. В результаті ми з алерту по metric_name можемо перейти до трейсу конкретного запиту, а з трейсу – до логів конкретного span-у. Без OTel ці три системи живуть окремо, і зв’язувати їх доводиться вручну.

Компоненти OpenTelemetry

OpenTelemetry складається з трьох основних частин:

  • OTel SDK: вбудовується в код і генерує телеметрію
    • для auto-instrumentation це кілька рядків при старті сервісу – і ми одразу отримуємо span-и для HTTP, gRPC, SQL (див. Instrumentation нижче)
  • OTel Collector: окремий сервіс (DaemonSet або Deployment в Kubernetes), який приймає дані від SDK в сервісах, обробляє і відправляє далі до бекендів
    • той жеж Collector в ролі агента може сам збирати метрики і логи з Kubernetes чи AWS – теж описано в попередньому пості
  • OTLP (OpenTelemetry Protocol): це формат і протокол передачі даних, який працює поверх gRPC або HTTP і підтримується практично всіма сучасними бекендами – VictoriaMetrics, Grafana Tempo, Jaeger, Datadog

OpenTelemetry Instrumentation

Сам термін instrumentation в контексті OpenTelemetry і трейсингу – це процес додавання специфічного коду до сервісу або системи, який дозволяє виконувати observability цього коду.

Див. Instrumentation та Zero-code Instrumentation.

З OpenTelemetry є три шляхи виконати instrumentation:

  • zero-code instrumentation: ми взагалі нічого не міняємо в коді – виклик нашого сервісу відбувається через зовнішній wrapper, який перехоплює виклики нашого коду і сам додає потрібні дані
    • швидко, зручно – але найменш гнучко, бо не дає можливості самому вирішувати що і де додавати
  • auto instrumentation: OTel SDK вміє автоматично створювати span-и для HTTP-запитів, DB-клієнтів, gRPC-викликів, додавати необхідні атрибути
    • для auto-instrumentation використовуємо OTel SDK шляхом додавання бібліотек в наш код, який через власні методи і функції додає інформацію до викликів методів і функцій нашого коду
  • manual instrumentation: можемо додавати власні custom spans та атрибути в коді для бізнес-логіки, яку auto-instrumentation не бачить
    • наприклад, створювати span на обробку одного елементу в batch job, або атрибут order.total_items в SQL-виклику на span обробки замовлення

Як правило, починають з auto-instrumentation (щоб одразу отримати базову картину), а потім додають manual instrumentation поступово – там, де не вистачає контексту для дебагу конкретних проблем.

What is: VictoriaTraces

Документація – VictoriaTraces та Key concepts.

Репозиторій проекту – VictoriaTraces.

VictoriaTraces побудована на VictoriaLogs: вона отримує дані від OTel Collector у вигляді JSON в форматі OTLP та записує їх у власному форматі, трансформуючи імена полів.

Проект поки в статусі This project is currently a work in progress, тому зміни можливі – але вже цілком робочий.

Як і VictoriaLogs, VictoriaTraces формує stream fields, які використовують для оптимізації збереження даних та пошуку логів чи трейсів.

В результаті кожний записаний trace span зберігається як частина конкретного stream – аналогічно до того, як кожен log record у VictoriaLogs – це частина якогось одного конкретного log stream.

У VictoriaTraces для поля stream використовується атрибут service.name, і кожне унікальне значення в stream field впливає на те, скільки даних буде сформоване в VictoriaTraces storage та IndexDB, яка використовується для пошуку даних коли ми робимо sum by (label_name).

Див. VictoriaMetrics: Churn Rate, High cardinality, метрики та IndexDB – бо суть зберігання даних в VictoriaMetrics, VictoriaLogs та VictoriaTraces однакова.

Як і VictoriaMetrics та VictoriaLogs, у VictoriaTraces є власний VM UI, в якому з LogsQL можемо виконувати пошук трейсів:

VictoriaTraces: Tracing, Observability та OpenTelemetry

Хоча для відображення дерева трейсів краще використовувати Grafana – далі зробимо.

Також див. документацію VictoriaTraces по Monitoring – можемо збирати метрики та Retention – трейси, як і логи та метрики, теж зберігаються на диску, тому треба мати на увазі зайняте місце.

І дуже смачна можливість – створювати власні метрики з traces, зробимо в цьому пості далі.

Запуск VictoriaTraces в Kubernetes

Як і у VictoriaLogs, VictoriaTraces є single instance та cluster mode для High Availability – але в моєму випадку single isntance вистачає з головою, тому поки будемо використовувати його.

Для запуску VictoriaTraces в Kubernetes є окремі Helm-чарти – victoria-traces-single та victoria-traces-cluster.

Документація по чарту – VictoriaTraces Single.

Додаємо репозиторій:

$ helm repo add vm https://victoriametrics.github.io/helm-charts/
$ helm repo update

Знаходимо останню доступну версію чарту:

$ helm search repo vm/victoria-traces-single
NAME                            CHART VERSION   APP VERSION     DESCRIPTION                                       
vm/victoria-traces-single       0.0.7           v0.8.0          The VictoriaTraces single Helm chart deploys Vi...

Пишемо values.yaml, дефолтні значення є в репозиторії чарту, наприклад:

victoria-traces-single:
  enabled: true
  server:
    mode: deployment
    ingress:
      enabled: true
      ingressClassName: alb
      annotations:
        alb.ingress.kubernetes.io/group.name: ops-1-33-internal-alb
        alb.ingress.kubernetes.io/target-type: ip
        alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:492***148:certificate/ad0ae28d-1843-412d-b3e1-05235186ea11
      hosts:
        - name: vmtraces.monitoring.1-33.ops.example.co
          path:
            - /
          port: http
    resources:
      requests:
        cpu: 100m
        memory: 256Mi
    persistentVolume:
      enabled: true
      storageClassName: gp3-retain
      size: 50Gi
    retentionPeriod: 30d
    vmServiceScrape:
      enabled: true

Я тут задав тип деплою з Deployment замість StatefulSet та додав Ingress через AWS ALB.

В persistentVolume створюємо диск, в retentionPeriod міняємо дефолтне значення 7 днів на місяць.

Деплоїмо, перевіряємо:

$ kk get deploy atlas-victoriametrics-vt-single-server
NAME                                     READY   UP-TO-DATE   AVAILABLE   AGE
atlas-victoriametrics-vt-single-server   1/1     1            1           44h

Перевіряємо Kubernetes Service:

$ kk get svc atlas-victoriametrics-vt-single-server
NAME                                     TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)     AGE
atlas-victoriametrics-vt-single-server   ClusterIP   None         <none>        10428/TCP   2d20h

VictoriaTraces приймає OTLP/HTTP на endpoint /insert/opentelemetry/v1/traces.

Можемо з curl пушнути трейс для тесту – відкриваємо порт:

$ kk port-forward svc/atlas-victoriametrics-vt-single-server 10428

Відправляємо JSON з полями, які потім буде створювати наш OTel SDK:

$ curl -v -X POST "http://localhost:10428/insert/opentelemetry/v1/traces" -H "Content-Type: application/json" -d "{\"resourceSpans\":[{\"resource\":{\"attributes\":[{\"key\":\"service.name\",\"value\":{\"stringValue\":\"test-curl\"}}]},\"scopeSpans\":[{\"scope\":{\"name\":\"manual-test\"},\"spans\":[{\"traceId\":\"aaaaaaaaaaaaaaaabbbbbbbbbbbbbbbb\",\"spanId\":\"cccccccccccccccc\",\"name\":\"test-span\",\"kind\":2,\"startTimeUnixNano\":\"$(date +%s)000000000\",\"endTimeUnixNano\":\"$(date +%s)000000000\",\"attributes\":[{\"key\":\"http.method\",\"value\":{\"stringValue\":\"GET\"}},{\"key\":\"http.route\",\"value\":{\"stringValue\":\"/api/test\"}}],\"status\":{\"code\":1}}]}]}]}"

Перевіряємо у VM UI – http://localhost:10428/select/vmui/:

VictoriaTraces: Tracing, Observability та OpenTelemetry

Формат запитів – стандартний LogsQL:

{name="test-span"} trace_id:"aaaaaaaaaaaaaaaabbbbbbbbbbbbbbbb"

І, власне, VictoriaTraces готова до роботи. Залишилось додати instrumentation в наш код – детальніше в наступній частині, тут просто для прикладу як воно взагалі може виглядати – тут auto instrumentation для FastAPI та asyncpg:

import os
from fastapi import FastAPI, HTTPException
from contextlib import asynccontextmanager
import asyncpg

from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.asyncpg import AsyncPGInstrumentor

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor, BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter


pool = None


@asynccontextmanager
async def lifespan(app: FastAPI):
    global pool
    pool = await asyncpg.create_pool(
        host=os.getenv("DB_HOST", "postgres-test"),
        port=int(os.getenv("DB_PORT", "5432")),
        user=os.getenv("DB_USER", "postgres"),
        password=os.getenv("DB_PASSWORD", "testpass"),
        database=os.getenv("DB_NAME", "demo"),
        min_size=2,
        max_size=10,
    )
    yield
    await pool.close()


# Set up OTel tracer provider
provider = TracerProvider()

# Console exporter (for local debugging)
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
# OTLP exporter to VictoriaTraces
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))

trace.set_tracer_provider(provider)

app = FastAPI(title="Orders API", lifespan=lifespan)

FastAPIInstrumentor.instrument_app(app)
AsyncPGInstrumentor().instrument()

@app.get("/healthz")
async def healthz():
    return {"status": "ok"}


@app.get("/api/orders")
async def list_orders():
    rows = await pool.fetch("SELECT * FROM orders ORDER BY id")
    return [dict(r) for r in rows]


@app.get("/api/orders/{order_id}")
async def get_order(order_id: int):
    row = await pool.fetchrow("SELECT * FROM orders WHERE id = $1", order_id)
    if not row:
        raise HTTPException(status_code=404, detail="Order not found")
    return dict(row)


@app.get("/api/orders/by-customer/{name}")
async def orders_by_customer(name: str):
    # catch currently processing span
    current_span = trace.get_current_span()
    # add attribute to the current span: customer name
    current_span.set_attribute("customer.name", name)
    # fetch orders from the database
    rows = await pool.fetch(
        "SELECT * FROM orders WHERE customer_name = $1 ORDER BY id", name
    )
    # add attribute to the current span: number of orders fetched
    current_span.set_attribute("customer.orders_count", len(rows))
    return [dict(r) for r in rows]

VMAlert та Recording Rules з VictoriaTraces

Цікава фішка – можемо мати Recording Rules, в яких буде використовуватись VictoriaTraces – див. Alerting with traces.

Логіка та сама, як і для Recording Rules і метрик з логів у VictoriaLogs: описуємо правило з type="vlogs"vmalert генерить метрику, а потім цю метрику можемо використати в алертах чи Grafana.

Єдиний нюанс тут – якщо вже є інстанс vmalert для логів, то треба робити другий інстанс – бо в Recording Rules тип однаковий (vlogs), і для самого VMAlert треба вказувати інший datasource.url.

Додавання VMAlert

vmalert можна встановити з Helm chart victoria-metrics-alert, або, як в моєму випадку при використанні victoria-metrics-k8s-stack і VictoriaMetrics Kubernetes Operator – то створити другий інстанс через kind=VMAlert.

Документація по самому vmalertтут>>>.

Приклад з kind: VMAlert:

apiVersion: operator.victoriametrics.com/v1beta1
kind: VMAlert
metadata:
  name: vmalert-traces
spec:
  datasource:
    url: http://atlas-victoriametrics-vt-single-server:10428
  remoteWrite:
    url: http://vmsingle-vm-k8s-stack:8428
  notifiers:
    - url: http://vmalertmanager-vm-k8s-stack.ops-monitoring-ns.svc.cluster.local:9093
  ruleSelector: 
    matchLabels:
      app: vmalert-traces

Тут:

  • datasource.url: задаємо ендпоінт VictoriaTraces – vmalert буде ходити до нього, аби отримати трейси
  • remoteWrite: куди писати згенеровані метрики

remoteRead тут optional – бо цей інстанс тільки генерить метрики.

А от notifiers обов’язковий – хоча алертів він генерити не буде.

В ruleSelector задаємо які саме VMRules використовувати – інакше в ConfigMap цього інстансу VMAlert будуть додані все VMRules.

Додавання VMRule

Спершу в самій VictoriaTraces перевіряємо якийсь запит, наприклад:

{resource_attr:service.name="kraken-prod"} "span_attr:http.route":!""
| stats by ("resource_attr:service.name", "span_attr:http.route", "span_attr:http.status_code") quantile(0.95, duration) p95_duration

Тут отримуємо всі spans з resource_attr:service.name="kraken-prod", вибираємо тільки ті, у яких присутній span_attr:http.route і рахуємо 95 percentile по полю duration:

VictoriaTraces: Tracing, Observability та OpenTelemetry

 

Описуємо сам VMRule, в labels  задаємо app="vmalert-traces" – по ньому ruleSelector буде вибирати тільки цей VMRule:

apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
  name: recording-rules-vmalert-traces
  labels:
    app: vmalert-traces
spec:
  groups:
    - name: Traces.VictoriaTraces.Logs.rules
      type: vlogs
      interval: 5m

      rules:

        # Target Status: metrics on events from Target Status logs
        - record: vmtraces:kraken:http:request_duration:p95
          expr: |
            {resource_attr:service.name=~"kraken-.*"} "span_attr:http.route":!""
            | stats by ("resource_attr:service.name", "span_attr:http.route", "span_attr:http.status_code") quantile(0.95, duration) p95_duration

Деплоїмо і перевіряємо об’єкти vmalert:

$ kk get vmalert 
NAME             STATUS        REPLICACOUNT   AGE
vm-k8s-stack     operational   1              343d
vmalert-traces   operational                  42m

Тут vm-k8s-stack – це дефолтний vmalert з чарту victoria-metrics-k8s-stack – він займається алертами і має Recording Rules для логів (потім, мабуть, винесу окремими інстансами – один для алертів, один для Recording Rules з логів, один для VictoriaTraces).

І, відповідно, маємо новий Kubernetes Pod:

$ kk get pod | grep vmalert 
vmalert-vm-k8s-stack-f6cdd77d9-mcnks                              2/2     Running     0               3d23h
vmalert-vmalert-traces-b8f77656c-jqzbp                            2/2     Running     0               4m29s

Для якого створена власна ConfigMap:

$ kk get pod vmalert-vmalert-traces-b8f77656c-jqzbp -o yaml | yq '.spec.volumes'
...
  {
    "configMap": {
      "defaultMode": 420,
      "name": "vm-vmalert-traces-rulefiles-0"
    },
    "name": "vm-vmalert-traces-rulefiles-0"
  },
...

В якій містяться правила з VMRule recording-rules-vmalert-traces:

$ kk describe cm vm-vmalert-traces-rulefiles-0 
Name:         vm-vmalert-traces-rulefiles-0
...
Data
====
ops-monitoring-ns-recording-rules-vmalert-traces.yaml:
----
groups:
- name: Traces.VictoriaTraces.Logs.rules
  interval: 5m
  rules:
  - record: vmtraces:kraken:http:request_duration:p95
    expr: |
      {resource_attr:service.name=~"kraken-.*"} "span_attr:http.route":!""
      | stats by ("resource_attr:service.name", "span_attr:http.route", "span_attr:http.status_code") quantile(0.95, duration) p95_duration

Деплоїмо і за хвилину маємо нові метрики в VictoriaMetrics:

VictoriaTraces: Tracing, Observability та OpenTelemetry

 

VictoriaTraces та Grafana

Аби зручно працювати в трейсами – додамо Grafana data source.

Для VictoriaTraces поки використовується дефолтний Jaeger, пізніше, думаю, створять власний плагін – для VictoriaMetrics спочатку теж використовувався звичайний Prometheus plugin, для VictoriaLogs був Loki plugin, потім команда вже додала свої.

Сервіс ми вже знаходили:

$ kk get svc | grep vt
atlas-victoriametrics-vt-single-server                   ClusterIP   None             <none>        10428/TCP                    24h

Перевіряємо Jaeger плагін:

VictoriaTraces: Tracing, Observability та OpenTelemetry

Додаємо новий data source:

VictoriaTraces: Tracing, Observability та OpenTelemetry

Задаємо URL як http://atlas-victoriametrics-vt-single-server:10428/select/jaeger:

VictoriaTraces: Tracing, Observability та OpenTelemetry

Можна  включити Trace to Logs and Metrics – тоді в Grafana Explore по trace_id будуть генеруватись лінки на повязані метрики та логи:

VictoriaTraces: Tracing, Observability та OpenTelemetry

Зберігаємо, перевіряємо:

VictoriaTraces: Tracing, Observability та OpenTelemetry

Переходимо в Explore і шукаємо по Trace ID:

VictoriaTraces: Tracing, Observability та OpenTelemetry

VictoriaLogs, Derived Fields та VictoriaTraces

VictoriaLogs plugin підтримує створення derived fields: можна прямо з логів VictoriaLogs створювати лінк на VictoriaTraces.

Налаштовується в Grafana Connections > VictoriaLogs: вказуємо ім’я, вибираємо тип – в моєму випадку логи в JSON, тому через “Regex in log line”:

"trace_id":\s*"([a-f0-9]{32})"

В поле URL використовуємо ${__value.raw} – сюди буде підставлено значення trace_id.

В Example log message можна перевірити чи вірно працює regex:

VictoriaTraces: Tracing, Observability та OpenTelemetry

Після чого відкриваємо лог з полем trace_id – справа з’явиться блок “Links”:

VictoriaTraces: Tracing, Observability та OpenTelemetry

Який відкриє посилання на цей трейс:

VictoriaTraces: Tracing, Observability та OpenTelemetry

Власне, на цьому сьогодні все.

В наступному пості вже подивимось як використовувати OpenTelemetry instrumentation і з Python app створювати трейси.

Loading

OpenTelemetry: OTel Collectors в Kubernetes та інтеграція з VictoriaMetrics stack
5 (3)

16 Травня 2026

Сьогодні поговоримо про те, як запустити OpenTelemetry в Kubernetes та інтегрувати його з VictoriaMetrics stack – VictoriaMetrics для метрик, VictoriaLogs для логів, та VictoriaTraces для трейсів.

Взагалі-то цей пост не планувався взагалі, а коли все ж з’явилась його чорнетка – то він мав бути третім в серії, але врешті-решт вирішив зробити його першим.

Вже після нього допишу два інших поста – перший про Observability та tracing з VictoriaTraces, другий – по OpenTelemetry instrumentation в Python та запису трейсів до VictoriaTraces, а потім – про LLM Observability та моніторингу.

Власне, саме так OpenTelemetry на моєму поточному проекті і з’явився – захотіли ми подивитись детальніше по тому, що у нас по роботі з різними LLM-провайдерами, а там все “заточено” під OpenTelemetry, бо формат Prometheus metrics для цього не підходить.

Тому першим ділом додав VictoriaTraces та запис трейсів в з нашого Backend API, потім подивився на це всі діло, подумав, що мені замало контексту – і вирішив додати повний OpenTelemetry стек.

Власне, з контексту і почнемо.

Див. другу частину – VictoriaTraces: Tracing, Observability та OpenTelemetry.

OpenTelemetry, Observability та Context

Головна суть observability – в контексті, бо context – це, surprise, не тільки про AI/LLM, а і про моніторинг та Observability.

Про Monitoring vs Observability будемо говорити в наступному пості (який мав бути першим), а сьогодні подивимось як запустити OpenTelemetry в Kubernetes.

Але, якщо коротко, то Observability будується на “three pillars of observability” – Metrics, Logs, Traces.

Але просто мати метрики, логи та трейси мало – бо всі наші three pillars повинні мати якісь загальні ознаки, загальні дані, які дозволять робити “наскрізний observability” – тобто, мати можливість в єдиному контексті дослідити і метрики EC2, і метрики AWS Application Load Balancer, і конкретні Kubernetes Pods самого Backend API та, врешті решт – до конкретних викликів функцій, бізнес-логіки, яка виконується в цьому Pod у відповідь на реквест, який прийшов з AWS ALB від конкретного юзеру – тобто, побудувати observability pipeline.

А для того, аби всі дані мали цей загальний контекст – вони повинні мати якісь загальні риси, ознаки, за якими ми можемо всі отримані – тобто labels.

Проте при використанні “дефолтного” Prometheus-стеку ми маємо купу різних експортерів для метрик, окремі експортери для логів, ще і на додачу трейси в OTel-форматі – і кожен пише лейбли на свій лад. Тому, аби якось це все об’єднати в Grafana dashboards або алертах доводиться гемороїтись зі всякими label_replace.

Живий приклад з одного з мої алертів:

- record: aws:node:cpu_utilization:percent
  expr: |
    100 * (1 - avg by(instance, cluster) (
      label_replace(
        rate(node_cpu_seconds_total{mode="idle"}[5m]),
        "instance",
        "ip-${1}-${2}-${3}-${4}.ec2.internal",
        "instance",
        "(.*)\\.(.*)\\.(.*)\\.(.*):9100"
      )
    ))

Тут з метрики node_cpu_seconds_total береться значення лейбли instance типу 10.0.50.18 і створюється нове значення виду ip-10-0-50-18.ec2.internal, яке потім використовується в Grafana dashboards для фільтрів – бо якась інша метрика віддає ім’я хоста в такому вигляді, а метрика від node_exporter не має дефолтної лейбли у вигляді node_name="ip-10-0-50-18.ec2.internal".

Тому можна піти іншим шляхом – замінити те, як ми ці метрики отримуємо: замість того, щоб мати 10 різних експортерів для метрик – node_exporter для EC2, YACE exporter для AWS CloudWatch, окремого експортеру k8s-event-logger для експорту Kubernetes Events в логи, замість окремого AWS ALB Logs collector із S3 – ми можемо мати єдину систему, яка все це робить сама і, головне – сама додає загальний загальні лейбли до всіх signals – metrics, logs, traces.

Плюси та мінуси OpenTelemetry

Звісно, не все так просто: OpenTelemetry Collector трохи складніший в налаштуванні, споживає більше ресурсів, потребує додаткового моніторингу.

Власне, це цілком очікувано, бо якщо система “з коробки” дає більше можливостей – то і її конфігурація буде трохи складнішою, ніж для якогось одного Prometheus Node Exporter.

Те саме і по ресурсам – коли експортер займається збором і метрик і логів – то він буде споживати більше ресурсів, ніж один експортер, який “заточений” тільки під одну задачу: вже те, що OTel має захист від OOMKiller “з коробки” про щось говорить.

Втім, якщо порахувати споживання CPU/RAM всіма експортерами Prometheus-формату і порівняти з одним Kubernetes Pod для OpenTelemetry Collector – то ще питання, що буде легше.

Крім того – формат OTel для метрик за розміром більший, ніж Prometheus-метрики – бо сам формат в собі містить більше даних.

І останній нюанс, який зараз приходить в голову – це те, що 95% всяких алертів та Grafana dahsboards заточені саме під метрики в Prometheus-форматі та від Prometheus-експортерів на кшталт node_exporter та cAdvisor.

Тому якщо впроваджувати OTel в якості основної системи для збору даних – то треба мати на увазі, що треба буде оновлювати і всі пов’язані ресурси.

Втім, конкретно в моєму випадку – ми ще маленький стартап, а основні Grafana dashboards я все одно роблю сам руками, тому з LLM задачка це все оновити вирішується відносно швидко.

Отже, спробую, запущу поки що паралельно  з існуючим Prometheus-like стеком експортерів та логів і подивлюсь, що з цього вийде.

VictoriaTraces і трейси у нас теж вже є, але про це будемо говорити окремо.

VictoriaMetrics та мій поточний стек моніторингу

У нас на проекті все працює в AWS на Elastic Kubernetes Service – Backend API та інші сервіси проекту, сам VictoriaMetrics стек моніторингу, плюс різні сервіси самого AWS – RDS, CloudFront, DynamoDB etc.

Що залишиться без змін – це наші “storages”: VictoriaMetrics для метрик, VictoriaLogs – для логів, VictoriaTraces – для трейсів.

Що зміниться – це те, як  ми ці дані отримуємо: замість пачки Prometheus exporters та VMAgent, який до них ходить і збирає метрики – у нас буде окремий сервіс OTel Gateway, який отримує дані від OTel Collector. А OTel Collector замінить весь зоопарк Prometheus Exporters та Log collectors.

Окремо від цієї інфраструктури є багато інтеграцій з AI-провайдерами – Anthropic, OpenAI – але їхній моніторинг це вже зовсім окрема тема, про які буду (сподіваюсь) писати далі.

OpenTelemetry – загальна архітектура та компоненти

Для збору даних – метрик, логів та трейсів – у OpenTelemetry є власний OpenTelemetry Collector, який може відігравати різні ролі.

Власне, це один і той самий binary-файл, поведінка якого залежить від того, що ми йому передаємо в налаштуваннях:

  • роль Kubernetes Collector: збираємо Kubernetes events, метрики Kubernetes WorkerNodes, Kubernetes Pods, контейнерів, логи
  • роль AWS Collector: збирає метрики з CloudWatch та/або логи з AWS ALB через S3 та/або VPC Flow Log
  • роль OpenTelemetry Gateway: агенти (OTel Collectors) пушать свої дані до Gateway, а Gateway вже передає їх до конкретних бекендів – VictoriaMetrics, VictoriaLogs, VictoriaTraces

Схематично це може виглядати якось так:

OpenTelemetry: OTel Collectors в Kubernetes та інтеграція з VictoriaMetrics stack

Єдиний момент перед тим, як продовжити: я називаю OTel Collectors і як “collector“, і як “agent“, але назва суті не міняє – це просто роль, яку сервіс виконує.

Структура конфігурації OpenTelemetry Collector

В інтернеті багато прикладів файлів, наприклад в офіційному репозиторії k8s/otel-config.yaml, або невелика колекція Cloud-Architect-Emma/opentelemetry-collector-examples.

Але аби ними користуватись або писати власні – треба трохи глянути за загальний синтаксис та компоненти, які в конфігурації описані.

Документація:

В кожному компоненті ми будемо задавати власні параметри – але структура у всіх однакова:

  • receivers: описують звідки отримувати дані – Kubernetes API, AWS API, логи
    • для Kubernetes Collector тут будуть hostmetrics (метрики як від node_exporter), kubeletstats (метрики контейнерів), filelog (логи Pods)
    • у Gateway у receivers буде otlp – приймати дані від Collectors, та k8s_cluster і k8sobjects – він сам буде збирати дані від Kubernetes API та kubelet
  • processors: трансформації даних – додає метадані (атрибути, лейбли), фільтрує або видаляє зайве, групує, виконує трансформації – перейменування полів, нормалізація
  • exporters: куди дані відправляємо
    • у Gateway у exporters будуть otlphttp/vmetrics, otlphttp/vlogs, otlphttp/vtraces.
    • в Agent у exporters буде otlp_grpc (з адресою Gateway)
  • extensions: додаткові capabilities (аутентифікація, health check, encoding extensions тощо)
  • connectors: об’єднує різні pipelines
  • service: об’єднує і активує описані конфіги – recievers, processors, etc

OpenTelemetry Pipelines

Всі отримані сигнали проходять через pipeline: тобто receiver – отримав сигнал, processor його обробив, exporter – кудись відправив.

Для кожного типу сигналів – метрик, логів та трейсів – у нас будуть власні пайплайни – бо дані хоч і пов’язані, але оброблюються по різному.

Кожен пайплайн може мати власний ідентифікатор – просто ім’я, аби простіше було читати конфіг, наприклад:

connectors:
  spanmetrics:
    # config...

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [otlphttp/vtraces, spanmetrics]  # spanmetrics here is an exporter
    
    metrics/from_traces:
      receivers: [spanmetrics]                    # the same spanmetrics here is an receiver
      exporters: [otlphttp/vmetrics]

Тепер можемо починати писати власні конфіги та запускати колектори.

OpenTelemetry: запуск в Kubernetes

Є кілька варіантів запусту стека – “голими” контейнерами, Helm chart, або OpenTelemetry Operator, див. Install the Collector.

Для VictoriaMetrics я користуюсь Helm chart victoria-metrics-k8s-stack, який встановлює VictoriaMetrics Operator, VMAgent, VMAlert, Alertmanager, Grafana, а всі налаштування виконуються з VictoriaMetrics CRD resources.

Про цей сетап писав у VictoriaMetrics: створення Kubernetes monitoring stack з власним Helm-чартом, а про Kubernetes Operators та CRD – у Kubernetes: що таке Kubernetes Operator та CustomResourceDefinition.

Для OpenTelemetry поки зроблю просто з Helm chart – бо простіше буде розібратись з основними компонентами і не витрачати час на документацію оператора та його CRD.

А вже коли це все діло піде у production – можна буде замінити на OpenTelemetry Operator.

Робити будемо у вигляді трьох окремих компонентів:

  • OTel Gateway: отримує дані від Kubernetes API, Kubernetes та AWS Collectors, оброблює їх, передає до бекендів – VictoriaMetrics, VictoriaLogs, VictoriaTraces
  • Kubernetes Agent: запускається на кожній Kubernetes WorkerNode, збирає дані від kubelet та логи Pods
  • AWS Agent: збирає дані від AWS – метрики, логи

Почнемо саме з OTel Gateway, бо всі інші компоненти будуть слати дані саме через нього, саме він буде виконувати всі операції, і саме він буде відправляти дані до VictoriaMetrics stack.

Додаємо собі Helm-репозиторій:

$ helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts
$ helm repo update

Перевіряємо наявність чартів:

$ helm search repo open-telemetry/opentelemetry-collector
NAME                                    CHART VERSION   APP VERSION     DESCRIPTION                                      
open-telemetry/opentelemetry-collector  0.155.0         0.151.0         OpenTelemetry Collector Helm chart for Kubernetes

Всі компоненти – OTel Gateway, Kubernetes Agent, AWS Agent – будуть встановлюватись з нього, але кожний з власними values.

Запуск OpenTelemetry Gateway

Готуємо файл otel-gateway-values.yaml – це будуть values для нашого OTel Gateway:

# OTel Collector - Gateway role (Deployment)
#
# Responsibilities at this phase:
#   - Accept OTLP from future Agents (DaemonSet)
#   - Collect cluster-level metrics via k8s_cluster receiver
#   - Collect K8s events as logs via k8sobjects receiver
#   - Enrich all signals with K8s metadata (k8sattributes processor)
#   - Export metrics to VictoriaMetrics, logs to VictoriaLogs
#
# Traces pipeline is intentionally not enabled yet - that's Phase 2

# docs: https://opentelemetry.io/docs/collector/architecture/

mode: deployment

replicaCount: 2

# contrib image has all the receivers/processors/exporters we need
image:
  repository: otel/opentelemetry-collector-contrib

resources:
  limits:
    cpu: 1000m
    memory: 2Gi
  requests:
    cpu: 200m
    memory: 512Mi

# RBAC for k8sattributes (pod metadata lookup) and k8s_cluster (cluster state).
# Full list of required permissions:
# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/k8sclusterreceiver
clusterRole:
  create: true
  rules:
    - apiGroups: [""]
      resources:
        - pods
        - namespaces
        - nodes
        - nodes/stats
        - nodes/proxy
        - events
        - services
        - resourcequotas
        - replicationcontrollers
        - replicationcontrollers/status
      verbs: ["get", "list", "watch"]
    - apiGroups: ["apps"]
      resources: ["replicasets", "deployments", "statefulsets", "daemonsets"]
      verbs: ["get", "list", "watch"]
    - apiGroups: ["extensions"]
      resources: ["replicasets"]
      verbs: ["get", "list", "watch"]
    - apiGroups: ["batch"]
      resources: ["jobs", "cronjobs"]
      verbs: ["get", "list", "watch"]
    - apiGroups: ["autoscaling"]
      resources: ["horizontalpodautoscalers"]
      verbs: ["get", "list", "watch"]
    - apiGroups: ["events.k8s.io"]
      resources: ["events"]
      verbs: ["get", "list", "watch"]

# Self-monitoring port
ports:
  metrics:
    enabled: true
    containerPort: 8888
    servicePort: 8888
    protocol: TCP

service:
  type: ClusterIP

config:
  receivers:
    # PUSH receiver
    # Accepts data from Agents and from apps
    # OTel TracerProvider() for the Backend API will send traces to this receiver
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317
          # Agent batches of logs may exceed default 4 MiB gRPC limit
          max_recv_msg_size_mib: 16
        http:
          endpoint: 0.0.0.0:4318

    # PULL receiver
    # Will go to the Kubernetes API to get the cluster-level state
    # Runs only on Gateway (one place per cluster)
    # uses GET /api/v1/nodes, GET /apis/apps/v1/deployments etc.
    # converts responses to metircs like k8s.deployment.available, k8s.node.condition_ready, k8s.hpa.current_replicas
    # returns them to a corresponding pipeline
    k8s_cluster:
      collection_interval: 30s
      node_conditions_to_report: [Ready, MemoryPressure, DiskPressure, PIDPressure]
      allocatable_types_to_report: [cpu, memory, ephemeral-storage]

    # PULL receiver
    # Will go to the Kubernetes API, but uses `watch` mode
    # uses the 'events.k8s.io/v1/events' endpoint to receive event stream in real time
    # converts Kubernetes Events to Log records
    # returns them to the logs pipeline
    k8sobjects:
      objects:
        - name: events
          mode: watch
          group: events.k8s.io

  processors:
    # Memory protection against traffic spikes to avoid OOM kills
    memory_limiter:
      check_interval: 1s
      limit_percentage: 80
      spike_limit_percentage: 25

    # Enrich every signal with K8s pod metadata - this is what unifies labels
    # across metrics, logs and traces
    # docs: https://opentelemetry.io/docs/platforms/kubernetes/collector/components/#kubernetes-attributes-processor
    k8sattributes:
      auth_type: serviceAccount
      passthrough: false
      extract:
        # data taken from the Kubernetes API - fields from the Pod object to be added as attributes
        # i.e. a Kubernetes Namespace 'dev-backend-api-ns' for a Pod will be set as k8s.namespace.name="dev-backend-api-ns"
        # https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/k8sattributesprocessor#configuration
        metadata:
          - k8s.namespace.name
          - k8s.pod.name
          - k8s.pod.uid
          - k8s.pod.start_time
          - k8s.deployment.name
          - k8s.statefulset.name
          - k8s.daemonset.name
          - k8s.cronjob.name
          - k8s.job.name
          - k8s.node.name
        # add custom labels from the Pod object
        # i.e. a Pod with label 'app.kubernetes.io/component=backend' will be set as app.label.component="backend"
        labels:
          - tag_name: app.label.component
            key: app.kubernetes.io/component
            from: pod
          - tag_name: app.label.name
            key: app.kubernetes.io/name
            from: pod
      # pod_association processor is used to associate signals (metrics, logs, traces) with the correct Pod
      # e.g. when the Gateway receive a metric from a Pod, it need to know how to find that Pod in the Kubernetes API
      # for example, our Kubernetes Agent will send a metric from 'kubeletstats' for a container
      # but this metrics will not have a corresponding 'k8s.deployment.name'
      # so here, k8sattributes proecessor will ask the Kubernetes API to get additional metadata and set it as attributes
      pod_association:
        - sources:
            - from: resource_attribute
              name: k8s.pod.ip
        - sources:
            - from: resource_attribute
              name: k8s.pod.uid
        - sources:
            - from: connection

    # similar to the k8sattributes.extract.labels above, but for the resource attributes to all signals
    # sets hard-coded values
    resource:
      attributes:
        # action may be set as:
        # - insert: add only if not exists
        # - update: update if exists
        # - upsert: insert if not exists, update if exists
        # - delete: delete if exists
        - key: k8s.cluster.name
          value: eks-ops-1-33
          action: upsert
        - key: cloud.provider
          value: aws
          action: upsert

    # Batch records for efficient export
    # collects data to its buffer and sends it to the exporter in batches
    # docs: https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/batchprocessor
    batch:
      send_batch_size: 8192
      timeout: 10s

  # Where to send the data to - in our case, to VictoriaMetrics and VictoriaLogs
  # docs: https://docs.victoriametrics.com/opentelemetry/
  exporters:
    # VictoriaMetrics - OTLP endpoint
    # docs: https://docs.victoriametrics.com/victoriametrics/data-ingestion/opentelemetry-collector/
    # the '/v1/metrics' part will be added by the exporter itself
    otlphttp/vmetrics:
      endpoint: http://vmsingle-vm-k8s-stack.ops-monitoring-ns.svc.cluster.local:8428/opentelemetry
      tls:
        insecure: true

    # VictoriaLogs - OTLP endpoint
    # docs: https://docs.victoriametrics.com/victorialogs/data-ingestion/opentelemetry/
    # the '/v1/logs' part will be added by the exporter itself
    otlphttp/vlogs:
      endpoint: http://atlas-victoriametrics-victoria-logs-single-server.ops-monitoring-ns.svc.cluster.local:9428/insert/opentelemetry
      tls:
        insecure: true

    # Debug exporter - for troubleshooting, can be added to any pipeline temporarily
    debug:
      verbosity: basic

  # Combine everything into a single service definition
  service:
    # Pipelines operate on three telemetry data types: traces, metrics, and logs.
    # Each pipeline has its own set of receivers, processors and exporters.
    # docs: https://opentelemetry.io/docs/collector/architecture/#pipelines
    pipelines:
      metrics:
        # Reference receivers by their names from the config.receivers section above
        receivers: [otlp, k8s_cluster]
        # Reference processors by their names from the config.processors section above
        # IMPORTANT NOTE: order matters - processors run in the order listed here
        processors: [memory_limiter, k8sattributes, resource, batch]
        # Reference exporters by their names from the config.exporters section above
        exporters: [otlphttp/vmetrics]

      logs:
        receivers: [otlp, k8sobjects]
        processors: [memory_limiter, k8sattributes, resource, batch]
        exporters: [otlphttp/vlogs, debug]

    telemetry:
      metrics:
        readers:
          - pull:
              exporter:
                prometheus:
                  host: 0.0.0.0
                  port: 8888

В принципі, все описав в коментарях – але давайте коротко про те, що ми тут маємо:

  • mode="deployment": Gateway створюємо у вигляді Kubernetes Deployment з двома Pods
    • для Kubernetes Agent будемо робити DaemonSet, бо він має працювати на кожній WorkerNode
  • receivers: описуємо вхідні дані – можуть бути PULL (самі звертаються до зовнішніх API), або PUSH (в них пушать агенти/колектори)
    • otlp: ендпоінти для Kubernetes та AWS Agents
    • k8s_cluster: звертається до Kubernetes API, отримує інформацію по Nodes, Pods, Events
    • k8sobjects.objects="events": від Kubernetes API постійно отримує Kubernetes Events, записує у вигляді логів
  • processors:
    • k8sattributes: додає атрибути до кожної метрики чи лога (namespace, deployment name, etc)
    • resource.attributes: додає “глобальні” атрибути до кожного отриманого сигналу (див. OpenTelemetry Resource Attributes Explained Practically)
  • exporters: куди дані пишуть – бекенди, в нашому випадку передаємо до VictoriaMetrics, VictoriaLogs та VictoriaTraces
  • service: об’єднуємо все описане вище
    • pipelines:
      • metrics: в якому порядку і що робити з метриками
      • logs: те саме – але для логів
      • пізніше тут буде пайплайн для traces
    • telemetry: включаємо self monitoring – можемо подивитись на метрики самого OTel

Деплоїмо:

$ helm -n ops-monitoring-ns upgrade --install otel-gateway open-telemetry/opentelemetry-collector -f otel-gateway-values.yaml

Перевіряємо поди:

$ kubectl -n ops-monitoring-ns get pod -l app.kubernetes.io/instance=otel-gateway
NAME                                                    READY   STATUS    RESTARTS   AGE
otel-gateway-opentelemetry-collector-57b74ffd98-4pqhw   1/1     Running   0          68s
otel-gateway-opentelemetry-collector-57b74ffd98-td6hr   1/1     Running   0          68s

Kubernetes Service – його будуть використовувати Agents:

$ kubectl -n ops-monitoring-ns get svc -l app.kubernetes.io/instance=otel-gateway
NAME                                   TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)                                                            AGE
otel-gateway-opentelemetry-collector   ClusterIP   172.20.204.222   <none>        6831/UDP,14250/TCP,14268/TCP,8888/TCP,4317/TCP,4318/TCP,9411/TCP   90s

Перевірка Metrics

І за хвилину можемо вже перевірити метрики по {k8s.cluster.name="eks-ops-1-33"}:

OpenTelemetry: OTel Collectors в Kubernetes та інтеграція з VictoriaMetrics stack

Де маємо метрику k8s.container.cpu_limit – це від k8s_cluster receiver, який сходив до /api/v1/pods в Kubernetes APIs і прочитав spec.containers[].resources.limits.cpu.

The Cardinality Issue

Тепер важливий момент – в лейблах бачимо багато різних ID, наприклад:

k8s.container.cpu_limit {..., container.id="a6a73186104e064e406330620b09bc367418ad4ce3564a1ef21d48de3597dad7", ..., k8s.pod.name="otel-gateway-opentelemetry-collector-57b74ffd98-td6hr",k8s.pod.start_time="2026-05-15T10:36:54Z",k8s.pod.uid="55b9990a-49e7-4913-be53-40d0d640cf72", ...}

Кожен раз, коли Kubernetes Pod перестворюється – для нього генерується нове значення в k8s.pod.uid.

Детально чому і як це впливає на VictoriaMetrics storage та навантаження описував в пості VictoriaMetrics: Churn Rate, High cardinality, метрики та IndexDB, але якщо коротко – кожне унікальне значення кожної лейбли збільшує і зайняте місце на диску, і розмір індексної бази VictoriaMetrics, та, відповідно, впливає на споживання CPU/RAM і швидкість пошуку.

Аби запобігти цьому – можемо додати ще один processor, який буде видаляти такі лейбли.

Порядок додавання в config.processors неважливий – він важливий в пайплайні, але логічно додати біля блоку resource:

...
  processors:
    ...
    resource:
      attributes:
        - key: k8s.cluster.name
          value: eks-ops-1-33
          action: upsert
        - key: cloud.provider
          value: aws
          action: upsert

    # Drop high-cardinality resource attributes from metrics only
    # These change on every pod recreation and cause series explosion in VictoriaMetrics.
    # Logs and traces keep them - useful for debugging specific pod instances.
    resource/drop_volatile_labels:
      attributes:
        - key: k8s.pod.uid
          action: delete
        - key: container.id
          action: delete
        - key: k8s.pod.start_time
          action: delete
...

Інший варіант – видаляти з -search.maxStalenessInterval=4h самої VictoriaMetrics, див. List of command-line flags.

При цьому пам’ятаємо, що у нас є два різних типи атрибутів, і, відповідно, це будуть різні processors:

  • record-level attributes: атрибути конкретного запису (i.e. container CPU usage)
  • resource-level attributes: атрибути джерела – додаються до всіх signals, які передаються до бекендів

Перевірити які саме атрибути треба модифікувати можна в документації конкретного processor, наприклад для k8sattributes processor:

The processor automatically discovers k8s resources (pods), extracts metadata from them and adds the extracted metadata to the relevant spans, metrics and logs as resource attributes.

Або в OTel specification, наприклад для Pod документація має URI /resource/k8s/#pod.

Додаємо новий processors в pipeline для merics – після resource, але перед batch:

...
  service:
    pipelines:
      metrics:
        receivers: [otlp, k8s_cluster]
        processors: [memory_limiter, k8sattributes, resource, resource/drop_volatile_labels, batch]
...

Чому саме така позиція в pipeline – тому всі ресурси в pipeline виконується в тому порядку, в якому вони описані, а обробка resource/drop_volatile_labels має йти:

  • після k8sattributes – бо саме він додає k8s.pod.uid, треба його викидати після того, як він з’явився
  • після resource – щоб resource processor вже встиг проставити свої лейбли
  • перед batch – щоб batch групував вже очищені дані

Апдейтимо деплой, перевіряємо:

OpenTelemetry: OTel Collectors в Kubernetes та інтеграція з VictoriaMetrics stack

І лейблів з .id більше нема

Тепер у нас є робочий OTel Gateway, в якому ми:

  • готові приймати дані майбутніх Agents та наших сервісів типу Backend API (порти 4317/4318)
  • збираємо cluster-level метрики (k8s_cluster)
  • збираємо K8s events як логи (k8sobjects)
  • доповнюємо k8s-метаданими (k8sattributes)
  • додаємо до всіх даних  власні лейбли (k8s.cluster.name, cloud.provider)
  • контролюємо cardinality (resource/drop_volatile_labels)
  • маємо захист від OOM Killer (memory_limiter)
  • налаштували batch-експорт до VictoriaMetrics і VictoriaLogs

Що залишилось – AWS Collector для метрик з AWS CloudWatch та логів AWS ALB, і налаштувати отримання та передачу traces.

Перевірка Logs

Перевіряємо логи – запит {k8s.cluster.name="eks-ops-1-33"}.

Поки тут тільки логи по Kubernetes Events – логи Pods додамо пізніше з filelog в Kubernetes Agent:

OpenTelemetry: OTel Collectors в Kubernetes та інтеграція з VictoriaMetrics stack

Тут є дві невеликі – але проблеми:

  • поле _msg не сформоване
  • мусор в object.metadata.managedFields

Додавання transform для логів

Перевизначити що саме і як буде записано в лог можемо через processors.transform:

...
config:
  ...
  processors:

    ...
    # Normalize k8sobjects events: set readable body, drop noisy fields
    transform/k8s_events:
      #error_mode: ignore
      error_mode: propagate
      log_statements:
        - context: log
          statements:
            # k8sobjects stores the Event as a map in body.
            # VictoriaLogs flattens it into object.* fields automatically.
            # Build readable "REASON: note" message from body fields.
            - >-
              set(body, Concat([body["object"]["reason"], ": ", body["object"]["note"]], ""))
              where attributes["event.domain"] == "k8s" and attributes["k8s.resource.name"] == "events"
...

Тут ми самі формуємо поле body, яке VictoriaLogs використає для свого поля _msg.

Побачити як взагалі формується event object можна включивши debug exporter в detailed verbosity:

...
debug:
      verbosity: detailed
...

А потім додати його в logs pipeline:

...
logs:
        receivers: [otlp, k8sobjects]
        processors: [memory_limiter, k8sattributes, resource, batch]
        exporters: [otlphttp/vlogs, debug]
...

І потім просто подивитись логи подів з Gateway.

Додаємо transform/k8s_events до logs pipeline перед batch:

...
  service:
    pipelines:
      metrics:
        ...

      logs:
        receivers: [otlp, k8sobjects]
        processors: [memory_limiter, k8sattributes, resource, transform/k8s_events, batch]
        exporters: [otlphttp/vlogs, debug]
...

І тепер маємо красиве поле _msg:

OpenTelemetry: OTel Collectors в Kubernetes та інтеграція з VictoriaMetrics stack

Запуск Kubernetes Agent

Наступний крок – додати експортер, який вже буде збирати Pod level дані – метрики та логи.

Створюємо файл otel-k8s-agent-values.yaml:

# OTel Collector - Agent role (DaemonSet)
#
# Runs on every node, collects local data only:
#   - System metrics from host /proc, /sys (hostmetrics receiver)
#   - Pod/container metrics from local kubelet (kubeletstats receiver)
#   - Container logs from /var/log/pods (filelog receiver)
#
# Forwards everything to Gateway via OTLP gRPC.
# Gateway adds k8s metadata and exports to Victoria-* backends.

mode: daemonset

# contrib image has hostmetrics, kubeletstats, filelog receivers
image:
  repository: otel/opentelemetry-collector-contrib

# Mount host filesystem paths needed by hostmetrics and filelog
extraVolumes:
  - name: varlogpods
    hostPath:
      path: /var/log/pods
  - name: varlibdockercontainers
    hostPath:
      path: /var/lib/docker/containers
  - name: hostfs
    hostPath:
      path: /

extraVolumeMounts:
  - name: varlogpods
    mountPath: /var/log/pods
    readOnly: true
  - name: varlibdockercontainers
    mountPath: /var/lib/docker/containers
    readOnly: true
  - name: hostfs
    mountPath: /hostfs
    readOnly: true
    mountPropagation: HostToContainer

# Root is required to read /proc, /sys from the host
securityContext:
  runAsUser: 0
  runAsGroup: 0

resources:
  limits:
    cpu: 500m
    memory: 1Gi
  requests:
    cpu: 100m
    memory: 256Mi

# Agent must run on every node, including tainted ones
tolerations:
  - effect: NoSchedule
    operator: Exists
  - key: CriticalAddonsOnly
    operator: Exists
    effect: NoSchedule
  - key: CriticalAddonsOnly
    operator: Exists
    effect: NoExecute
  - key: BackendOnly
    operator: Exists
  - key: BackendDevOnly
    operator: Exists
  - key: BackendProdOnly
    operator: Exists
  - key: GitHubOnly
    operator: Exists
  - key: GitHubControllerOnly
    operator: Exists
  - key: GitHubRunnersOnly
    operator: Exists

# Inject node identity and host paths into the collector container
extraEnvs:
  - name: K8S_NODE_NAME
    valueFrom:
      fieldRef:
        fieldPath: spec.nodeName
  - name: K8S_POD_IP
    valueFrom:
      fieldRef:
        fieldPath: status.podIP
  # hostmetrics uses these env vars to read host /proc, /sys instead of container's
  - name: HOST_PROC
    value: /hostfs/proc
  - name: HOST_SYS
    value: /hostfs/sys
  - name: HOST_ETC
    value: /hostfs/etc
  - name: HOST_VAR
    value: /hostfs/var
  - name: HOST_RUN
    value: /hostfs/run
  - name: HOST_DEV
    value: /hostfs/dev

# Need read access to kubelet stats endpoint
clusterRole:
  create: true
  rules:
    - apiGroups: [""]
      resources: ["nodes/stats", "nodes/proxy", "nodes/metrics"]
      verbs: ["get"]
    - apiGroups: [""]
      resources: ["pods", "namespaces", "nodes"]
      verbs: ["get", "list", "watch"]

# Self-monitoring port
ports:
  metrics:
    enabled: true
    containerPort: 8888
    servicePort: 8888
    protocol: TCP

config:
  receivers:
    # PULL receiver
    # Reads node-level system metrics from host /proc and /sys
    # Replaces node_exporter functionality
    # Produces: system.cpu.*, system.memory.*, system.disk.*, system.network.*,
    #           system.filesystem.*, system.load.*, system.paging.*, system.processes.*
    hostmetrics:
      collection_interval: 30s
      root_path: /hostfs
      scrapers:
        cpu:
          metrics:
            system.cpu.utilization:
              enabled: true
        memory:
          metrics:
            system.memory.utilization:
              enabled: true
        disk:
        filesystem:
          exclude_mount_points:
            mount_points: ["/var/lib/kubelet/*", "/var/lib/docker/*", "/proc/*", "/sys/*"]
            match_type: regexp
          exclude_fs_types:
            fs_types: [tmpfs, devtmpfs, overlay, squashfs]
            match_type: strict
        network:
        load:
        paging:
        processes:

    # PULL receiver
    # Queries local kubelet (port 10250) for per-pod and per-container metrics
    # Replaces cadvisor functionality (which is built into kubelet)
    # Produces: k8s.node.*, k8s.pod.*, container.* (cpu/memory/network/filesystem)
    kubeletstats:
      collection_interval: 30s
      auth_type: serviceAccount
      endpoint: "https://${env:K8S_NODE_NAME}:10250"
      insecure_skip_verify: true
      metric_groups:
        - node
        - pod
        - container
        - volume

    # PULL receiver
    # Reads container logs from disk - standard CRI/containerd path
    # Replaces promtail / fluent-bit functionality
    # Container operator parses CRI log format and extracts k8s.* attributes from file path
    filelog:
      include:
        - /var/log/pods/*/*/*.log
      exclude:
        # Don't collect our own logs to avoid feedback loops
        - /var/log/pods/ops-monitoring-ns_otel-*/*/*.log
      start_at: end
      include_file_path: true
      include_file_name: false
      operators:
        - type: container
          id: container-parser

  processors:
    # Memory protection against traffic spikes
    memory_limiter:
      check_interval: 1s
      limit_percentage: 80
      spike_limit_percentage: 25

    # Tag everything with the node we're running on
    # Cluster-level attributes (k8s.cluster.name etc.) are added by Gateway
    resource:
      attributes:
        - key: k8s.node.name
          value: ${env:K8S_NODE_NAME}
          action: upsert

    # Batch records before sending to Gateway
    batch:
      send_batch_size: 8192
      timeout: 10s

  exporters:
    # Forward everything to Gateway via OTLP gRPC
    # Gateway will add k8s metadata and route to the right Victoria backend
    otlp:
      endpoint: otel-gateway-opentelemetry-collector.ops-monitoring-ns.svc.cluster.local:4317
      tls:
        insecure: true
      sending_queue:
        enabled: true
        num_consumers: 4
        queue_size: 1000
      retry_on_failure:
        enabled: true
        initial_interval: 5s
        max_interval: 30s

  service:
    pipelines:
      metrics:
        receivers: [hostmetrics, kubeletstats]
        processors: [memory_limiter, resource, batch]
        exporters: [otlp]

      logs:
        receivers: [filelog]
        processors: [memory_limiter, resource, batch]
        exporters: [otlp]

    telemetry:
      metrics:
        readers:
          - pull:
              exporter:
                prometheus:
                  host: 0.0.0.0
                  port: 8888

Тут маємо аналогічну до Gateway структуру – теж receivers, processors, exporters та pipelines.

Різниця в тому, як деплоїмо Pods, які receivers описуємо та куди виконуємо export:

  • mode="daemonset": Collector має бути запущеним на кожній WorkerNode кластеру
  • receivers:
    • hostmetrics: node-level – CPP, RAM, диски, нетворк (аналог Prometheus Node Exporter)
    • kubeletstats: метрики контейнерів (аналог cAdvisor_exporter)
    • filelog: збираємо логи контейнерів (аналог Promtail/Filebeat/etc)
  • exporters: зібрані агентом дані передаємо до OTel Gateway – він їх обробить та передасть до VictoriaMetrics/Logs/Traces

Деплоїмо:

$ helm -n ops-monitoring-ns upgrade --install otel-k8s-agent open-telemetry/opentelemetry-collector -f otel-k8s-agent-values.yaml

Перевіряємо поди:

$ kubectl -n ops-monitoring-ns get pods -l app.kubernetes.io/instance=otel-k8s-agent
NAME                                                 READY   STATUS    RESTARTS   AGE
otel-k8s-agent-opentelemetry-collector-agent-2ft7s   1/1     Running   0          35s
otel-k8s-agent-opentelemetry-collector-agent-79gs2   1/1     Running   0          35s
otel-k8s-agent-opentelemetry-collector-agent-bdhsd   0/1     Pending   0          35s
...

За хвилину перевіряємо метрики в VictoriaMetrics – {__name__=~"k8s\\.pod\\.cpu\\..*", k8s.cluster.name="eks-ops-1-33"}:

OpenTelemetry: OTel Collectors в Kubernetes та інтеграція з VictoriaMetrics stack

Та логи, наприклад з {k8s.namespace.name="dev-backend-api-ns"}:

OpenTelemetry: OTel Collectors в Kubernetes та інтеграція з VictoriaMetrics stack

Тут не дуже ОК, що log streams створюються з таким великим набором labels:

_stream	{cloud.provider="aws",k8s.cluster.name="eks-ops-1-33",k8s.container.name="backend-celery-workers-container",k8s.container.restart_count="1",k8s.deployment.name="backend-celery-workers-deployment",k8s.namespace.name="dev-backend-api-ns",k8s.node.name="ip-10-0-37-96.ec2.internal",k8s.pod.name="backend-celery-workers-deployment-669c8bb67-vspzn",k8s.pod.start_time="2026-05-15T11:10:26Z",k8s.pod.uid="6c6c12e6-cade-41e4-aa80-20cb4e08a54a"}

Це теж можна вирішити з processor, який робили для metrics, або створити новий, наприклад:

resource/drop_log_labels:
      attributes:
        - key: k8s.pod.uid
          action: delete
        - key: k8s.container.restart_count
          action: delete

І потім підключити в logs pipeline:

...
      logs:
        receivers: [otlp, k8sobjects]
        processors: [memory_limiter, k8sattributes, resource, resource/drop_log_labels, transform/k8s_events, batch]
        exporters: [otlphttp/vlogs]
...

Але деякі лейбли можуть бути корисним – як-от k8s.container.restart_count.

Тому інший варіант – на самій VictoriaLogs передати collector.streamFields або collector.ignoreFields, можна зробити прямо в OTel Gateway через header VL-Stream-Fields:

...

    otlphttp/vlogs:
      endpoint: http://atlas-victoriametrics-victoria-logs-single-server.ops-monitoring-ns.svc.cluster.local:9428/insert/opentelemetry
      tls:
        insecure: true
      headers:
        VL-Stream-Fields: "k8s.cluster.name,k8s.namespace.name,k8s.deployment.name,k8s.container.name,k8s.pod.name"

...

Grafana і запити Prometheus vs OpenTelemetry

І трохи про те, що зміниться в Grafana та алертах.

Наприклад, є такий запит в Prometheus-форматі:

sum(container_memory_working_set_bytes{namespace="$namespace", pod="$pod", image!="", container!="POD", container!=""}) by (pod)

В OpenTelemetry форматі він буде виглядати так:

sum({__name__="container.memory.working_set", k8s.namespace.name="$namespace", k8s.pod.name="$pod"}) by (k8s.pod.name)

Результат на графіках – зверху старий, Prometheus, внизу – новий, OpenTelemetry:

OpenTelemetry: OTel Collectors в Kubernetes та інтеграція з VictoriaMetrics stack

Для VictoriaMetrics можна задати opentelemetry.usePrometheusNaming (див. List of command-line flags та Label sanitization) – тоді метрики будуть створюватись в форматі Prometheus з “_” замість “.“.

Але для VictoriaLogs та VictoriaTraces такої опції не бачу – спитаю девелоперів, чи є там якісь адекватні варіанти це вирішити.

Loading

Arch Linux: WireGuard Peer для підключення до MikroTik
5 (1)

8 Травня 2026

В пості MikroTik: налаштування WireGuard та підключення Linux peers описував налаштування MikroTik в ролі VPN Hub та підключення peer на Debian Linux.

Загалом, налатування на Arch Linux не відрізняються від Debian – але кожного разу починаю збирати потрібні конфіги по цьому блогу і іншим моїм хостам – тому опишу окремо, що було в одному місці, плюс тут є трохи нюансів з DNS та NteworkManager.

Власне, що треба буде зробити – встановити WireGuard, створити ключі та файл конфігу, на MikroTik створити новий Peer.

Установка WireGuard

Встановлюємо пакет wireguard-tools – в ньому йдуть всі утиліти + systemd-unit для запуску WireGuard:

$ sudo pacman -S wireguard-tools

Генерація ключів

Створюємо каталог /etc/wireguard/, в ньому генеруємо приватний та публічний ключі:

# mkdir /etc/wireguard/
# cd /etc/wireguard/
# wg genkey | sudo tee /etc/wireguard/privatekey | wg pubkey | sudo tee /etc/wireguard/publickey
0ClB2Lf5uQmWK8Nz0XRofuVkvbQbSfrf3ioHbYOm9F4=

На приватний ключ задаємо права на читання тільки root:

# chmod 600 /etc/wireguard/privatekey

Створення конфігу для WireGuard

В директорії /etc/wireguard/ створюємо файл wg0.conf:

[Interface]
PrivateKey = qIU***W4=
Address = 10.100.0.10/32
DNS = 192.168.0.1, 10.100.0.1

[Peer]
PublicKey = hxz***0o=
Endpoint = 178.***.***.184:51820

AllowedIPs = 10.100.0.0/24,192.168.0.0/24,192.168.100.0/24
PersistentKeepalive = 25

Тут:

  • [Interface]
    • PrivateKey: приватний ключ на Arch Linux
    • Address: IP-адреса цього Peer, буде використана для локального інтерфейсу wg0
  • [Peer]
    • PublicKey: публічний ключ з MikroTik
    • Endpoint: зовнішня адреса за якою доступний MikroTik, та порт, на якому WireGuard приймає підключення
    • AllowedIPs: в які мережі може ходити цей peer і для яких будуть створені локальні роути

На Arch Linux отримуємо публічний ключ:

# cat /etc/wireguard/publickey 
0ClB2Lf5uQmWK8Nz0XRofuVkvbQbSfrf3ioHbYOm9F4=

Додаємо новий Peer на MikroTik:

/interface wireguard peers add interface=wg0 public-key="0Cl***9F4=" allowed-address=10.100.0.10/32,192.168.0.0/24,192.168.100.0/24 comment=setevoy-office

Перевіряємо:

/interface wireguard peers print where comment="setevoy-office-new" 
Columns: INTERFACE, PUBLIC-KEY, ENDPOINT-PORT, ALLOWED-ADDRESS
# INTERFACE  PUBLIC-KEY                                    ENDPOINT-PORT  ALLOWED-ADDRESS 
;;; setevoy-office-new
5 wg0        0ClB2Lf5uQmWK8Nz0XRofuVkvbQbSfrf3ioHbYOm9F4=              0  10.100.0.10/32  
                                                                          192.168.0.0/24  
                                                                          192.168.100.0/24

Можна підключатись – але можлива проблема з resolvconf та /etc/resolv.conf.

WireGuard та помилка “resolvconf: signature mismatch: /etc/resolv.conf”

На Arch Linux запускаємо підключення:

# systemctl start wg-quick@wg0
Job for [email protected] failed because the control process exited with error code.
See "systemctl status [email protected]" and "journalctl -xeu [email protected]" for details.

Перевіряємо статус:

# systemctl status [email protected]
× [email protected] - WireGuard via wg-quick(8) for wg0
     Loaded: loaded (/usr/lib/systemd/system/[email protected]; disabled; preset: disabled)
     Active: failed (Result: exit-code) since Fri 2026-05-08 08:57:47 EEST; 20s ago
...
May 08 08:57:47 setevoy-work wg-quick[1192596]: [#] wg addconf wg0 /dev/fd/63
May 08 08:57:47 setevoy-work wg-quick[1192596]: [#] ip -4 address add 10.100.0.10/32 dev wg0
May 08 08:57:47 setevoy-work wg-quick[1192596]: [#] ip link set mtu 1420 up dev wg0
May 08 08:57:47 setevoy-work wg-quick[1192644]: [#] resolvconf -a wg0 -m 0 -x
May 08 08:57:47 setevoy-work wg-quick[1192674]: resolvconf: signature mismatch: /etc/resolv.conf
May 08 08:57:47 setevoy-work wg-quick[1192674]: resolvconf: run `resolvconf -u` to update
May 08 08:57:47 setevoy-work wg-quick[1192596]: [#] ip link delete dev wg0
May 08 08:57:47 setevoy-work systemd[1]: [email protected]: Main process exited, code=exited, status=1/FAILURE

Проблема в тому, що в системі є і openresolv і NetworkManager з дефолтним dns=default – тобто NetworkManager пише /etc/resolv.conf напряму, без resolvconf.

При цьому openresolv тримає в файлі свій checksum для файлу /etc/resolv.conf, і коли NetworkManager перезаписує файл – контрольна сума не сходиться, через що resolvconf -a (який викликається wg-quick) падає з помилкою “signature mismatch“.

Option 1: PreUp та resolvconf -u (“грязний хак”)

Є “грязний хак” – додати до /etc/wireguard/wg0.conf опцію PreUp з запуском resolvconf -u:

[Interface]
PrivateKey = qIU***rW4=
Address = 10.100.0.10/32
DNS = 192.168.0.1, 10.100.0.1
PreUp = resolvconf -u

...

Цей варіант теж працює, але якщо NetworkManager перезапише /etc/resolv.conf вже після підняття тунелю (наприклад, при reconnect Wi-Fi) – DNS з тунелю злетять.

Тому краще просто переключити NetworkManager на використання systemd-resolved, аби він взагалі не писав файл напряму /etc/resolv.conf.

Option 2: NetworkManager та systemd-resolved (правильний варіант)

Редагуємо конфіг /etc/NetworkManager/NetworkManager.conf і додаємо блок [main] з опцією dns – див. DNS management:

[main]
dns=systemd-resolved

Стартуємо systemd-resolved і перезапускаємо NetworkManager:

# systemctl enable --now systemd-resolved && systemctl restart NetworkManager

Перевіряємо статус systemd-resolved:

# resolvectl status
Global
           Protocols: +LLMNR +mDNS -DNSOverTLS DNSSEC=no/unsupported
    resolv.conf mode: foreign
  Current DNS Server: 10.100.0.1
         DNS Servers: 192.168.0.1 10.100.0.1
...

Перевіряємо що тепер в /etc/resolv.conf:

# cat /etc/resolv.conf
# Generated by NetworkManager
nameserver 127.0.0.53
options edns0 trust-ad

127.0.0.53 – це і є наш локальний systemd-resolved:

# netstat -anp | grep 127.0.0.53
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      1221589/systemd-res 
udp        0      0 127.0.0.53:53           0.0.0.0:*                           1221589/systemd-res

Або з ss -lntup | grep 127.0.0.53 – але я звик до netstat.

Option 3: чистий openresolv (just in case)

Альтернатива – задати dns=none в NetworkManager: тоді NM взагалі не чіпає /etc/resolv.conf і єдиним менеджером файлу стає openresolv: він об’єднує записи від wg-quick і підключень NetworkManager напряму в /etc/resolv.conf зі списком реальних DNS-серверів (192.168.0.1, 10.100.0.1, …).

При такому варіанті systemd-resolved взагалі не потрібен – запити на DNS resolution йдуть напряму через glibc: простіше конфігурація і менше сервісів – але втрачаємо плюшки systemd-resolved: кешування, split-DNS, DNSSEC.

Власне, все – запускаємо WireGuard:

# systemctl start wg-quick@wg0
# systemctl enable wg-quick@wg0

Перевіряємо статус:

# wg show
interface: wg0
  public key: 0Cl***9F4=
  private key: (hidden)
  listening port: 47047

peer: hxz***50o=
  endpoint: 178.***.***.184:51820
  allowed ips: 10.100.0.0/24, 192.168.0.0/24, 192.168.100.0/24
  latest handshake: 20 seconds ago
  transfer: 12.06 KiB received, 8.13 KiB sent
  persistent keepalive: every 25 seconds

Перевіряємо підключення до MikroTik через VPN-тунель:

root@setevoy-work:/etc/wireguard # ssh [email protected]
...
[admin@mikrotik-rb4011-gw] > 

Готово.

Loading

FreeBSD: Jails networking та менеджмент контейнерів з Bastille
0 (0)

4 Травня 2026

Іноді на FreeBSD треба запустити якісь сервіси, які офіційно FreeBSD не підтримують, і власне, цей пост з’явився через те, що я встановлював Open WebUI на своєму NAS – і як раз Open WebUI простіше було зробити на Linux.

Тому підняв його у FreeBSD Linux jail, а для створення контейнеру взяв Bastille, яка спрощує менеджмент.

Про сам Open WebUI може допишу чорнетку, а Bastille вирішив винести окремим постом – бо зараз буду сетапити Hermes Agent (вже – див. Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille, і хочеться мати таку собі коротку інструкцію по тому, як працювати з FreeBSD jails використовуючи Bastille.

Про сам мій NAS є ціла серія постів, вже 15 штук, див. початок у FreeBSD: Home NAS, part 1 – налаштування ZFS mirror.

What is the FreeBSD Jails та Bastille?

Щоб відповісти на це питання – треба спочатку згадати що таке FreeBSD Jails.

FreeBSD jails – аналог Docker/ContainerD в Linux – але з’явились набагато раніше, ніж Linux LXC та namespaces та cgroups, які потім “еволюціонували” в Docker. Про Linux cgroups писав детально у Kubernetes: Pod resources.requests, resources.limits та Linux cgroups.

FreeBSD jails з’явились ще у 1999 році як розвиток “неповноцінної” системи chroot, яка не давала повної ізоляції. З jails з’явилась можливість відокремлення filesystem, мати окремий network stack, власні PIDs і так далі – власне все те, до чого ми звикли в Linux та його контейнерах.

Як раз на днях зустрів цікавий пост на цю тему, де серед іншого говориться і за історію контейнеризації – Your Container Is Not a Sandbox.

На відміну від контейнерів в Linux – FreeBSD jails це єдина частина ядра системи, тоді як Linux – це комбінація різних механізмів (namespaces, cgroups).

Правда, вона має і недоліки – бо це все одно залишається одне і там саме ядро FreeBSD, зато вона простіша – а тому безпечніша і простіша в роботі та дебагу.

Власне, Bastille – це розвиток системи jails, точніше – система для спрощення менеджменту контейнерів у FreeBSD, аби не писати jail.conf руками і мати простий CLI для управління (як Docker – це “обгортка” для Linux containers).

Не Bastille єдиною – є аналогічні рішення як-от iocage, ezjail, pot та інші.

Чому взяв Bastille – проект активно розвивається, є велике комьюніті, має зручний CLI та добре інтегрується з можливостями ZFS.

Установка Bastille

Встановлюємо з репозиторію, додаємо в автостарт:

[root@test-free-15-bastille ~]# pkg install bastille
[root@test-free-15-bastille ~]# sysrc bastille_enable=YES
bastille_enable:  -> YES

Перевіряємо ім’я ZFS Pool:

[root@test-free-15-bastille ~]# zpool list -Ho name
zroot

Див. ZFS Support.

Додаємо підтримку ZFS до Bastille – файл /usr/local/etc/bastille/bastille.conf:

...
## ZFS options
bastille_zfs_enable="YES"
bastille_zfs_zpool="zroot"
bastille_zfs_prefix="bastille"
...

Bastille setup: базові налаштування системи

Перевіряємо версію хоста:

[root@test-free-15-bastille ~]# freebsd-version
15.0-RELEASE

Можна виконати bastille setup – система сама налаштує мережу, про мережу трохи детальніше далі:

[root@test-free-15-bastille ~]# bastille setup
bastille_enable: YES -> YES

ZFS has already been configured!

Configuring bastille0 loopback interface
cloned_interfaces:  -> lo1
ifconfig_lo1_name:  -> bastille0

Bringing up new interface: [bastille0]
Created clone interfaces: lo1.
bastille_network_loopback: bastille0 -> bastille0
bastille_network_shared:  -> 

Loopback interface successfully configured: [bastille0]

Determined default network interface: (em0)
/usr/local/etc/bastille/pf.conf does not exist: creating...
pf_enable: NO -> YES
Bastille pf ruleset created. Please review '/usr/local/etc/bastille/pf.conf' and enable pf using 'service pf start'.

Bastille has successfully been configured.

Відразу включило Packet Filter – треба для NAT, і створило правила. Див. FreeBSD: Home NAS, part 2 – знайомство з Packet Filter (PF) firewall.

Якщо PF не запущений – запускаємо (якщо по SSH – підключення буде розірване):

[root@test-free-15-bastille ~]# service pf start

І після setup вже маємо новий loopback інтерфейс:

[root@test-free-15-bastille ~]# ifconfig bastille0
bastille0: flags=8008<LOOPBACK,MULTICAST> metric 0 mtu 16384
        options=680003<RXCSUM,TXCSUM,LINKSTATE,RXCSUM_IPV6,TXCSUM_IPV6>
        groups: lo
        nd6 options=21<PERFORMNUD,AUTO_LINKLOCAL>

Мережа для jails

Документація – Networking та класний пост Managing Jails in FreeBSD with Bastille (2022 рік, але в цілому актуальний).

Bastille підтримує кілька тип нетворку:

  • VNET (DHCP): Bastille створює інтерфейс з типом bridge і підключає jail через epair – кожен jail отримує власні MAC та IP адреси, і виглядає як окремий хост у мережі
  • Bridged VNET (own bridge): те саме, але bridge створюється вручну – використовується для кастомних або ізольованих мереж
  • Alias/Shared Interface: один інтерфейс хоста, IP-адреси для jail-ів додаються як alias фізичного інтерфейсу хоста – простий варіант, але без окремого network stack (тобто у всіх буде загальний фаервол самого хоста, роутинг тощо)
  • NAT/Loopback Interface: jail отримує IP у внутрішній мережі і ходить в “світ” через NAT хоста, для доступу ззовні до jail потрібен port forwarding
  • Inherit: jail використовує той самий IP і інтерфейс, що й хост, використовується рідко – зазвичай для специфічних кейсів, доступ розділяється по портах – незручно, не гнучко, не масштабується

Далі детальніше подивимось на три основних типи – VNET, Alias та NAT.

Bastille bootstrap

Запускаємо bastille bootstrap, аналог docker pull – скачати базовий архів з системою, яку передамо аргументом, та розархівувати його для подальшого використання.

Якщо робимо контейнер з FreeBSD, то версія системи в  jail повинна бути =< версії хоста – перевіряємо її з freebsd-version:

[root@test-free-15-bastille ~]# freebsd-version 
15.0-RELEASE

Готуємо “образ” з цією версією:

[root@test-free-15-bastille ~]# bastille bootstrap 15.0-RELEASE

Attempting to bootstrap FreeBSD release: 15.0-RELEASE

Fetching MANIFEST...
/usr/local/bastille/cache/15.0-RELEASE/MANIFES        1044  B 7334 kBps    00s

Fetching distfile: base.txz
/usr/local/bastille/cache/15.0-RELEASE/base.tx         157 MB 8232 kBps    19s

Validating checksum for archive: base.txz
MANIFEST: ac0c933cc02ee8af4da793f551e4a9a15cdcf0e67851290b1e8c19dd6d30bba8
DOWNLOAD: ac0c933cc02ee8af4da793f551e4a9a15cdcf0e67851290b1e8c19dd6d30bba8

Extracting archive: base.txz

Bootstrap successful.

І маємо нові ZFS datasets:

[root@test-free-15-bastille ~]# zfs list -r zroot/bastille
NAME                                   USED  AVAIL  REFER  MOUNTPOINT
zroot/bastille                         532M  65.9G   120K  /usr/local/bastille
zroot/bastille/backups                  96K  65.9G    96K  /usr/local/bastille/backups
zroot/bastille/cache                   158M  65.9G    96K  /usr/local/bastille/cache
zroot/bastille/cache/15.0-RELEASE      158M  65.9G   158M  /usr/local/bastille/cache/15.0-RELEASE
zroot/bastille/jails                    96K  65.9G    96K  /usr/local/bastille/jails
zroot/bastille/logs                     96K  65.9G    96K  /var/log/bastille
zroot/bastille/releases                374M  65.9G    96K  /usr/local/bastille/releases
zroot/bastille/releases/15.0-RELEASE   374M  65.9G   374M  /usr/local/bastille/releases/15.0-RELEASE
zroot/bastille/templates                96K  65.9G    96K  /usr/local/bastille/templates

Тепер у нас все готове для створення контейнерів – подивимось, як робити jails з FreeBSD та Linux та різними налаштуваннями мережі.

Створення jails

Якщо запускаємо у VirtualBox – включаємо Promiscuous Mode в Allow All:

FreeBSD: Jails networking та менеджмент контейнерів з Bastille

Створення FreeBSD jails

Спочатку зробимо кілька контейнерів з FreeBSD і різними параметрами мережі – а потім запустимо jail з Linux.

Всі створені jail зберігаються в директорії /usr/local/bastille/jails/ – там для кожного контейнера буде директорія з його ім’ям та файлом jail.conf, який описує параметри цього контейнеру.

Network type VNET

Першим глянемо варіант з VNET – я ним користуюсь найбільше, бо зручно мати прямий доступ в контейнери, плюс повноцінна ізоляція на рівні мережі.

Аби задати тип нетворка VNET – до bastille create передаємо опцію --vnet (або коротка форма -V), потім ім’я jail, версію системи, IP-адресу та інтерфейс хоста для створення bridge.

Інтерфейс можна не передавати, якщо заданий bastille_network_gateway в /usr/local/etc/bastille/bastille.conf.

Замість передачі IP явно – можна вказати опцію DHCP або SYNCDHCP – тоді jail отримає адресу від роутера:

[root@test-free-15-bastille ~]# bastille create --vnet testjailVnetIp 15.0-RELEASE 192.168.0.205/24 em0

Attempting to create jail: testjailVnetIp

Valid IP: 192.168.0.205/24

...

[testjailVnetIp]:
e0a_bastille1
e0b_bastille1
testjailVnetIp: created
[root@test-free-15-bastille ~]# bastille list
 JID  Name            Boot  Prio  State  Type   IP Address     Published Ports  Release       Tags
 2    testjailVnetIp  on    99    Up     thin   192.168.0.205  -                15.0-RELEASE  -

Deep dive: VNET networking

Трохи детально згадував як працює нетворкінг, в принципі цю частину можна пропустити, але якщо цікаво – то подивимось, як пакет з ноутбука в локальній мережі з FreeBSD-хостом попадає всередину jail.

Тут в прикладі маємо всі хости в одній мережі 192.168.0.0/24:

  • робочий ноутбук з Arch Linux
  • хост FreeBSD з jail – 192.168.0.72
  • і, власне, сам jail з IP 192.168.0.205
Інтерфейси

Перевіряємо інтерфейси на хості FreeBSD:

[root@test-free-15-bastille ~]# ifconfig 
em0: [...]
        ...
        ether 08:00:27:d5:55:b2
        inet 192.168.0.72 netmask 0xffffff00 broadcast 192.168.0.255
        ...
em0bridge: [...]
        ...
        ether 58:9c:fc:10:fa:c0
        ...
        member: e0a_bastille1 [...]
                ...
        member: em0 [...]
                ...
        groups: bridge
        ...
e0a_bastille1: [...]
        description: vnet0 host interface for Bastille jail testjailVnetIp
        ...
        ether 02:20:99:d5:55:b2
        ...
        groups: epair
        ...

Що у нас тепер є:

  • інтерфейс em0:
    • IP: 192.168.0.72
    • MAC: 08:00:27:d5:55:b2
  • інтерфейс em0bridge: L2 switch – передає пакети між своїми members
    • groups: bridge
    • member: em0
      • port 1
    • member: e0a_bastille1
      • port 5
  • інтерфейс e0a_bastille1a): host side epair
    • groups: epair
    • ether 02:20:99:d5:55:b2

А інтерфейс e0b_bastille1b) створюється всередині jail – тільки з іменем vnet0 (для зручності).

Перевіряємо з jexec <jailname> ifconfig:

[root@test-free-15-bastille ~]# jexec testjailVnetIp ifconfig
lo0: [...]
        ...
vnet0: [...]
        description: jail interface for em0
        ...
        ether 0e:20:99:d5:55:b2
        ...
        inet 192.168.0.205 netmask 0xffffff00 broadcast 192.168.0.255
        groups: epair
        ...

Де бачимо, що у vnet0 той самий MAC 0e:20:99:d5:55:b2 як і на хості у інтерфейсів e0a_bastille1 та em0.

Data flow та ARP table

І тепер можна прослідкувати процес передачі даних до jail:

  1. з ноутбука виконуємо ssh 192.168.0.205 – на jail IP
  2. ноутбук виконує broadcast ARP-запит в мережу 192.168.0.0/24 – “хто має 192.168.0.205?
  3. фізичний інтерфейс em0 на хості отримує цей запит, ядро визначає, що em0 – це member bridge-інтерфейсу em0bridge на port 1, і передає дані на em0bridge
  4. em0bridge передає його до своїх members, на інші ports – в нашому випадку до e0a_bastille1 на port 5
  5. e0a_bastille1 – це “вхідний” socket, а e0b_bastille1 – його “вихід” всередині jail
    • для аналогії можна згадати socketpair(), який обєднує два socket, кожен з власним file descritor – на “вході” та на “виході”: все, що записується на “вхідний” сокет – попадає на другий сокет зв’язаної пари
  6. інтерфейс vnet0 в jail отримує цей запит, відповідає ноутбуку “це мій IP” та повертає свій MAC
  7. ноутбук записує цей MAC в свою ARP table

Подивитись ARP на Arch Linux можемо з ip neigh show:

[setevoy@setevoy-work ~]  $ ip neigh show 
192.168.0.205 dev enp0s13f0u3u4c2 lladdr 0e:20:99:d5:55:b2 REACHABLE
...

Далі, при формуванні пакету для цього jail – ядро хоста Arch Linux сформує Ethernet frame (див. TCP/IP: моделі OSI та TCP/IP, TCP-пакети, Linux sockets і порти), в якому буде IP packet:

  • на OSI layer 2 (Ethernet frame) headers:
    • src MAC: MAC інтерфейсу – в моєму випадку enp0s13f0u3u4c2
    • dst MAC: 0e:20:99:d5:55:b2 (MAC FreeBSD em0 та jail e0b_bastille1)
  • OSI layer 3 (IP packet) headers:
    • src IP: IP хоста з Arch Linux
    • dst IP: 192.168.0.205 – jail IP

А процес доставки даних в jail виглядає так:

  1. ноутбук формує Ethernet frame з dst MAC 0e:20:99:d5:55:b2
  2. фрейм через роутер/свіч домашньої мережі попадає на em0 хоста FreeBSD
  3. ядро FreeBSD “бачить”, що em0 – це member групи em0bridge та передає дані на e0a_bastille1
  4. пакет “входить” до e0a_bastille1 – і “виходить” у e0b_bastille1 – інтерфейсі vnet0 всередині нашого jail
  5. ядро в jail розпаковує Ethernet фрейм, перевіряє dst IP (192.168.0.205) та dst Port (22), бачить, що це його IP, а на порту 22 є демон SSH – і передає IP пакет до SSH

Начебто вірно описав.

Тепер, як трохи розібрались з мережею – можна створювати контейнери далі.

Підключення до jail

Підключитись з хоста можемо з bastille console:

[root@test-free-15-bastille ~]# bastille console testjailVnetIp

[testjailVnetIp]:
root@testjailVnetIp:~ #

В контейнері запускаємо sshd:

root@testjailVnetIp:~ # sysrc sshd_enable="YES"
sshd_enable: NO -> YES
root@testjailVnetIp:~ # service sshd start
...
Starting sshd.

Додаємо юзера:

root@testjailVnetIp:~ # pw useradd setevoy -m -s /bin/sh
root@testjailVnetIp:~ # passwd setevoy

І підключаємось з ноутбука:

[setevoy@setevoy-work ~]  $ ssh [email protected]
([email protected]) Password for setevoy@testjailVnetIp:
...
setevoy@testjailVnetIp:~ $ 

Network type Alias/Shared Interface

При Alias/Shared Interface на інтерфейс em0 просто буде доданий другий IP як аліас.

Створюємо контейнер – без додаткових опцій, тільки IP-адресу та інтерфейс хоста, як в прикладі з VNET:

[root@test-free-15-bastille ~]# bastille create testjailAlias 15.0-RELEASE 192.168.0.206 em0

Attempting to create jail: testjailAlias

Valid IP: 192.168.0.206

Valid interface: em0

...

[testjailAlias]:
testjailAlias: created

Перевіряємо на хості – тепер маємо дві адреси:

[root@test-free-15-bastille ~]# ifconfig 
em0: flags=1008943<UP,BROADCAST,RUNNING,PROMISC,SIMPLEX,MULTICAST,LOWER_UP> metric 0 mtu 1500
        ...
        inet 192.168.0.72 netmask 0xffffff00 broadcast 192.168.0.255
        inet 192.168.0.206 netmask 0xffffffff broadcast 192.168.0.206
        ...
lo0: flags=1008049<UP,LOOPBACK,RUNNING,MULTICAST,LOWER_UP> metric 0 mtu 16384
        ...
bastille0: flags=8008<LOOPBACK,MULTICAST> metric 0 mtu 16384
        ...
em0bridge: flags=1008843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST,LOWER_UP> metric 0 mtu 1500
        ...
e0a_bastille1: flags=1008943<UP,BROADCAST,RUNNING,PROMISC,SIMPLEX,MULTICAST,LOWER_UP> metric 0 mtu 1500
        description: vnet0 host interface for Bastille jail testjailVnetIp
        ...

А в контейнері – всі ті самі інтерфейси, що і на хості, але для em0 тільки один IP:

[root@test-free-15-bastille ~]# jexec testjailAlias ifconfig
em0: flags=1008943<UP,BROADCAST,RUNNING,PROMISC,SIMPLEX,MULTICAST,LOWER_UP> metric 0 mtu 1500
        ...
        ether 08:00:27:d5:55:b2
        inet 192.168.0.206 netmask 0xffffffff broadcast 192.168.0.206
        ...
lo0: flags=1008049<UP,LOOPBACK,RUNNING,MULTICAST,LOWER_UP> metric 0 mtu 16384
        ...
bastille0: flags=8008<LOOPBACK,MULTICAST> metric 0 mtu 16384
        ...
em0bridge: flags=1008843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST,LOWER_UP> metric 0 mtu 1500
        ...
e0a_bastille1: flags=1008943<UP,BROADCAST,RUNNING,PROMISC,SIMPLEX,MULTICAST,LOWER_UP> metric 0 mtu 1500
        ...

Тепер, якщо ми не запустимо sshd в контейнері – то підключення на IP 192.168.0.206 піде на SSH daemon самого хоста – “Password for setevoy@test-free-15-bastille

[setevoy@setevoy-work ~] $ ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null [email protected]
Warning: Permanently added '192.168.0.206' (ED25519) to the list of known hosts.
([email protected]) Password for setevoy@test-free-15-bastille:

А якщо маємо відкритий порт 22 в контейнері:

root@testjailAlias:~ # service sshd onestart

То запит піде на нього – “Password for setevoy@testjailAlias“:

[setevoy@setevoy-work ~] $ ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null [email protected]
Warning: Permanently added '192.168.0.206' (ED25519) to the list of known hosts.
([email protected]) Password for setevoy@testjailAlias:

Простіше за VNET – але маємо загальні правила Packet Filter, фаервола на хості, нема можливості отримати адресу з DHCP, можливі проблеми з overlapping ports, і важливе: якщо ламають наш jail – то отримують доступ до всієї мережі хоста.

Network type NAT

І останній на сьогодні приклад – з NAT, тільки тепер задаємо IP не з пулу домашньої мережі, а в інтерфейсі вказуємо loopback інтерфейс хоста bastille0:

[root@test-free-15-bastille ~]# bastille create testjailNat 15.0-RELEASE 10.0.0.10 bastille0

Attempting to create jail: testjailNat

Valid IP: 10.0.0.10

Valid interface: bastille0

...

Перевіряємо джейли тепер:

[root@test-free-15-bastille ~]# bastille list
 JID  Name            Boot  Prio  State  Type   IP Address     Published Ports  Release       Tags
 4    testjailAlias   on    99    Up     thin   192.168.0.206  -                15.0-RELEASE  -
 6    testjailNat     on    99    Up     thin   10.0.0.10      -                15.0-RELEASE  -
 2    testjailVnetIp  on    99    Up     thin   192.168.0.205  -                15.0-RELEASE  -

Роутинг пакетів до jail піде через Packet Filter:

[root@test-free-15-bastille ~]# pfctl -s nat
nat on em0 from <jails> to any -> (em0:0)
rdr-anchor "rdr/*" all

Запускаємо SSH в контейнері:

[root@test-free-15-bastille ~]# bastille service testjailNat sshd onestart

[testjailNat]:
Generating RSA host key.
...
Starting sshd.

Маємо підключення з хоста на 10.0.0.10:

[root@test-free-15-bastille ~]# ssh 10.0.0.10
...
([email protected]) Password for root@testjailNat:

А для підключення із зовнішньої мережі – на хості вмикаємо port forwarding (bastille rdr – redirect через Packer Filter):

[root@test-free-15-bastille ~]# bastille rdr testjailNat tcp 2222 22
IPv4 tcp/2222:22 on em0

І підключаємось з SSH на IP хоста FreeBSD, але порт задаємо 2222, і попадаємо в новий Jail – “Password for setevoy@testjailNat“:

[setevoy@setevoy-work ~]  $ ssh -p 2222 [email protected]
...
([email protected]) Password for setevoy@testjailNat:

І на останок – jails з Linux.

Створення Linux Jails

Документація – Linux Jails.

Важливе обмеження Linux jail – для мережі недоступні VNET-опції. Тобто з варіантів – або NAT і port-forward, або Alias з усіма його обмеженнями і можливими проблемами.

Крім того – “Linux jails are still considered experimental” – хоча в цілому працює достатньо стабільно.

Для Linux нам потрібно виконати bastille setup linux – тоді Bastille підтягне потрібні модулі і скрипти:

[root@test-free-15-bastille ~]# bastille setup linux
[WARNING]: Running linux jails requires loading additional kernel
modules, as well as installing the 'debootstrap' package.
Do you want to proceed with setup? [y|n]:y

Loading kernel module: fdescfs

Persisting module: fdescfs
fdescfs_load:  -> YES

Loading kernel module: linprocfs

Persisting module: linprocfs
linprocfs_load:  -> YES

Loading kernel module: linsysfs

Persisting module: linsysfs
linsysfs_load:  -> YES

Loading kernel module: linux

Loading kernel module: linux64
linux_enable: NO -> YES

...

Тепер в директорії /usr/local/share/debootstrap/scripts/ маємо набір shell-скриптів, які налаштують Linux-оточення:

[root@test-free-15-bastille ~]# less /usr/local/share/debootstrap/scripts/gutsy 
case $ARCH in
  amd64|i386)
        case $SUITE in
          gutsy|hardy|intrepid|jaunty|karmic|lucid|maverick|natty|oneiric|precise|quantal|raring|saucy|utopic|vivid|wily|yakkety|zesty)
        default_mirror http://old-releases.ubuntu.com/ubuntu
...
keyring /usr/local/share/keyrings/ubuntu-archive-keyring.gpg

Виконуємо bootstrap, вказуємо ім’я системи – власне, ім’я скрипта з /usr/local/share/debootstrap/scripts/.

Але для Ubuntu остання доступна версія – Jammy, 22.04.

Займе хвилин 10-15, може і більше поки все скачає:

[root@test-free-15-bastille ~]# bastille bootstrap jammy

Attempting to bootstrap Linux/Ubuntu release: Ubuntu_2204

Ensuring Linux compatability...

...

Потім створюємо контейнер з create та опцією --linux (-L):

[root@test-free-15-bastille ~]# bastille create -L openwebui jammy 192.168.0.207/24 em0

...

[openwebui]:
openwebui: created

І маємо контейнер з “Ubuntu”:

[root@test-free-15-bastille ~]# bastille cmd openwebui lsb_release -a

[openwebui]:
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 22.04 LTS

Bastille та Jails management – основні команди

І трохи про основні доступні команди для роботи з контейнерами, див. також приклади у FreeBSD Jails with Bastille – свіжий, 2025 року.

Документація – Bastille sub-commands.

  • clone: скопіювати jail (див. Limitations – є нюанси з інтерфейсами)
  • cmd: виконати команду в jail:
[root@test-free-15-bastille ~]#  bastille cmd testjailNat ps 

[hermesagent1]:
  PID TT  STAT    TIME COMMAND
63825  2  R+J  0:00.00 ps
  • config: отримати чи змінити параметр :
[root@test-free-15-bastille ~]#  bastille config testjailVnetIp get vnet.interface
e0b_bastille1
  • cp: скопіювати файл з хоста в jail
  • destroy: видалити jail та всі його дані
  • export: створити архів в усіма даними jail, потім  import можна відновити на іншому хості
  • mount: підключити файл чи каталог з хоста в контейнер
  • restart: перезапустити jail
  • top, htop: ресурси та процеси в контейнері

Також варто подивитись на bastille monitor – є цікаві можливості з моніторингу і алертингу, та Templates – створення контейнерів з шаблонів які можна взяти з BastilleBSD/templates або створювати власні.

А з bastille zfs – можна створювати ZFS snapshots контейнерів (див. FreeBSD: Home NAS, part 5 – ZFS pool, datasets, snapshots та моніторинг).

Loading

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille
5 (1)

3 Травня 2026

По самому Hermes Agent і його можливостям буду писати окремо, сьогодні – як запустити його на FreeBSD.

Вчора погрався на своєму Arch Linux – тепер хочеться вже більш production setup.

Крутити буду на моєму NAS з FreeBSD, запускати там, звісно, вже тільки у FreeBSD Jail, бо NAS – це доступ до важливих даних і бекапів.

На Linux налаштування агента всі ті самі – тільки простіший сетап, тому окремо описувати не буду.

А от по можливостям Hermes Agent і більш детальний конфіг – зроблю окремий пост, бо там є, що потрогати.

Для роботи з Jails використовую Bastille – про неї теж якось окремо напишу, є чорнетка.

Власне, що будемо робити:

  • створимо FreeBSD Jail
  • налаштуємо мережу
  • встановимо сам Hermes Agent
  • налаштуємо підключення до Telegram
  • і встановимо Hermes Agent Web UI

Поїхали.

Але спочатку трохи оффтопік 🙂

Holywar: FreeBSD Jail чи “контейнер”?

Тут коротко – чи вірно казати “контейнер” про FreeBSD Jail – бо мене можуть заплювати 🙂

Як людина, яка зазвичай працює з Linux, то для мене “контейнер” це і FreeBSD Jail – і Linux Docker, тому в цьому пості буду все ж про Jails казати “контейнери”.

Ба більше – навіть в офіційній документації Bastille говориться:

While reading the documentation and using Bastille, you will find that sometimes “container” is used, and sometimes “jail” is used. These are completely interchangeable, but there is some debate as to which one is more correct. Be that as it may, anytime you read “container” or “jail”, it means a FreeBSD jail.

Крім того, читачі мого блогу в основному теж Linux users – тому нехай вже буде “контейнери”. А в окремому пості по Bastille трохи детальніше поговоримо про Jails у FreeBSD vs Linux containers.

Все – тепер погнали робити установку.

FreeBSD: створення Jail з Bastille

Перевіряємо версію FreeBSD, готуємо контейнер:

root@setevoy-nas:~ # freebsd-version 
14.4-RELEASE-p1

root@setevoy-nas:~ # bastille bootstrap 14.4-RELEASE
...

Створюємо сам контейнер – звичайний FreeBSD (Bastille підтримує і Linux, в мене є Jail з Opeb WebUI, якось про нього допишу, теж чорнетка є).

Мережа – Bastille VNET, тобто контейнер буде доступний із моєї загальної мережі по IP 192.168.0.210:

root@setevoy-nas:~ # bastille create --vnet hermesagent1 14.4-RELEASE 192.168.0.210/24 em0

Attempting to create jail: hermesagent1

Valid IP: 192.168.0.210/24

Creating a thinjail...

...

Перевіряємо статус Jail:

root@setevoy-nas:~ # bastille list hermesagent1
 JID  Name          Boot  Prio  State  Type   IP Address     Published Ports  Release               Tags
 3    hermesagent1  on    99    Up     thin   192.168.0.210  -                14.4-RELEASE          -

Підключаємось всередину:

root@setevoy-nas:~ # bastille console hermesagent1

[hermesagent1]:
root@hermesagent1:~ #

Встановлюємо апдейти:

root@hermesagent1:~ # pkg update

Встановлюємо пакети для установки Hermes Agent:

root@hermesagent1:~ # pkg install curl bash uv sudo

Перевіряємо де у нас bash – бо на FreeBSD він в /usr/local/bin/, а не /usr/bin:

root@hermesagent1:~ # which bash
/usr/local/bin/bash

Створюємо юзера для Hermes Agent, задаємо йому пароль:

root@hermesagent1:~ # pw useradd hermes -m -s /usr/local/bin/bash -c "Hermes Agent"
root@hermesagent1:~ # passwd hermes

Включаємо SSH:

root@hermesagent1:~ # sysrc sshd_enable="YES"
sshd_enable: NO -> YES

root@hermesagent1:~ # service sshd start

Перевіряємо підключення з робочого ноутбука:

[setevoy@setevoy-work ~]  $ ssh [email protected]
([email protected]) Password for hermes@hermesagent1:
...
[hermes@hermesagent1 ~]$

Запускаємо visudo, додаємо юзера туди – із запитом пароля:

hermes ALL=(ALL:ALL) ALL

Аби запускати Hermes CLI від рута – додаємо до /root/.profile:

...
# Hermes Agent — ensure ~/.local/bin is on PATH
export PATH="$HOME/.local/bin:$PATH"

Тут все – можна встановлювати сам агент.

Установка Hermes Agent

Встановлюємо необхідні бібліотеки – бо автоматичний інсталер самого Hermes Agent не дуже дружить з FreeBSD, тому руками:

root@hermesagent1:~ # pkg install -y python3 py311-pip py311-sqlite3 sqlite3 git curl rust pkgconf openssl libffi node22 npm-node22 ripgrep ffmpeg

Запускаємо установку – зайняло хвилин 5:

[hermes@hermesagent1 ~]$ curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash

По завершенню інсталятор запропонує налаштувати агента.

Hermes Agent Setup

На Arch Linux я робив швидку установку, тут запустив повну – подивитись, що там є.

Всі опції можна потім змінити, так що не критично.

Документація – Configuration.

Запускаємо налаштування агенту:

Токенів він жре багато, тому Claude в прольоті – взяв OpenAI та GPT 5.5, працює чудово:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Просить провести аутентифікацію – відкриває посилання на ноутбуці з браузером, вводимо код:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Задаємо модель 5.5 – пізніше можна змінити з /model (див. Slash Commands):

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

І далі вже все можна залишити по дефолту, поки не дійдемо до Messaging.

Telegram setup

Документація – Telegram Setup.

Вибір тут, звісно, великий – я поки буду користуватись Telegram:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Переходимо до @BotFather, створюємо нового бота:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Налаштовуємо його:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Важливо – див. Step 3: Privacy Mode (Critical for Groups).

Переходимо в його Settings:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Відключаємо Group Privacy:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Отримуємо API-токен бота:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Аби ми могли писати боту – знаходимо свій User ID з @userinfobot.

Якщо бот буде в групі чи каналі – в тому ж боті жеж можна знайти їх ID.

Я цього бота роблю для тесту, тому залишаю мого юзера:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

І в “home channel” теж:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Готово:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

 

Далі ще налаштування браузера та Tools – там залишаємо все дефолтними, і готово:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Telegram та Hermes Agent Gateway на FreeBSD

На Linux Hermes Gateway включається просто через systemd – на FreeBSD трохи “ручками” (в лапках – бо зробив це самим агентом 🙂 ).

Перевіряємо статус зараз:

[hermes@hermesagent1 ~]$ hermes gateway status
✗ Gateway is not running

To start:
  hermes gateway run      # Run in foreground
  hermes gateway install  # Install as user service
  sudo hermes gateway install --system  # Install as boot-time system service

Команда hermes gateway install на FreeBSD очікувано повернула “not supported on this platform“:

[root@hermesagent1 /usr/home/hermes]# /home/hermes/.hermes/hermes-agent/venv/bin/hermes gateway install --system
Service installation not supported on this platform.
Run manually: hermes gateway run

Перевіряємо де саме лежить Hermes:

[hermes@hermesagent1 ~]$ head -1 "$(command -v hermes)"
#!/home/hermes/.hermes/hermes-agent/venv/bin/python3

Встановлюємо Python-модуль python-telegram-bot:

[hermes@hermesagent1 ~]$ /home/hermes/.hermes/hermes-agent/venv/bin/python3 -m pip install python-telegram-bot
Collecting python-telegram-bot
  Downloading python_telegram_bot-22.7-py3-none-any.whl.metadata (17 kB)
...

Пробуємо запустити руками:

hermes@hermesagent1 ~]$ hermes gateway run
┌─────────────────────────────────────────────────────────┐
│           ⚕ Hermes Gateway Starting...                 │
├─────────────────────────────────────────────────────────┤
│  Messaging platforms + cron scheduler                    │
│  Press Ctrl+C to stop                                   │
└─────────────────────────────────────────────────────────┘

...

І пишемо боту в Telegram:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Hermes Agent Gateway autostart на FreeBSD

Ну і давайте спробуємо – чи справиться агент з задачею “я лінивий інженер, сдєлай мені харашо” – нехай сам скаже, як його гейтвей додати в автостарт на FreeBSD:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Окей.

Але я настільки лінивий, що не хочу займатись copy-paste – нехай робить все сам.

Ми в Jail – тому це безпечно:

Для створення rc.d скрипта йому потрібен root – питає пароль, бо sudo у нас тут парольний:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Скрипт готовий:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Сам скрипт, який він написав – /usr/local/etc/rc.d/hermes_gateway:

#!/bin/sh

# PROVIDE: hermes_gateway
# REQUIRE: LOGIN NETWORKING
# KEYWORD: shutdown

. /etc/rc.subr

name="hermes_gateway"
rcvar="hermes_gateway_enable"

load_rc_config "$name"

: ${hermes_gateway_enable:="NO"}
: ${hermes_gateway_user:="hermes"}
: ${hermes_gateway_home:="/home/hermes"}
: ${hermes_gateway_command:="/home/hermes/.local/bin/hermes"}
: ${hermes_gateway_log:="/var/log/hermes_gateway.log"}

pidfile="/var/run/${name}.pid"
command="/usr/sbin/daemon"
command_args="-f -p ${pidfile} -u ${hermes_gateway_user} -o ${hermes_gateway_log} /usr/bin/env HOME=${hermes_gateway_home} ${hermes_gateway_command} gateway run"

start_cmd="${name}_start"
stop_cmd="${name}_stop"
status_cmd="${name}_status"

hermes_gateway_start()
{
    if [ ! -x "${hermes_gateway_command}" ]; then
        echo "Hermes executable not found or not executable: ${hermes_gateway_command}"
        return 1
    fi

    touch "${hermes_gateway_log}"
    chown "${hermes_gateway_user}" "${hermes_gateway_log}" 2>/dev/null || true

    echo "Starting Hermes gateway."
    ${command} ${command_args}
}

hermes_gateway_stop()
{
    echo "Stopping Hermes gateway."
    if [ -f "${pidfile}" ]; then
        kill "$(cat ${pidfile})" 2>/dev/null || true
        rm -f "${pidfile}"
    else
        pkill -u "${hermes_gateway_user}" -f "${hermes_gateway_command} gateway run" 2>/dev/null || true
    fi
}

hermes_gateway_status()
{
    if [ -f "${pidfile}" ] && kill -0 "$(cat ${pidfile})" 2>/dev/null; then
        echo "Hermes gateway is running as pid $(cat ${pidfile})."
        return 0
    fi

    if pgrep -u "${hermes_gateway_user}" -f "${hermes_gateway_command} gateway run" >/dev/null 2>&1; then
        echo "Hermes gateway is running, but pidfile is missing/stale."
        return 0
    fi

    echo "Hermes gateway is not running."
    return 1
}

run_rc_command "$1"

Перевіряємо, що в /etc/rc.conf все додано:

root@hermesagent1:~ # cat /etc/rc.conf | grep hermes
hermes_gateway_enable="YES"
hermes_gateway_user="hermes"
hermes_gateway_home="/home/hermes"
hermes_gateway_command="/home/hermes/.local/bin/hermes"
hermes_gateway_log="/var/log/hermes_gateway.log"

Зупиняємо “hermes gateway run“, яку запускали руками вище, і пробуємо запустити вже через сервіс:

[hermes@hermesagent1 ~]$ sudo service hermes_gateway start
Starting Hermes gateway.
[hermes@hermesagent1 ~]$ sudo service hermes_gateway status
Hermes gateway is running as pid 60901.

Hermes Agent та Web UI

Нагуглив кілька рішень, собі поки взяв nesquena/hermes-webui, але ще можна глянути на EKKOLearnAI/hermes-web-ui.

Клонуємо репозиторій:

[hermes@hermesagent1 ~]$ git clone https://github.com/nesquena/hermes-webui.git hermes-webui
[hermes@hermesagent1 ~]$ cd hermes-webui/

Аби мати доступ до WebUI з мережі – задаємо $HERMES_WEBUI_HOST:

[hermes@hermesagent1 ~/hermes-webui]$ export HERMES_WEBUI_HOST=0.0.0.0

Запускаємо сервіс:

[hermes@hermesagent1 ~/hermes-webui]$ python3 bootstrap.py 
[bootstrap] Starting Hermes Web UI on http://0.0.0.0:8787
[bootstrap] Web UI is ready: http://0.0.0.0:8787
[bootstrap] Log file: /home/hermes/.hermes/webui/bootstrap-8787.log

Пробуємо в браузері:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

В Telegram просимо створити якесь нагадування:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

І бачимо його в Tasks:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Власне – на цьому і все.

Але ще приклад того, що можна робити з агентом.

Hermes Agent Use Case example: створення документації в DokuWiki

Попросив пройтись по всім моїм мережам і знайти хости – запустив nmap, все просканував, зібрав в табличку:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Потім я створив для агента окремого юзера в моєму локальному DokuWiki, включив XML-RPC у файлі /usr/local/www/dokuwiki/conf/local.php:

$conf['remote'] = 1;
$conf['remoteuser'] = 'hermes-agent';

Попросив агента – і Hermes створив мені сторінку з документацією по всім моїм хостам:

Hermes Agent: запуск AI Agent у FreeBSD Jail з Bastille

Перевіряв роботу з VictoriaMetrics – чудово додає метрики навіть без VictoriaMetrics Skills (див. Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetrics), тому можна буде зробити автоматизацію типу “Alermanger webhook > Hermes > investigate > send result to Telegram”.

Короче – система цікава, класна – можна буде робити цікаві штуки.

Loading

Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetrics
5 (5)

30 Квітня 2026

Поки пишеться серія постів по налаштуванню і використанню Claude Code – запишу приклад створення власного AI Agent для VictoriaMetrics та Kubernetes і “загортання” його в Claude Code Plugin та створення власного Claude Code Markeplace, де будуть жити подібні плагіни для девелоперів на моєму проекті.

Загальна ідея: мати агента, якого девелопери можуть підключити собі до Claude Code (а у нас 95% проекту користуються ним), і з яким зможуть запитати “якого біса впав той Kubernetes Pod

І не тільки девелопери – я, коли тестив цього агента, відкрив для себе причину постійних рестартів Grafana – тому агент буде корисний і мені самому.

Є, звісно, проекти типу kubectl-ai або навіть robusta.dev – але ми побудуємо власного агента з маркетплейсом і скілами.

Що будемо робити:

  • агент буде використовувати офіційні скіли від VictoriaMetrics – для самої VictoriaMetrics, VictoriaLogs та Alertmanager
  • напишемо власний SKILL.md, в кому буде описаний flow перевірки стану Kubernetes Pods – цей скіл буде включений в плагін і потім його можна буде використовувати з новими агентами
  • для зручного підключення агента з усіма його скілами запакуємо весь проект в плагін для Claude Code, який буде зберігатись в проектному GitHub

Сам агент, описаний тут, більше PoC в плані його інструкцій, і по ходу діла буде тюнитись і допилюватись – але загальна конструкція створення маркеплейсу, побудові агента і плагіну для Claude Code залишиться такою, як показано в цьому пості.

Загальна структура і план

Ключові концепти про які варто знати наперед:

  • Marketplace: це git-репозиторій з одним або кількома плагінами, який девелопер додає до свого інстансу Claude Code одною командою.
  • Plugin: можемо “запакувати” всі Agents, Skills, Commands, MCP servers в єдиний пакет, який теж встановлюється до Claude Code одною командою
  • Agent: субагент Claude Code з власним system prompt і permissions, викликається з основної сесії через Agent tool – працює в ізольованому контексті, виконує задачу, повертає результат до головного “оркестратора”, в ролі якого в нашому випадку буде Claude Code девелопера (чи мій)
  • Skill: “інструкція”, яку агент читає при потребі і яка описує деталі виконання задачі

В результаті в репозиторії atlas-claude-plugins отримаємо таку структуру каталогів і файлів:

$ tree -a -I .git atlas-claude-plugins/
atlas-claude-plugins/
├── .claude
│   └── settings.json
├── .claude-plugin
│   └── marketplace.json
├── .gitignore
├── CLAUDE.md
├── README.md
├── plugins
│   └── k8s-tools
│       ├── .claude-plugin
│       │   └── plugin.json
│       ├── agents
│       │   └── k8s-pod-debugger.md
│       └── skills
│           └── k8s-troubleshooting-flow
│               └── SKILL.md
└── scripts
    └── bootstrap.sh

MCP vs Skills: чому скіли

Перша версія агента була з MCP. Насправді взагалі ця ідея з’явилась як раз під час написання чорнетки поста про Claude Code та підключення MCP – там описана робота з MCP на прикладі офіційних MCP від VictoriaMetrics (див. mcp-victoriametrics) та Kubernetes.

І тому, коли почав робити вже цього агента, то спочатку додав MCP, але потім стало питання – як девелоперам їх встановлювати? Тоді згадав, що у команди VictoriaMetrics є і набір готових skills: то, може, просто взяти їх – а не тягнути якісь бінарніки? І нехай собі Claude Code через Bash tool використовує curl та робить запити напряму до API.

Бо насправді – в чому різниця між MCP та Skill?

MCP (Model Context Protocol) – дає типізовані tools з чітко визначеними командами: наприклад, для VictoriaMetrics є офіційний mcp-victoriametrics сервер, в якому є Tools типу query(query: string, time: timestamp). Це виглядає круто – бо є детермінізм, визначеність, чітка структура – агент не може викликати функцію з неправильними параметрами, схема валідується.

Але! При використанні MCP функції – наприклад, query, функція приймає аргумент string – і агент все одно сам пише MetricsQL запит. Тобто, MCP визначає тільки як виконати запит – але не саму структуру запиту, і запит все одно LLM будує сама.

Власне, те саме і з kubectl – чи ми використовуємо MCP сервер для нього, чи ми робимо Bash(kubectl get pod …) – результат однаковий: агент/LLM все одно самі визначають запит і фільтри.

Зато в скілах ми можемо описати інше – “Щоб перевірити логи Pod-у в нашому кластері, використовуй такий pattern: _stream:{namespace=’X’}“. Це знання для агенту, яке MCP не передає.

Плюс є практичний момент:

  • використання офіційного marketplace VictoriaMetrics зі скілами victoriametrics-query, victorialogs-query, alertmanager-query – вони підтримуються командою VictoriaMetrics, оновлюються при змінах в MetricsQL/LogsQL
  • не треба тягнути зайві бінарні файли в систему юзера (девелопера) – простіше підключення та налаштування (хоча bootstrap.sh все одно треба додати, да і скіли VictoriaMetrics підключити)

Тому вирішив робити чисто “Bash + curl + власний скіл з нашим контекстом + офіційні VictoriaMetrics скіли” – без MCP взагалі.

Окремо момент з Kubernetes MCP та Skills: тут сама логіка – всі LLM чудово знають синтаксис kubectl, тому великого сенсу в додаванні MCP не бачу.

Можна було б додати якийсь готовий скіл, як це зроблено для VictoriaMetrics, наприклад LukasNiessen/kubernetes-skill – але цей скіл більше про те, як деплоїти – а ми будемо робити read-only агента, який буде дебажити, а не деплоїти – зовсім інший use case. Тому якісь деталі по нашому конкретному сетапу (типу namespace convention) можна просто додати в SKILL.md самого плагіну – а LLM вже сама розбереться як зробити kubectl get events.

Поїхали.

Створення Marketplace

Почнемо з бази – маркетплейсу, де буде цей агент та, в майбутньому, інші, і в ньому ж створимо вже структуру для плагіну.

Документація – Create and distribute a plugin marketplace.

У нас буде:

  • Marketplace: це сам GitHub репозиторій atlas-claude-plugins
    • Plugin: k8s-tools
      • Agent: k8s-pod-debugger
      • Skill: k8s-troubleshooting-flow

Marketplace: файл .claude-plugin/marketplace.json

В корні репозиторію створюємо каталог .claude-plugin/, в ньому файл marketplace.json – він описує сам маркетплейс та плагіни в ньому:

{
    "name": "atlas-claude-plugins",
    "owner": {
      "name": "Org Engineering",
      "url": "https://github.com/Org-Engineering"
    },
    "metadata": {
      "description": "Org DevOps team Claude Code plugins for Kubernetes operations and debugging",
      "version": "0.1.0"
    },
    "plugins": [
      {
        "name": "k8s-tools",
        "source": "./plugins/k8s-tools",
        "description": "Kubernetes operations toolkit - read-only debugging agents and skills with VictoriaLogs, VictoriaMetrics, and Alertmanager integration",
        "version": "0.1.0",
        "category": "devops",
        "tags": ["kubernetes", "debugging", "observability", "victoriametrics", "victorialogs", "alertmanager"]
      }
    ]
  }

Тут:

  • name: "atlas-claude-plugins": задає ім’я, з яким marketplace буде встановлюватись до Claude Code з командою /plugin marketplace add your-org/atlas-claude-plugins
  • version: задається як на рівні marketplace (збільшуємо, коли міняється список плагінів) – так і на рівні кожного плагіну (збільшуємо, коли міняється сам плагін)
  • plugins[].source: масив, в якому описується список плагінів цього маркетплейсу, для кожного плагіну задаємо відносний шлях в репозиторії – з префіксом ./

Плагін: файл plugins/k8s-tools/.claude-plugin/plugin.json

Далі описуємо сам плагін – каталог plugins/k8s-tools/, як задано в plugins[].source файлу marketplace.json вище.

В plugins/k8s-tools/ створюємо каталог .claude-plugin/, а в ньому файл plugin.json:

{
    "name": "k8s-tools",
    "version": "0.1.0",
    "description": "Kubernetes operations toolkit - read-only debugging agents and skills for VictoriaLogs, VictoriaMetrics, and Alertmanager",
    "author": {
      "name": "Org Engineering",
      "url": "https://github.com/Org-Engineering"
    }
  }

Тут визначаємо що взагалі за плагін та його версію. Версія має співпадати з plugins[].version в marketplace.json.

Файл .claude/settings.json

Це файл який дає одну дуже приємну фічу для Claude Code – extraKnownMarketplaces: коли девелопер клонує репо і відкриває його в Claude Code – той автоматично пропонує встановити marketplace. Без “ручного читання” README.md, без ручного запуску команд.

Він не являє собою обов’язкову частину Claude Code Marketplace – це просто фішка, якою ми спростимо собі і девелоперам життя при запуску Claude Code.

Крім того, ми тут додаємо і наш власний маркетплейс – і маркетплейс від VictoriaMetrics: одним “yes” девелопер встановить обидва:

{
    "extraKnownMarketplaces": {
      "atlas-claude-plugins": {
        "source": {
          "source": "github",
          "repo": "Org-Engineering/atlas-claude-plugins"
        }
      },
      "victoriametrics-tools": {
        "source": {
          "source": "github",
          "repo": "VictoriaMetrics/skills"
        }
      }
    }
  }

Файл .gitignore

Відразу визначаємо дані, які нам в репозиторії не треба – бо всякі .claude/sessions/ це вже локальні дані девелопера, вони в репозиторії не потрібні. Аналогічно з файлами whatever.local.json – це локальні overwrides, які я чи девелопер може додати чисто для себе, в плагіні їх ігноруємо. Див. Available scopes, і про них буду писати окремо вже в постах по самому Claude Code:

# OS
.DS_Store
Thumbs.db

# Editors
.vscode/
.idea/
*.swp
*~

# Claude Code per-user state and overrides (never share)
.claude/local/
.claude/conversations/
.claude/sessions/
.claude/cache/
.claude/settings.local.json
.claude/*.local.json

# Local env files
.env
.env.local

Тепер маємо таку структуру:

$ tree -a -I '.git'
.claude
└── settings.json
.claude-plugin
└── marketplace.json
plugins
└── k8s-tools
    ├── .claude-plugin
    │   └── plugin.json

Тут:

  • .claude: конфіг для інстансу Claude Code на робочих машинках
  • .claude-plugin: метадані самого маркеплейсу
  • plugins/k8s-tools/.claude-plugin: метадані плагіну k8s-tools

Переходимо до самого агента.

Створення AI Agent

Давайте ще раз визначимо – що таке “AI Agent” взагалі: це окрема “сутність”, яку Claude Code може запустити для виконання якоїсь конкретної задачі.

У агента окремий system prompt, окремий контекст – його задача “зробити щось”, і повернути результат до головного інстансу Claude Code. Таким чином ми не забиваємо зайвими даними контекст самого Claude Code – він отримує тільки ті дані, які йому треба для виконання основної задачі.

Крім того, наприклад, окремому агенту можна задати іншу, більш дешеву модель – тоді при задачах типу парсінгу логів економимо гроші, бо логів багато – токенів жре багато. Дешева моделька вибирає тільки основні патерни, дані – і повертає їх до самого Claude Code, а та вже з дорогою моделлю типу Opus 4.7 (остання на сьогодні) виконує детальний аналіз.

Profit!

Файл plugins/k8s-tools/agents/k8s-pod-debugger.md

Для агенту нам треба створити файл, який буде описувати метадані агента та задавати його system prompt.

Файл розбитий на дві основні частини:

  • YAML frontmatter: блок на початку файла markdown, де між двома “---” задаємо ім’я, Description, Tools, Permissions
  • System Prompt: а вже в body файлу – описуємо агенту що і як він має робити, тут жеж можемо додати якісь деталі по конкретно нашому Kubernetes-кластеру чи зв’язкам workloads – як їх дебажити

Ім’я файлу k8s-pod-debugger.md має співпадати з полем name в frontmatter – інакше Claude Code не звʼяже їх.

Frontmatter: поля Name та Description

На початку файлу визначаємо ім’я, задаємо опис агента – це загальні метадані агента.

Тут зміст частинами, потім весь файл.

Опис використовується тільки Claude Code – не людиною, девелопером:

---
name: k8s-pod-debugger
description: Use this agent to investigate Kubernetes Pod, Namespace, or Workload issues. Performs read-only diagnostics across resource state, events, logs, metrics, and alerts. Invoke when user asks to debug a Pod, check why a Pod is failing (CrashLoopBackOff, OOMKilled, Pending, ImagePullBackOff), investigate problems in a Namespace, or troubleshoot a Deployment/StatefulSet/DaemonSet/Job that is not behaving correctly.

...

Frontmatter: поля Permissions та Tools

Далі визначаємо які Claude Code Tools агент може використовувати – а що йому явно заборонено.

У нас read-only agent, ніяких kubectl delete pod він робити не повинен – тому явно це визначаємо:

tools:
  - Bash
  - Read
  - Grep
allowed-tools:
  - Bash(kubectl get *)
  - Bash(kubectl describe *)
  - Bash(kubectl logs *)
  ...
  - Bash(curl --silent --get *)
  - Bash(jq *)
  - Bash(cat ~/.config/atlas/env)
deny-tools:
  - Bash(kubectl delete *)
  - Bash(kubectl apply *)
  - Bash(kubectl exec *)
  ...
  - Bash(*curl* -X *)
  - Bash(*curl* --request *)
  - Bash(*curl* -d *)
  ...
  - Bash(rm *)
  - Bash(mv *)
---

Permissions, deny-tools та Least Privilege Principle

Тут трохи зупинюсь на деталях deny-tools, бо важлива частина.

Використовуємо принцип least privilege – мінімально потрібні для роботи агента доступи.

Наприклад, curl без обмежень може видалити time series в VictoriaMetrics через POST на admin endpoint

$ curl -X POST https://victoriametrics.internal/api/v1/admin/tsdb/delete_series?match[]={namespace=”prod”}

Тому ріжемо їх, дозволяємо тільки GET.

Для bash – блокуємо різні pipe-операції – виконати curl … | bash агент не зможе.

Блокуємо різні redirect output – >, >> – не даємо писати в файли через bash.

Виконання команд rm, mv, cp – все це теж в denied.

Те саме для kubectl – явно забороняємо дії типу kubectl delete чи kubectl exec.

Body: Agent’s System Promt

І головна частина файлу – System Promt агента: що і як він має виконувати при дебагу:

...

# Kubernetes Pod Debugger

You are a read-only Kubernetes troubleshooting agent. Your job is to investigate issues with Pods, Namespaces, and Workloads (Deployments, StatefulSets, DaemonSets, Jobs) and report findings clearly. You have access to the cluster via kubectl, and to observability data via VictoriaLogs, VictoriaMetrics, and Alertmanager.

You are NOT responsible for Node issues, networking deep-dives (Service/Ingress connectivity), or Storage (PV/PVC) troubleshooting. If the user asks about those, advise that a different agent is needed and stop.

## Your boundaries

You are strictly read-only. You investigate, observe, and report. You do not modify anything in the cluster or any external system. The user's permissions enforce this, but you must also respect this boundary in your reasoning - never propose write actions as part of your investigation.
...

## Available tools and data sources

**kubectl** - read-only commands only (`get`, `describe`, `logs`, `top`, `events`).

**Observability via VictoriaMetrics skills.** This plugin relies on the official VictoriaMetrics `query` plugin from the `victoriametrics-tools` marketplace, which provides:
- `victoriametrics-query` skill - for metrics queries (PromQL/MetricsQL)
- `victorialogs-query` skill - for log searches (LogsQL)
- `alertmanager-query` skill - for active and silenced alerts
...

## Our environment specifics

This is essential context for query construction. Always apply these when invoking VM skills:

**VictoriaMetrics labels** (standard prometheus-operator stack):
- `cluster` - REQUIRED in all queries (e.g. `kube_pod_status_phase{cluster="prod-1",namespace="..."}`)
...

**VictoriaLogs streams** (promtail-based collection):
- Stream label: only `namespace` is indexed at stream level
...

## How to investigate

The investigation flow depends on the entry point. Identify which type of request you got and follow the matching flow.

### Entry point A: Single Pod

Triggered by requests like "debug pod X", "why is X failing", "what's wrong with X in namespace Y".

1. **Establish context**
   - `kubectl config current-context` (also use as `CLUSTER` for metrics)
   - If Namespace not specified, get current default: `kubectl config view --minify -o jsonpath='{..namespace}'`
   - Verify Pod exists: `kubectl get pod <name> -n <ns>`

2. **Check Pod state**
   - `kubectl get pod <name> -n <ns> -o wide` - status, Node, IP, restart count
   - `kubectl describe pod <name> -n <ns>` - full event history, conditions, container statuses, last termination reason
   - For multi-container Pods, identify all containers: `kubectl get pod <name> -n <ns> -o jsonpath='{.spec.containers[*].name}'`
...

## How to report findings

Structure your final report in clear sections. Be concrete - include actual values, error messages, timestamps. Avoid filler.

Use this template, adapted to the entry point:

- **Subject** - what you investigated (Pod name / Namespace / Workload kind+name)
...
- **Recommended actions** - read-only or human-driven next steps (you cannot execute writes)

For Namespace overview reports, structure findings as a prioritized list of issues, with a brief sub-report per top issue.

## Important rules

- **Never invent data.** If a command fails or returns nothing, report that explicitly. Do not fabricate values.
- **Cite your evidence.** Every claim in your conclusion must reference a specific kubectl output, log line, metric value, or alert
...
- **Out of scope:** Node issues, networking (Service/Ingress connectivity), Storage (PV/PVC). If the request is purely about these, say so and stop - a different agent should handle them.

..

Тут:

  • Kubernetes Pod Debugger: описуємо агенту – хто він такий і що робить взагалі
  • Your boundaries: ще раз вказуємо, що він read-only
  • Available tools and data sources: які утиліти і як він має використовувати – вказуємо, що є окремі скіли від VictoriaMetrics, аби він їх підключав
  • Our environment specifics: деталі, специфічні до нашого конкретного сетапу, labels в метриках чи streams в логах
  • How to investigate: описуємо процес пошуку проблем – як підключитись до Kubernetes, перевірити стан Kubernetes Pod, пов’язані events, etc
  • How to report findings: описуємо формат, в якому агент має повернути результати
  • Important rules: і трохи причісуємо поведінку – “не вигадуй, якщо щось пішло не так“, не лізти в зайві дані, і так далі

Окремо агенту явно вказуємо, що у нас є Skill k8s-troubleshooting-flow, в якому описані деталі виконання запитів – про нього далі.

Весь файл k8s-pod-debugger.md

В результаті маємо такий зміст:

---
name: k8s-pod-debugger
description: Use this agent to investigate Kubernetes Pod, Namespace, or Workload issues. Performs read-only diagnostics across resource state, events, logs, metrics, and alerts. Invoke when user asks to debug a Pod, check why a Pod is failing (CrashLoopBackOff, OOMKilled, Pending, ImagePullBackOff), investigate problems in a Namespace, or troubleshoot a Deployment/StatefulSet/DaemonSet/Job that is not behaving correctly.
tools:
  - Bash
  - Read
  - Grep
allowed-tools:
  - Bash(kubectl get *)
  - Bash(kubectl describe *)
  - Bash(kubectl logs *)
  - Bash(kubectl top *)
  - Bash(kubectl events *)
  - Bash(kubectl version)
  - Bash(kubectl config view *)
  - Bash(kubectl config current-context)
  - Bash(kubectl auth can-i *)
  - Bash(curl -s -G *)
  - Bash(curl -sG *)
  - Bash(curl --silent --get *)
  - Bash(jq *)
  - Bash(source ~/.config/atlas/env)
  - Bash(cat ~/.config/atlas/env)
deny-tools:
  - Bash(kubectl delete *)
  - Bash(kubectl apply *)
  - Bash(kubectl exec *)
  - Bash(kubectl edit *)
  - Bash(kubectl patch *)
  - Bash(kubectl scale *)
  - Bash(kubectl rollout *)
  - Bash(kubectl cp *)
  - Bash(kubectl port-forward *)
  - Bash(kubectl create *)
  - Bash(kubectl replace *)
  - Bash(kubectl annotate *)
  - Bash(kubectl label *)
  - Bash(kubectl drain *)
  - Bash(kubectl cordon *)
  - Bash(kubectl uncordon *)
  - Bash(kubectl taint *)
  - Bash(*curl* -X *)
  - Bash(*curl* --request *)
  - Bash(*curl* -d *)
  - Bash(*curl* --data*)
  - Bash(*curl* --upload-file *)
  - Bash(*curl* -T *)
  - Bash(*curl* -o *)
  - Bash(*curl* --output *)
  - Bash(*|*sh*)
  - Bash(*|*bash*)
  - Bash(*>*)
  - Bash(*>>*)
  - Bash(rm *)
  - Bash(mv *)
  - Bash(cp *)
  - Bash(chmod *)
  - Bash(chown *)
---

# Kubernetes Pod Debugger

You are a read-only Kubernetes troubleshooting agent. Your job is to investigate issues with Pods, Namespaces, and Workloads (Deployments, StatefulSets, DaemonSets, Jobs) and report findings clearly. You have access to the cluster via kubectl, and to observability data via VictoriaLogs, VictoriaMetrics, and Alertmanager.

You are NOT responsible for Node issues, networking deep-dives (Service/Ingress connectivity), or Storage (PV/PVC) troubleshooting. If the user asks about those, advise that a different agent is needed and stop.

## Your boundaries

You are strictly read-only. You investigate, observe, and report. You do not modify anything in the cluster or any external system. The user's permissions enforce this, but you must also respect this boundary in your reasoning - never propose write actions as part of your investigation.

For HTTP requests, use only `curl -sG` or `curl --silent --get`. Never use `-X`, `-d`, `--data`, or any non-GET method. If you find a problem that needs a fix, describe it as a recommendation in your final report - do not attempt to execute it.

## Available tools and data sources

**kubectl** - read-only commands only (`get`, `describe`, `logs`, `top`, `events`).

**Local skill: `k8s-troubleshooting-flow`** - this plugin includes a skill with our environment-specific knowledge: VictoriaLogs stream label schema, VictoriaMetrics label conventions (including the required `cluster` label), MetricsQL/LogsQL query templates for common Pod failure modes, and correlation patterns linking kubectl observations to metrics/logs/alerts. **Read this skill at the start of any non-trivial investigation** - it tells you which queries to construct for the situation at hand.

**External skills via VictoriaMetrics `query` plugin** - this plugin relies on the official `victoriametrics-tools` marketplace, which provides:
- `victoriametrics-query` skill - executes metrics queries (PromQL/MetricsQL) via curl
- `victorialogs-query` skill - executes log searches (LogsQL) via curl
- `alertmanager-query` skill - queries active and silenced alerts via curl

These skills handle curl mechanics, pagination, and result parsing. The flow is: read `k8s-troubleshooting-flow` to learn WHAT to query, then invoke the appropriate VM skill to actually run the query.

**Environment variables** - the VM skills require these to be set in the user's shell:
- `VM_METRICS_URL` - VictoriaMetrics endpoint
- `VM_LOGS_URL` - VictoriaLogs endpoint
- `VM_ALERTMANAGER_URL` - Alertmanager endpoint

These are configured by the bootstrap script (`scripts/bootstrap.sh` in the atlas-claude-plugins repo) and stored in `~/.config/atlas/env`. If commands fail because vars are missing, instruct the user to run the bootstrap script.

## How to investigate

The investigation flow depends on the entry point. Identify which type of request you got and follow the matching flow.

Before starting any flow, **read the `k8s-troubleshooting-flow` skill** to refresh your memory on:
- Our VictoriaLogs stream schema (so you build correct LogsQL)
- Our VictoriaMetrics label conventions (so you build correct MetricsQL with required `cluster` label)
- The correlation patterns matching the failure mode you're investigating

### Entry point A: Single Pod

Triggered by requests like "debug pod X", "why is X failing", "what's wrong with X in namespace Y".

1. **Establish context**
   - `kubectl config current-context`
   - If Namespace not specified, get current default: `kubectl config view --minify -o jsonpath='{..namespace}'`
   - Verify Pod exists: `kubectl get pod <name> -n <ns>`

2. **Check Pod state**
   - `kubectl get pod <name> -n <ns> -o wide` - status, Node, IP, restart count
   - `kubectl describe pod <name> -n <ns>` - full event history, conditions, container statuses, last termination reason
   - For multi-container Pods, identify all containers: `kubectl get pod <name> -n <ns> -o jsonpath='{.spec.containers[*].name}'`

   Pay attention to:
   - `Status`, `Reason`, `Message` fields
   - Container `State` and `Last State` (with reason: `CrashLoopBackOff`, `OOMKilled`, `Error`, `ImagePullBackOff`)
   - `Events` section - especially Warning events
   - Resource requests and limits vs actual usage

3. **Identify failure mode and consult skill** - based on the kubectl output above, identify which failure mode this matches (CrashLoopBackOff, OOMKilled, ImagePullBackOff, Pending, Ready=False but Running). Open the `k8s-troubleshooting-flow` skill and follow the correlation pattern matching that mode - it tells you exactly which metrics, logs, and alerts to check next.

4. **Check related events**
   - `kubectl events -n <ns> --for=pod/<name>` (newer kubectl versions)
   - Fallback: `kubectl get events -n <ns> --field-selector involvedObject.name=<name>`

5. **Check logs (recent, from kubectl)**
   - Current container: `kubectl logs <name> -n <ns> --tail=200`
   - Previous container if restarted: `kubectl logs <name> -n <ns> --previous --tail=200`
   - For multi-container Pods, iterate per container with `-c <container>`

6. **Check metrics** - use `victoriametrics-query` skill with MetricsQL templates from `k8s-troubleshooting-flow`. Always include the `cluster` label as documented in the skill.

7. **Check deeper logs (longer time window)** - use `victorialogs-query` skill with LogsQL stream patterns from `k8s-troubleshooting-flow`. Default time window: last 1 hour. Expand if not enough.

8. **Check related alerts** - use `alertmanager-query` skill. Filter by matching `namespace` and `pod` labels. Include both firing and recently resolved (last 1 hour) to catch flapping issues.

### Entry point B: Namespace overview

Triggered by requests like "what's wrong in namespace X", "check namespace X", "is anything broken in X".

1. **Establish context** - confirm cluster context, verify Namespace exists.

2. **Find unhealthy resources**
   - `kubectl get pods -n <ns>` - look for any non-Running, non-Completed Pods
   - `kubectl get pods -n <ns> --field-selector=status.phase!=Running,status.phase!=Succeeded`
   - `kubectl get deployments,statefulsets,daemonsets -n <ns>` - check ready/available counts mismatch
   - `kubectl get events -n <ns> --sort-by='.lastTimestamp' | tail -50` - recent Warning events

3. **Triage** - rank issues by severity:
   - Pods stuck in CrashLoopBackOff, ImagePullBackOff, Error - highest priority
   - Pods Pending - check if scheduling, image pull, or resource issue
   - Workloads with replicas mismatch (e.g. Deployment wants 3, has 2 ready)
   - Recent Warning events (OOM, FailedMount, FailedScheduling)

4. **Deep-dive on top issues** - for each priority Pod/Workload, switch to entry point A or C respectively. Limit to top 3-5 issues to keep report manageable. Use `k8s-troubleshooting-flow` correlation patterns for each.

5. **Check Namespace-level alerts** - use `alertmanager-query` skill, filter by `namespace="..."` label.

### Entry point C: Workload (Deployment / StatefulSet / DaemonSet / Job)

Triggered by requests like "why is deployment X not updating", "statefulset Y has issues", "job Z keeps failing".

1. **Establish context** - confirm cluster context, Namespace.

2. **Check Workload state**
   - `kubectl get <kind>/<name> -n <ns> -o wide`
   - `kubectl describe <kind>/<name> -n <ns>` - replicas, conditions, events, rollout status
   - For Deployments: `kubectl rollout history deployment/<name> -n <ns>` (read-only)
   - For Jobs: check `.status.conditions` and `.status.failed`/`.status.succeeded`

3. **Check Pods owned by Workload**
   - `kubectl get pods -n <ns> -l <workload-selector>` (selector from describe output)
   - Identify Pods in bad states - then for each, follow entry point A flow (including consulting `k8s-troubleshooting-flow` per failure mode)

4. **Check ReplicaSet/ControllerRevision history** for Deployments and StatefulSets - sometimes the issue is the new revision is broken.

5. **Check Workload-level metrics and alerts** - MetricsQL templates for Workload state are in the `k8s-troubleshooting-flow` skill (Deployment/StatefulSet/DaemonSet/Job replica metrics).

## How to report findings

Structure your final report in clear sections. Be concrete - include actual values, error messages, timestamps. Avoid filler.

Use this template, adapted to the entry point:

- **Subject** - what you investigated (Pod name / Namespace / Workload kind+name)
- **Status** - one-line summary: healthy / failing / partially failing / pending / etc
- **Key Findings** - bulleted list of specific observations with data
- **Events** - recent significant events with timestamps
- **Logs** - relevant log excerpts with line numbers/timestamps
- **Metrics** - resource usage observations, anomalies
- **Alerts** - firing alerts related to the subject, or "none"
- **Conclusion** - likely root cause based on evidence above
- **Recommended actions** - read-only or human-driven next steps (you cannot execute writes)

For Namespace overview reports, structure findings as a prioritized list of issues, with a brief sub-report per top issue.

## Important rules

- **Never invent data.** If a command fails or returns nothing, report that explicitly. Do not fabricate values.
- **Cite your evidence.** Every claim in your conclusion must reference a specific kubectl output, log line, metric value, or alert.
- **Stay focused.** Investigate what was asked. Do not wander into unrelated cluster issues.
- **Time-box log scans.** Default to last 200 lines or last 1 hour. Expand only if initial scan is insufficient.
- **Limit Namespace deep-dives.** When investigating a Namespace, do not deep-dive every problem - pick top 3-5 by severity.
- **Always consult `k8s-troubleshooting-flow` for query construction.** Do not invent LogsQL stream filters or MetricsQL label selectors from memory - the skill has the correct schema for our environment.
- **Always include `cluster` label** in MetricsQL queries against our VictoriaMetrics - all metrics are labeled with it (see skill for details).
- **Respect read-only boundary.** If you find a problem that needs a fix (e.g. wrong env var, missing Secret, bad image tag, wrong replica count), describe the fix as a recommendation. Do not attempt to apply it.
- **Out of scope:** Node issues, networking (Service/Ingress connectivity), Storage (PV/PVC). If the request is purely about these, say so and stop - a different agent should handle them.

Тепер в плагіні у нас така структура:

$ tree -a plugins/
plugins/
└── k8s-tools
    ├── .claude-plugin
    │   └── plugin.json
    ├── agents
    │   └── k8s-pod-debugger.md

Skill: k8s-troubleshooting-flow

Файл агента plugins/k8s-tools/agents/k8s-pod-debugger.md описує самого агента – що і як він має робити, які утиліти йому доступні.

На додачу до нього – створимо в плагіні окремий Skill, який описує деталі виконання запитів до VictoriaLogs, приклади запитів MetricsQL до VictoriaMetrics, які проблеми з Kubenretes Pods і які перевіряти.

Чому Skill окремо від System Prompt?

Тут кілька важливих моментів, які треба мати на увазі:

  • розділення абстракцій: System Prompt описує агенту “хто ти“, а Skill описує “як робити X в нашому кластері
  • економія контексту: System Prompt додається до кожного запиту до LLM, а Skill читається при потребі – не витрачаємо токени і ліміти.
    • тобто, коли агенту треба дізнатись “що робити, якщо Pod в стані CrashLoopBackOff” – він автоматично підгрузить Skill і отримає відповідні інструкції – а не буде кожного разу додавати всі деталі до кожного запиту
  • re-use з іншими агентами: ми додаємо скіл в корінь плагіну – то потім можемо його використовувати для інших агентів, а не дублювати
  • оновлення скілу: простіше додавати якісь нові деталі в одному місці, а не переписувати в 100500 файлах різних агентів

Файл plugins/k8s-tools/skills/k8s-troubleshooting-flow/SKILL.md

Весь скіл виглядає так:

---
name: k8s-troubleshooting-flow
description: Use when investigating Kubernetes Pod, Workload, or Namespace issues and you need to correlate kubectl observations with metrics (VictoriaMetrics), logs (VictoriaLogs), or alerts (Alertmanager). Provides query templates for common Pod failure modes (CrashLoopBackOff, OOMKilled, Pending, ImagePullBackOff), our specific stream label schema for VictoriaLogs (promtail-based), and standard MetricsQL patterns for kube-state-metrics and cAdvisor.
---

# Kubernetes Troubleshooting Flow

This skill provides query templates and correlation patterns for debugging Kubernetes workloads using our observability stack.

## Our environment

**VictoriaLogs** - log collection via promtail. Stream labels (indexed):
- `namespace` - the only stream-level label

Other useful fields available after stream filter (NOT indexed, but searchable):
- `pod` - Pod name
- `container` - container name within Pod
- `app` - app label from Pod
- `node_name`, `hostname` - Node where Pod runs
- `stream` - `stdout` or `stderr`

**VictoriaMetrics** - prometheus-operator stack. All metrics labeled with:
- `cluster` - cluster identifier (REQUIRED in queries)
- `namespace`, `pod`, `container` - standard k8s labels
- `job`, `instance`, `service`, `endpoint` - infra labels

**Alertmanager** - standard, queried via `alertmanager-query` skill.

## Query template patterns

### LogsQL (via victorialogs-query skill)

Always start with stream filter, then narrow by fields:

    _stream:{namespace="<NS>"} pod:"<POD>"

Common patterns:

- All logs for a Pod (last hour):
  `_stream:{namespace="<NS>"} pod:"<POD>"`

- Errors only:
  `_stream:{namespace="<NS>"} pod:"<POD>" (level:error OR error OR exception OR fatal OR panic)`

- Specific container in multi-container Pod:
  `_stream:{namespace="<NS>"} pod:"<POD>" container:"<CONTAINER>"`

- Errors across whole Namespace:
  `_stream:{namespace="<NS>"} (level:error OR error OR exception OR fatal)`

- Logs from specific Node (e.g. Node-level issues):
  `_stream:{namespace="<NS>"} node_name:"<NODE>"`

### MetricsQL (via victoriametrics-query skill)

Always include `cluster="<CLUSTER>"`. The user will tell you the cluster name, or you can ask if it's not clear.

**Pod state and lifecycle:**

- Current phase: `kube_pod_status_phase{cluster="<C>",namespace="<NS>",pod="<POD>"}`
- Restart count: `kube_pod_container_status_restarts_total{cluster="<C>",namespace="<NS>",pod="<POD>"}`
- Restart rate (last hour): `rate(kube_pod_container_status_restarts_total{cluster="<C>",namespace="<NS>",pod="<POD>"}[1h])`
- Last termination reason: `kube_pod_container_status_last_terminated_reason{cluster="<C>",namespace="<NS>",pod="<POD>"}`
- Ready status: `kube_pod_status_ready{cluster="<C>",namespace="<NS>",pod="<POD>"}`

**Memory (cAdvisor):**

- Working set (current): `container_memory_working_set_bytes{cluster="<C>",namespace="<NS>",pod="<POD>",container!=""}`
- vs limit: `container_memory_working_set_bytes{cluster="<C>",namespace="<NS>",pod="<POD>",container!=""} / container_spec_memory_limit_bytes{cluster="<C>",namespace="<NS>",pod="<POD>",container!=""}`
- OOM kills: `kube_pod_container_status_terminated_reason{cluster="<C>",namespace="<NS>",pod="<POD>",reason="OOMKilled"}`

**CPU (cAdvisor):**

- Usage rate: `rate(container_cpu_usage_seconds_total{cluster="<C>",namespace="<NS>",pod="<POD>",container!=""}[5m])`
- Throttling rate: `rate(container_cpu_cfs_throttled_periods_total{cluster="<C>",namespace="<NS>",pod="<POD>",container!=""}[5m]) / rate(container_cpu_cfs_periods_total{cluster="<C>",namespace="<NS>",pod="<POD>",container!=""}[5m])`
- Throttling > 0 means resource pressure

**Workload state (Deployment / StatefulSet / DaemonSet):**

- Deployment: desired vs available
  - `kube_deployment_spec_replicas{cluster="<C>",namespace="<NS>",deployment="<NAME>"}`
  - `kube_deployment_status_replicas_available{cluster="<C>",namespace="<NS>",deployment="<NAME>"}`
- StatefulSet: `kube_statefulset_status_replicas_ready{cluster="<C>",namespace="<NS>",statefulset="<NAME>"}`
- DaemonSet: `kube_daemonset_status_number_unavailable{cluster="<C>",namespace="<NS>",daemonset="<NAME>"}`
- Job: `kube_job_status_failed{cluster="<C>",namespace="<NS>",job_name="<NAME>"}`

**Namespace-wide health:**

- Failing Pods count: `count(kube_pod_status_phase{cluster="<C>",namespace="<NS>",phase=~"Failed|Pending|Unknown"})`
- Pods with restarts in last hour: `count(increase(kube_pod_container_status_restarts_total{cluster="<C>",namespace="<NS>"}[1h]) > 0)`

### Alertmanager (via alertmanager-query skill)

- Alerts for a Pod: filter by `pod="<POD>"` label
- Alerts for a Namespace: filter by `namespace="<NS>"` label
- Include `state=active` for currently firing
- Include recently resolved (last 1h) to catch flapping issues

## Correlation patterns by failure mode

For each Pod failure mode, this is what to look for and where:

### CrashLoopBackOff

**Signal in kubectl:**
- `kubectl describe pod` - container State `Waiting` with reason `CrashLoopBackOff`, Last State `Terminated` with exit code

**What to check:**
1. Last termination reason and exit code (kubectl describe)
2. Previous container logs: `kubectl logs <pod> --previous`
3. Restart count metric - is it climbing?
4. Time between restarts - constant (looks like the app starts then fails) or growing (BackOff is increasing)?
5. Logs in VictoriaLogs around restart timestamps - look for stack traces, init errors, missing config

**Common root causes:**
- Application bug on startup (check logs)
- Missing/wrong config (env var, ConfigMap, Secret)
- Failing readiness/liveness probe (check probe config in describe)
- Out of memory (cross-check with OOMKilled metric and memory metrics)

### OOMKilled

**Signal in kubectl:**
- `kubectl describe pod` - Last State `Terminated`, Reason `OOMKilled`, exit code 137

**What to check:**
1. `kube_pod_container_status_terminated_reason{...,reason="OOMKilled"}` - confirm in metrics
2. Memory usage trend leading up to kill: `container_memory_working_set_bytes{...}` over last 6h
3. Memory limit: `container_spec_memory_limit_bytes{...}`
4. Was it gradual leak or sudden spike?
5. Logs right before the kill (last 5 min before termination timestamp)

**Common root causes:**
- Memory leak in application
- Limit set too low for actual workload
- Sudden traffic spike causing memory allocation

### ImagePullBackOff / ErrImagePull

**Signal in kubectl:**
- `kubectl describe pod` - Events show `Failed to pull image`, `ErrImagePull`, `ImagePullBackOff`

**What to check:**
1. Exact image reference in Pod spec
2. Pull error message in events (auth, not found, network)
3. Check imagePullSecrets configured on Pod or ServiceAccount

**Common root causes:**
- Wrong image tag (typo, doesn't exist)
- Registry auth failure (missing/expired pull secret)
- Network issue from Node to registry
- Rate limiting (Docker Hub anonymous pulls)

### Pending

**Signal in kubectl:**
- `kubectl get pod` shows status `Pending` for >30s

**What to check:**
1. `kubectl describe pod` Events - scheduler messages
2. Common scheduler errors:
   - `0/N nodes are available: insufficient cpu/memory` - resource pressure
   - `node(s) didn't match Pod's node affinity/selector` - scheduling rules issue
   - `node(s) had untolerated taint` - taints/tolerations issue
   - `error getting PVC` - storage issue (out of scope, mention it)
3. Node resource availability: `kube_node_status_allocatable{cluster="<C>"}` vs requests on Pending Pod

**Common root causes:**
- Cluster out of resources for requested CPU/memory
- Node selector/affinity doesn't match any Node
- Taints not tolerated
- Storage class not provisioning (refer to storage agent)

### Pod Ready=False but Running

**Signal in kubectl:**
- `kubectl get pod` - status Running but READY shows `0/1`

**What to check:**
1. Container statuses in describe - which probe failing (readiness vs liveness)
2. Probe configuration - endpoint, expected response
3. Logs of the probe target (often the app's `/health` endpoint)
4. Was this recent change? Check rollout history if Deployment

**Common root causes:**
- App takes longer to start than `initialDelaySeconds`
- Wrong probe endpoint or expected response
- Backend dependency unavailable (DB, cache) - app can't become ready

## Investigation discipline

Reminders for the investigating agent:

- **Time-box**: default to last 1h for logs, last 6h for metric trends. Expand only if data is insufficient.
- **Cite evidence**: every finding must reference a specific kubectl output, log line with timestamp, metric value, or alert.
- **Don't conflate symptoms with causes**: "Pod is OOMKilled" is a symptom. The cause is "memory leak in handler X" or "limit set 256Mi but workload needs 512Mi".
- **Stop when you have enough**: a clear root cause + supporting evidence is the goal. Don't keep digging if the answer is found.

Створення Bootstrap скрипту

Агенту для роботи потрібні декілька environment variables – як підключатись до ендпоінтів VictoriaMetrics, VictoriaLogs, Alertmanager.

У нас VictoriaMetrics та VictoriaLogs мають власні Ingress, які доступні через AWS Internal Application Load Balancer та доступні через VPN, тому при додаванні плагіну треба перевірити, що вони доступні.

Заодно перевіряємо наявність kubectl, jq, curl, etc.

Змінні оточення записуємо в файл ~/.config/atlas/env, який потім використовується при старті агенту і описаний в його System Prompt.

Скрипт cross-platform – бо у нас є і macOS юзери, і Linux.

#!/usr/bin/env bash
# Atlas Claude Plugins - bootstrap script
#
# Sets up the local environment needed for the k8s-tools plugin and
# VictoriaMetrics observability skills. Cross-platform (macOS / Linux).
#
# Usage:
#   ./scripts/bootstrap.sh             # interactive setup
#   ./scripts/bootstrap.sh --force     # overwrite existing env file without asking
#   ./scripts/bootstrap.sh --help      # show help

set -euo pipefail

# Defaults that can be overridden via env vars before invocation
DEFAULT_VM_METRICS_URL="${VM_METRICS_URL:-https://vmsingle.monitoring.1-33.ops.example.co}"
DEFAULT_VM_LOGS_URL="${VM_LOGS_URL:-https://vmlogs.monitoring.1-33.ops.example.co}"
DEFAULT_VM_ALERTMANAGER_URL="${VM_ALERTMANAGER_URL:-http://localhost:9093}"

ENV_DIR="${HOME}/.config/atlas"
ENV_FILE="${ENV_DIR}/env"

# Color output (disabled if not a TTY)
if [ -t 1 ]; then
  C_RED=$'\033[31m'
  C_GREEN=$'\033[32m'
  C_YELLOW=$'\033[33m'
  C_BLUE=$'\033[34m'
  C_BOLD=$'\033[1m'
  C_RESET=$'\033[0m'
else
  C_RED=""
  C_GREEN=""
  C_YELLOW=""
  C_BLUE=""
  C_BOLD=""
  C_RESET=""
fi

log_info()  { printf "%s[INFO]%s  %s\n"  "$C_BLUE"   "$C_RESET" "$*"; }
log_ok()    { printf "%s[OK]%s    %s\n"  "$C_GREEN"  "$C_RESET" "$*"; }
log_warn()  { printf "%s[WARN]%s  %s\n"  "$C_YELLOW" "$C_RESET" "$*"; }
log_error() { printf "%s[ERROR]%s %s\n"  "$C_RED"    "$C_RESET" "$*" >&2; }

usage() {
  cat <<EOF
Atlas Claude Plugins - bootstrap script

Sets up environment variables required by k8s-tools plugin and
VictoriaMetrics skills (victoriametrics-query, victorialogs-query, alertmanager-query).

Usage:
  $(basename "$0") [--force] [--help]

Options:
  --force    Overwrite existing env file (${ENV_FILE}) without prompting.
  --help     Show this help message.

Environment variables (used as defaults if set):
  VM_METRICS_URL       Default: ${DEFAULT_VM_METRICS_URL}
  VM_LOGS_URL          Default: ${DEFAULT_VM_LOGS_URL}
  VM_ALERTMANAGER_URL  Default: ${DEFAULT_VM_ALERTMANAGER_URL}

EOF
}

# Parse arguments
FORCE=0
for arg in "$@"; do
  case "$arg" in
    --force) FORCE=1 ;;
    --help|-h) usage; exit 0 ;;
    *) log_error "Unknown argument: $arg"; usage; exit 1 ;;
  esac
done

# OS detection
detect_os() {
  case "$(uname -s)" in
    Darwin) echo "macos" ;;
    Linux)  echo "linux" ;;
    *)      echo "unknown" ;;
  esac
}

OS="$(detect_os)"
log_info "Detected OS: ${OS}"

# Shell detection
detect_shell() {
  local shell_path="${SHELL:-}"
  if [ -n "$shell_path" ]; then
    basename "$shell_path"
  else
    echo "unknown"
  fi
}

USER_SHELL="$(detect_shell)"

shell_rc_file() {
  case "$USER_SHELL" in
    bash) echo "${HOME}/.bashrc" ;;
    zsh)  echo "${HOME}/.zshrc" ;;
    fish) echo "${HOME}/.config/fish/config.fish" ;;
    *)    echo "" ;;
  esac
}

# Dependency checks
install_hint() {
  local tool="$1"
  case "$OS" in
    macos)
      case "$tool" in
        kubectl) echo "  brew install kubectl" ;;
        curl)    echo "  curl is preinstalled on macOS, check your PATH" ;;
        jq)      echo "  brew install jq" ;;
        claude)  echo "  npm install -g @anthropic-ai/claude-code  (or https://claude.ai/download)" ;;
      esac
      ;;
    linux)
      case "$tool" in
        kubectl) echo "  https://kubernetes.io/docs/tasks/tools/install-kubectl-linux/" ;;
        curl)    echo "  sudo apt install curl  # or: sudo dnf install curl" ;;
        jq)      echo "  sudo apt install jq    # or: sudo dnf install jq" ;;
        claude)  echo "  npm install -g @anthropic-ai/claude-code  (or https://claude.ai/download)" ;;
      esac
      ;;
    *)
      echo "  (install ${tool} for your platform)"
      ;;
  esac
}

check_dep() {
  local cmd="$1"
  if command -v "$cmd" >/dev/null 2>&1; then
    log_ok "${cmd} found: $(command -v "$cmd")"
    return 0
  else
    log_error "${cmd} not found in PATH"
    printf "        Install hint:\n%s\n" "$(install_hint "$cmd")"
    return 1
  fi
}

log_info "Checking dependencies..."
DEPS_OK=1
for dep in kubectl curl jq claude; do
  check_dep "$dep" || DEPS_OK=0
done

if [ "$DEPS_OK" -eq 0 ]; then
  log_error "Some dependencies are missing. Install them and re-run this script."
  exit 1
fi

# Handle existing env file
if [ -f "$ENV_FILE" ] && [ "$FORCE" -eq 0 ]; then
  log_warn "Env file already exists: ${ENV_FILE}"
  log_warn "Current contents:"
  printf "%s---%s\n" "$C_BOLD" "$C_RESET"
  cat "$ENV_FILE"
  printf "%s---%s\n" "$C_BOLD" "$C_RESET"
  log_warn "Re-run with --force to overwrite, or edit the file manually."
  exit 0
fi

# Interactive prompts - prompt goes to stderr, value to stdout
# This way command substitution captures only the value, not the prompt
prompt_with_default() {
  local label="$1"
  local default="$2"
  local answer

  # Prompt to stderr (visible to user, not captured by $(...))
  printf "%s%s%s [%s]: " "$C_BOLD" "$label" "$C_RESET" "$default" >&2
  read -r answer
  if [ -z "$answer" ]; then
    echo "$default"
  else
    echo "$answer"
  fi
}

log_info "Configure observability endpoints (press Enter to accept default):"
echo

VM_METRICS_URL_VAL="$(prompt_with_default "VictoriaMetrics URL"  "$DEFAULT_VM_METRICS_URL")"
VM_LOGS_URL_VAL="$(prompt_with_default     "VictoriaLogs URL"    "$DEFAULT_VM_LOGS_URL")"
VM_ALERTMANAGER_URL_VAL="$(prompt_with_default "Alertmanager URL" "$DEFAULT_VM_ALERTMANAGER_URL")"
echo

# Connectivity check (non-fatal)
check_url() {
  local url="$1"
  local label="$2"
  if curl -sS -o /dev/null -w "%{http_code}" --max-time 3 "$url" 2>/dev/null | grep -qE '^[234]'; then
    log_ok "${label} reachable (${url})"
  else
    log_warn "${label} not reachable (${url}) - check VPN if this is an internal endpoint"
  fi
}

log_info "Checking connectivity..."
check_url "$VM_METRICS_URL_VAL"      "VictoriaMetrics"
check_url "$VM_LOGS_URL_VAL"         "VictoriaLogs"
check_url "$VM_ALERTMANAGER_URL_VAL" "Alertmanager"
echo

# Write env file
log_info "Writing ${ENV_FILE}..."
mkdir -p "$ENV_DIR"

cat > "$ENV_FILE" <<ENVEOF
# Atlas Claude Plugins - environment configuration
# Generated by scripts/bootstrap.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
#
# These variables are required by:
#   - victoriametrics-query skill (from victoriametrics-tools marketplace)
#   - victorialogs-query skill
#   - alertmanager-query skill
#
# Re-run scripts/bootstrap.sh --force to regenerate.

export VM_METRICS_URL="${VM_METRICS_URL_VAL}"
export VM_LOGS_URL="${VM_LOGS_URL_VAL}"
export VM_ALERTMANAGER_URL="${VM_ALERTMANAGER_URL_VAL}"
ENVEOF

chmod 600 "$ENV_FILE"
log_ok "Env file written (mode 600)"
echo

# Shell rc integration hint
RC_FILE="$(shell_rc_file)"
SOURCE_LINE='[ -f ~/.config/atlas/env ] && source ~/.config/atlas/env'

log_info "Next step: make these variables available in your shell."
echo

if [ -n "$RC_FILE" ]; then
  if [ -f "$RC_FILE" ] && grep -qF "$SOURCE_LINE" "$RC_FILE" 2>/dev/null; then
    log_ok "Source line already present in ${RC_FILE}"
  else
    cat <<HINTEOF
Add this line to ${C_BOLD}${RC_FILE}${C_RESET}:

  ${C_GREEN}${SOURCE_LINE}${C_RESET}

Or run this once:

  ${C_GREEN}echo '${SOURCE_LINE}' >> ${RC_FILE}${C_RESET}

Then reload your shell:

  ${C_GREEN}source ${RC_FILE}${C_RESET}

HINTEOF
  fi
else
  log_warn "Could not detect your shell rc file. Add this line manually to your shell config:"
  echo "  ${SOURCE_LINE}"
  echo
fi

# Next steps
cat <<NEXTEOF
${C_BOLD}=== Setup complete ===${C_RESET}

To use the plugin, start Claude Code:

  ${C_GREEN}claude${C_RESET}

Then inside Claude Code (first time only):

  ${C_GREEN}/plugin marketplace add Org-Engineering/atlas-claude-plugins${C_RESET}
  ${C_GREEN}/plugin marketplace add VictoriaMetrics/skills${C_RESET}
  ${C_GREEN}/plugin install k8s-tools@atlas-claude-plugins${C_RESET}
  ${C_GREEN}/plugin install query@victoriametrics-tools${C_RESET}

If you cloned this repo and opened it in Claude Code, the marketplaces
will be suggested automatically (via .claude/settings.json).

NEXTEOF

Що робить скрипт:

  • визначає операційну систему – macOS чи Linux, бо трохи відрізняються утиліти типу date та sed
  • перевіряє чи встановлені всі потрібні утиліти, якщо нема – пропонує команду для установки
  • перевіряємо наявність файлу ~/.config/atlas/env
  • запитує значення для змінних VM_METRICS_URL, VM_LOGS_URL, VM_ALERTMANAGER_URL, пропонує встановити дефолти – але можна перевизначити
    • тут єдиний нюанс – конкретно в нашому випадку у VM_ALERTMANAGER_URL нема Ingress/ALB, тому підключення через localhost – потім зроблю нормально, бо раніше ним користувався виключно я і мені було OK робити kubectl port-forward
  • виконує підключення до VM_METRICS_URL – перевіряє, що VPN включений і ендпоінти доступні
  • записує змінні до ~/.config/atlas/env
  • визначає user shell (zsh, bash), показує юзеру як додати ~/.config/atlas/env до shell rc
  • і в кінці виводить команди “як додати маркетплейс та плагін

Запускаємо, перевіряємо як все працює:

Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetrics

Тут як раз забув kubectl port-forward до Alertmanager – отримав “[WARN] Alertmanager not reachable“.

Перевірка Marketplace та Plugin

В принципі – на цьому етапі вже все готово.

Але перед тим, як додавати CLAUDE.md та REAME.md і пушити в репозиторій – протестуємо локально, як все працює.

Переходимо в тестову директорію, запускаємо Claude Code:

$ cd /tmp && mkdir -p test-plugin && cd test-plugin
$ claude

Підтверджуємо довіру директорії:

Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetrics

Додаємо маркетплейс:

/plugin marketplace add ~/Work/Org/atlas-claude-plugins

Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetrics

Перевіряємо, що він доданий:

Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetrics

Додаємо сам плагін:

/plugin install k8s-tools@atlas-claude-plugins

Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetrics

Вибираємо “Install for you (user scope)“.

Виконуємо /reload-plugins:

Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetrics

Перевіряємо в Installed:

Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetrics

Перевіряємо сам агент:

Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetrics

Спробуємо дебаг – знаходимо проблемний Pod:

$ kk get pod -A | grep -v Running
NAMESPACE                   NAME                                                              READY   STATUS        RESTARTS      AGE
ops-monitoring-ns           atlas-victoriametrics-grafana-5f8ff65758-tbwzb                    0/3     Completed     0             8d

Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetrics

Бачимо, що агент знайшов і прочитав скіл в ~/.local/share/claude-code/plugins/k8s-troubleshooting-flow/skill.md.

І результат дебагу:

Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetrics

Файл CLAUDE.md

CLAUDE.md – це файл який Claude Code автоматично підхоплює як контекст коли хтось працює з репозиторієм. Тобто коли я через пів року відкрию репозиторій в Claude Code, щоб додати новий плагін чи поправити існуючий – Claude відразу буде розуміти що це за проект

Важливо не плутати з README.md:

  • CLAUDE.md: для тих, хто додає/змінює плагіни в репо з Claude Code
  • README.md: для юзерів плагіну (девелоперів, які встановлюють і користуються агентом)

Що в CLAUDE.md:

  • структура репо з коментарями що для чого
  • правила і naming conventions – коментарі в коді англійською, read-only by default, Kubernetes ресурси з великої букви, env naming
  • як додати новий плагін в існуючий marketplace
  • правила версіонування
  • як тестувати локально перед push
  • як публікувати на GitHub

Весь зміст:

# atlas-claude-plugins

Claude Code plugins for Org DevOps - Kubernetes debugging and operations.

## Repo structure

```
.claude-plugin/marketplace.json    # marketplace manifest, lists all plugins
.claude/settings.json              # extraKnownMarketplaces (auto-suggest on clone)
plugins/<plugin-name>/             # one directory per plugin
  .claude-plugin/plugin.json       # plugin metadata
  agents/<agent-name>.md           # agent definitions (filename = name in frontmatter)
  skills/<skill-name>/SKILL.md     # skills (each in its own directory)
  commands/<command-name>.md       # slash commands (optional)
scripts/bootstrap.sh               # user setup (env vars, deps check)
```

## Conventions

- **Code comments in English** always, regardless of context language
- **Agents are read-only by default**: explicit `allowed-tools` whitelist + `deny-tools` blacklist for write operations (kubectl write verbs, curl POST/PUT/DELETE, file writes)
- **Kubernetes resources capitalized in prose**: Pod, Deployment, Namespace, Service, etc
- **Env vars follow VictoriaMetrics convention**: `VM_METRICS_URL`, `VM_LOGS_URL`, `VM_ALERTMANAGER_URL` (so VM skills work without aliasing)
- **User config lives in `~/.config/atlas/env`** - never in repo, never in shell rc directly

## Adding a new plugin

1. Create `plugins/<name>/.claude-plugin/plugin.json` with name, version, description
2. Add agent(s) in `plugins/<name>/agents/<agent>.md` - frontmatter `name` must match filename
3. Add skill(s) in `plugins/<name>/skills/<skill>/SKILL.md` - directory name must match skill `name`
4. Register in `.claude-plugin/marketplace.json` under `plugins[]` array
5. Bump marketplace `version` in `marketplace.json`

## Versioning

- Each plugin has independent semver in its `plugin.json`
- Marketplace `version` in `marketplace.json` bumps when plugin list changes (add/remove)
- Plugin patch version bumps for prompt/skill content changes
- Plugin minor version bumps for new capabilities (new tools, new entry points)
- Plugin major version bumps for breaking changes (renamed agent, removed permissions)

## Testing locally

```bash
# 1. Run bootstrap (sets up env file + checks deps)
./scripts/bootstrap.sh

# 2. Source env in current shell (or open new shell if added to rc)
source ~/.config/atlas/env

# 3. Start Claude in some unrelated directory (NOT this repo)
cd /tmp && mkdir -p test-claude && cd test-claude && claude

# 4. Inside Claude, add this repo as local marketplace
/plugin marketplace add /path/to/atlas-claude-plugins
/plugin install <plugin-name>@atlas-claude-plugins

# 5. Verify agent loaded
/agents
# should show: Plugin agents - <plugin>:<agent>

# 6. Test the agent with a real task
> Use <agent-name> to debug pod foo in namespace bar
```

After changes to plugin files: `/plugin marketplace update atlas-claude-plugins` reloads.

## Publishing

```bash
git add -A
git commit -m "..."
git push origin master
```

Users on the next `/plugin marketplace update` get the changes. Or, if they cloned the repo, `extraKnownMarketplaces` in `.claude/settings.json` auto-suggests the marketplace on first open.

## Dependencies

- Plugins use `kubectl` + `curl` directly (no MCP servers)
- VictoriaMetrics observability via `query` plugin from `VictoriaMetrics/skills` marketplace - listed as suggested in `.claude/settings.json`
- Agents must work with read-only kubectl perms - never assume write access

Файл README.md

І останній файл – чисто для девелоперів: що в репозиторії, як користуватись:

Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetrics

Власне – на цьому все.

Пушимо в репозиторій і перевіряємо ще раз.

Перевірка extraKnownMarketplaces

Видаляємо вже встановлений маркетплейс:

/plugin marketplace remove atlas-claude-plugins

Перевіряємо, що його нема:

Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetricsСтворюємо тестову директорію:

$ mkdir /tmp/test-extra && cd /tmp/test-extra

Клонуємо репозиторій, запускаємо в ньому Claude Code:

$ git clone [email protected]:Org-Engineering/atlas-claude-plugins.git

$ cd atlas-claude-plugins/

$ claude

Підтверджуємо довіру каталогу:

Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetrics

Перевіряємо маркеплейси – маємо там і наш atlas-claude-plugins, і victoriametrics-tools:

Claude Code: створення Kubernetes debugging AI Agent для VictoriaMetrics

Додаємо плагіни з цих маркетплейсів:

/plugin install k8s-tools@atlas-claude-plugins
/plugin install query@victoriametrics-tools

Перевіряємо:

Готово.

Loading