Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

sqlx-exporter

Prometheus-экспортёр на Go: периодически выполняет SQL-запросы к базам данных и публикует результаты как метрики на /metrics. Поддерживает PostgreSQL, MySQL, MSSQL и Oracle, hot-reload конфига без рестарта Pod, multi-row метрики с динамическими лейблами и гибкую систему инклюдов для конфигурации в Kubernetes.


Содержание


Быстрый старт

go build -o sqlx-exporter .
./sqlx-exporter --config ./config.yaml

Метрики появятся на http://localhost:2112/metrics.

Другие флаги: --listen-address (адрес HTTP-сервера, по умолчанию :2112), --watch-required (см. Hot-reload), --log-level (debug/info/warn/error, по умолчанию info). В Kubernetes с Helm chart из этого репозитория --log-level/--watch-required задаются через values.yaml (logLevel:/watchRequired:), не через ручное редактирование args: — см. Helm chart. По умолчанию (info) на каждое успешное выполнение запроса ничего не пишется — эта информация уже полнее и дешевле доступна через метрики (app_query_up, app_query_last_success_timestamp_seconds), а не через лог-строку на каждый тик каждого запроса. --log-level=debug включает построчный вывод (query success, реальное значение single-value запроса, удаление устаревших строк multi-row) — полезно для разовой отладки конкретного запроса, не для постоянной эксплуатации: при заметном числе запросов с коротким interval объём логов на debug быстро становится большим, а значения некоторых запросов могут быть чувствительными.

Минимальный конфиг:

databases:
  main:
    driver: "pgx"
    url: "postgres://user:password@localhost:5432/mydb"
    max_conns: 10
    max_idle_conns: 2

queries:
  active_connections:
    db: "main"
    sql: "SELECT count(1) FROM pg_stat_activity WHERE state = 'active'"
    timeout: "5s"
    interval: "30s"

Поддерживаемые БД

Драйвер в конфиге СУБД Go-библиотека
pgx PostgreSQL github.com/jackc/pgx/v5
mysql MySQL / MariaDB github.com/go-sql-driver/mysql
sqlserver Microsoft SQL Server github.com/microsoft/go-mssqldb
oracle Oracle Database github.com/sijms/go-ora/v2

Формат конфигурации

Конфиг — YAML-файл с тремя основными секциями: settings, databases, queries. Путь к файлу передаётся через --config (по умолчанию ./config.yaml).

databases

Карта подключений. Ключ — произвольное имя, используется в queries.<name>.db.

databases:
  main:
    driver: "pgx"                              # pgx | mysql | sqlserver | oracle
    url: "postgres://user:${DB_PASS}@host:5432/mydb"

    max_conns: 10                # максимум открытых соединений одновременно
    max_idle_conns: 2            # сколько держать в пуле простаивающими
    max_conn_lifetime: "1h"      # принудительно закрывать соединение старше этого возраста
    max_conn_idle_time: "30m"    # закрывать соединение если не использовалось дольше этого

max_conn_lifetime и max_conn_idle_time важны если перед БД стоит балансировщик (PgBouncer, AWS RDS Proxy) — они часто закрывают долгоживущие соединения со своей стороны, и эти настройки позволяют закрыть соединение самостоятельно раньше, избегая ошибок connection reset.

queries

Карта запросов. Ключ — имя запроса, становится именем метрики в Prometheus (если не указано иначе через клонирование для нескольких БД, см. ниже).

queries:
  my_query:
    db: "main"                    # имя БД из databases (можно не указывать, см. default_db)
    sql: "SELECT count(1) FROM users"
    timeout: "5s"                 # таймаут на выполнение запроса
    interval: "30s"                # как часто выполнять (либо interval, либо schedule)
    labels:                        # статические лейблы, добавляются к каждой точке
      environment: "prod"
    value_column: ""               # см. раздел про multi-row метрики

settings

Глобальные настройки приложения, не привязанные к конкретной БД. Разрешены только в корневом конфиге — если settings: встретится в инклюде (подключённом файле), он будет проигнорирован с явным предупреждением в логе, а не тихо применится:

WARN settings: is only honored in the root config detail="\"oracle-metrics.yaml\": settings: is only honored in the root config, ignored here"

Это намеренное ограничение: settings.* — это единственный, общий на весь процесс набор значений, а не что-то, что мержится «по частям» из разных файлов. Файл, задуманный как самодостаточный, переиспользуемый набор метрик одной команды, не должен иметь возможности незаметно поменять поведение для БД, объявленных в совершенно другом файле — если бы такое было разрешено, settings.max_conns в чьём-то oracle-metrics.yaml мог бы неожиданно стать дефолтом и для Postgres-баз другой команды, объявленных в root, без единой строчки в логе о том, что вообще произошло.

Если нужно применить одни и те же настройки пула к нескольким базам — задавай их явно, на каждой базе в databases: (см. ниже), либо через settings: в самом root config, а не в инклюде.

settings:
  db_reconnect_interval: "5m"   # как часто пытаться переподключиться к упавшим БД (default: 5m)
  default_db: "main"             # глобальный фолбэк БД для запросов без db (см. ниже)
  default_timezone: "Europe/Moscow"  # дефолт для schedule.timezone без явного значения (см. раздел про schedule, default: UTC)

  # Дефолты пула соединений — см. ниже
  max_conns: 10
  max_idle_conns: 2
  max_conn_lifetime: "1h"
  max_conn_idle_time: "30m"

Четыре последних поля — дефолты для настроек пула у каждой конкретной БД в databases:. Явное значение внутри самой БД всегда побеждает, settings.* заполняет только то, что оставлено пустым:

settings:
  max_conns: 10
  max_idle_conns: 2

databases:
  db_a:
    driver: "pgx"
    url: "postgres://..."
    # ничего не переопределено — возьмёт оба значения из settings

  db_b:
    driver: "oracle"
    url: "oracle://..."
    max_conns: 3   # переопределил только это — max_idle_conns всё равно из settings

  db_c:
    driver: "pgx"
    url: "postgres://..."
    max_idle_conns: 0   # явный ноль — не держать простаивающие соединения для этой БД,
                         # несмотря на settings.max_idle_conns: 2 выше

max_conns/max_idle_conns различают «явно указано 0» и «не указано вообще» — явный 0 у конкретной БД полностью override'ит ненулевой глобальный дефолт из settings, а не теряется в нём.


Запросы: single-value и multi-row

Single-value (по умолчанию) — SELECT возвращает одно число, которое становится значением метрики:

queries:
  total_users:
    db: "main"
    sql: "SELECT count(*) FROM users"
    timeout: "5s"
    interval: "1m"
total_users{db="main"} 1542

Multi-row — SELECT возвращает несколько строк, каждая становится отдельным time series. Указываешь value_column — какой столбец содержит число, остальные столбцы автоматически становятся лейблами:

queries:
  users_by_region:
    db: "main"
    sql: "SELECT region, status, count(*) as cnt FROM users GROUP BY region, status"
    value_column: "cnt"
    timeout: "5s"
    interval: "1m"
users_by_region{db="main", region="eu", status="active"} 320
users_by_region{db="main", region="us", status="idle"}   58

Reconciliation: если на следующем запуске какая-то комбинация лейблов пропала из результата (например регион eu/idle больше не существует) — соответствующая метрика автоматически удаляется из Prometheus и пропадёт с графиков в Grafana, а не зависнет с последним известным значением.

При ошибке запроса (таймаут, обрыв соединения) все метрики предыдущего успешного запуска удаляются — устаревшие данные не остаются на дашбордах молча.


Расписание (schedule)

Вместо interval можно задать конкретные дни недели и время через schedule — полезно для тяжёлых отчётных запросов которые не нужно гонять каждую минуту:

queries:
  weekly_report:
    db: "main"
    sql: "SELECT count(*) FROM big_table"
    timeout: "30s"
    schedule:
      timezone: "Europe/Moscow"
      at:
        - weekday: "mon, wed, fri"
          time: "03:00, 15:00"
        - weekday: "sun"
          time: "23:30"

weekday и time поддерживают список через запятую — генерируются все комбинации. В примере выше запрос выполнится: понедельник/среда/пятница в 03:00 и 15:00, и воскресенье в 23:30.

Если timezone не указан — сначала берётся settings.default_timezone (если задан), иначе UTC. Это позволяет не дублировать timezone: в каждом отдельном запросе с schedule:, если все они должны быть в одном часовом поясе:

settings:
  default_timezone: "Europe/Moscow"

queries:
  weekly_report:
    schedule:
      at:
        - weekday: "mon"
          time: "03:00"   # возьмёт Europe/Moscow из settings

  another_report:
    schedule:
      timezone: "UTC"      # явно переопределил — победит явное значение
      at:
        - weekday: "sun"
          time: "12:00"

UTC, а не часовой пояс контейнера, используется только если таймзона не задана нигде — ни здесь, ни в самом запросе. Это сделано намеренно: время срабатывания schedule: не должно зависеть от того, как собран базовый образ или что стоит в TZ окружения — один и тот же конфиг должен запускаться в одно и то же время независимо от того, где именно развёрнут под.

interval и schedule взаимоисключающие — указывается либо один, либо другой.


БД по умолчанию (default_db)

Если у запроса не указано db:, оно подставляется по следующему приоритету (от самого специфичного к самому общему):

  1. Явный db: внутри самого запроса — высший приоритет.
  2. Локальный default_db: в корне файла, где определён запрос (см. инклюды ниже).
  3. default_db, унаследованный от родительского файла (если дочерний файл не задал свой).
  4. settings.default_db — глобальный фолбэк, задаётся в root config (см. settings — в инклюдах не действует).
default_db: "main"   # действует только на запросы в ЭТОМ файле

databases:
  main:
    driver: "pgx"
    url: "postgres://user:${DB_PASS}@host:5432/mydb"

queries:
  q1:
    sql: "SELECT 1"      # db не указан → возьмёт "main"
    timeout: "5s"
    interval: "30s"

Это особенно полезно когда у вас несколько файлов метрик для разных БД — каждый файл может задать свою БД по умолчанию без необходимости прописывать db: в каждом отдельном запросе.


Инклюды конфига

Конфиг можно разбить на несколько файлов — это критично важно для Kubernetes, где разные команды или окружения хотят управлять своим набором метрик независимо.

Простой инклюд

# config.yaml
includes:
  "oracle.yaml": true
  "mssql.yaml": true

databases:
  ...

Важно: includes — это map, а не список (- "file.yaml"). Причина в Helm: при объединении нескольких values.yaml файлов (multi-source ArgoCD) Helm делает глубокий мерж для map-полей, но полностью заменяет списки. Если бы includes был списком, второй values файл стирал бы инклюды первого. С map — оба набора объединяются автоматически.

Пути в includes относительны директории основного файла. Поддерживается рекурсия (инклюд может инклюдить другие файлы), глубина ограничена 10 уровнями для защиты от циклов.

Содержимое инклюд-файла мержится в основной конфиг — инклюд имеет приоритет и перезаписывает совпадающие ключи databases/queries основного файла. Порядок инклюдов в map не гарантирован Go, поэтому ключи обрабатываются в отсортированном порядке для детерминированности.

include_defaults — переиспользуемые файлы метрик

Файл с метриками можно сделать полностью независимым от конкретной БД — без databases и без default_db внутри — а привязку к БД задать централизованно в основном конфиге:

# config.yaml
include_defaults:
  oracle-metrics.yaml: "azdh_oracle"
  mssql-metrics.yaml: "main_mssql"

includes:
  "oracle-metrics.yaml": true
  "mssql-metrics.yaml": true

databases:
  azdh_oracle:
    driver: "oracle"
    url: "oracle://user:${ORACLE_PASS}@host:1521/service"
  main_mssql:
    driver: "sqlserver"
    url: "sqlserver://user:${MSSQL_PASS}@host:1433?database=mydb"
# oracle-metrics.yaml — только запросы, никаких databases
queries:
  active_sessions:
    sql: "SELECT count(1) FROM v$session WHERE status = 'ACTIVE'"
    timeout: "10s"
    interval: "1m"

При загрузке oracle-metrics.yaml все его запросы без явного db: автоматически получат db: "azdh_oracle". Файл oracle-metrics.yaml можно переиспользовать для любой Oracle БД — просто меняя значение в include_defaults, без редактирования самого файла метрик.

Если для инклюда нет записи в include_defaults, файл полностью игнорируется (его databases/queries не попадают в итоговый конфиг). Это логируется при каждом reload:

INFO include skipped (no include_defaults entry) detail="\"forgotten.yaml\": no entry in include_defaults"

Это защищает от ситуации, когда файл метрик добавлен в includes, но забыли указать для него БД — вместо непонятной ошибки валидации файл просто не подключается, и это видно в логах.

Один файл метрик — несколько БД

include_defaults принимает не только строку, но и список БД — тогда один файл с метриками выполнится для каждой указанной БД:

include_defaults:
  oracle-metrics.yaml:
    - "azdh_oracle"
    - "ir_test_oracle"

Запросы из oracle-metrics.yaml запустятся дважды — один раз с подключением к azdh_oracle, другой раз к ir_test_oracle. Внутренние имена воркеров получают служебный суффикс (active_sessions__azdh_oracle, active_sessions__ir_test_oracle) для уникальности, но имя метрики в Prometheus остаётся чистым — без суффикса. Различать данные по БД позволяет лейбл db, который присутствует на каждой метрике всегда:

active_sessions{db="azdh_oracle"}    42
active_sessions{db="ir_test_oracle"} 17

Запросы в файле, подключаемом на несколько БД таким способом, не должны задавать db: явно. Сама идея этой фичи — переиспользуемый файл, у которого БД назначается снаружи, через include_defaults, а не изнутри самого файла. Если запрос всё же напишет свой db: — при include_defaults с несколькими БД это ошибка конфигурации, а не тихо игнорируемая мелочь: суффикс у воркера (__azdh_oracle, __ir_test_oracle) переименовывает только его внутренний ключ, но не трогает поле db: внутри самого запроса — получились бы два воркера с разными именами, оба реально бьющие в одну и ту же явно указанную БД и пишущие в одну и ту же метрику. Это ловится при reload и попадает в failedIncludes для этой конкретной пары (файл, БД) — остальной конфиг продолжает применяться как обычно:

ERROR include failed to load detail="\"oracle-metrics.yaml\" (db=azdh_oracle): query \"active_sessions\" has an explicit db \"other_db\", but this include is used for multiple databases via include_defaults — explicit db: is not allowed here, it would run the same query against the same database multiple times"

Если у файла в include_defaults указана одна БД (не список) — явный db: внутри запроса по-прежнему допустим и просто побеждает: дублирования там не возникает, потому что файл в этом случае применяется только один раз.


Подключение к Oracle

Стандартный URL

databases:
  myoracle:
    driver: "oracle"
    url: "oracle://user:${ORACLE_PASS}@host:1521/service_name"

TNS-дескриптор

Если у вас есть TNS-дескриптор (например из Vault или tnsnames.ora), используйте префикс oracle+tns://:

databases:
  myoracle:
    driver: "oracle"
    url: "oracle+tns://user:${ORACLE_PASS}@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=host)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=service)))"

Также поддерживается вариант с /?CONNSTR= перед дескриптором (распространённый формат при экспорте из некоторых систем):

url: "oracle+tns://user:${ORACLE_PASS}@/?CONNSTR=(DESCRIPTION=...)"

Оба варианта нормализуются одинаково. Пробелы внутри TNS-дескриптора убираются автоматически, так как драйвер go-ora не всегда корректно их обрабатывает.

Важно при работе с переменными окружения: если TNS-дескриптор целиком приходит из переменной окружения (например из Vault), он не URL-кодируется — экспортёр распознаёт значения похожие на TNS-дескриптор (начинаются с ( и содержат DESCRIPTION=/ADDRESS=) и подставляет их как есть. Иначе скобки ( ) и знак = превратились бы в %28 %29 %3D, что сломало бы парсинг дескриптора.

Кодировка сообщений об ошибках

Экспортёр автоматически задаёт client charset=UTF8 при подключении к Oracle — это нужно чтобы сообщения об ошибках с кириллицей (частая ситуация для русскоязычных инсталляций Oracle) отображались корректно, а не как \ufffd\ufffd\ufffd. Дополнительно устанавливается nls_language=AMERICAN для сессии — большинство системных сообщений Oracle приходят на английском, что упрощает диагностику.


Переменные окружения в конфиге

Поле url у каждой БД поддерживает подстановку переменных окружения через синтаксис ${VAR_NAME}. Только эта форма — голый $VAR_NAME без фигурных скобок не распознаётся и остаётся в строке буквально, даже если после $ идёт текст, похожий на имя переменной. Это специально: иначе литеральный пароль вроде MyP@ss$word123, зашитый прямо в YAML, был бы молча испорчен — $word123 подставился бы как ссылка на несуществующую переменную окружения и заменился бы пустой строкой.

databases:
  main:
    driver: "pgx"
    url: "postgres://user:${DB_PASSWORD}@host:5432/mydb"

Важно: подстановка переменных применяется только к полю url, не ко всему YAML-файлу. Это не защита от Oracle-синтаксиса вроде v$session/gv$instance — эти голые $ и так не тронуты строгим парсером (см. выше), с ними ничего не случится даже если применить подстановку ко всему файлу. Ограничение осталось из соображений предсказуемости: единственное поле, где вообще имеет смысл ссылаться на переменную окружения — это url (там живут credentials), и не хочется, чтобы ${...}-подобный текст где-то ещё в конфиге неожиданно начал что-то подставлять.

Значения переменных URL-кодируются автоматически — это значит пароль со спецсимволами (@, /, #, % и др.) не сломает парсинг URL:

# DB_PASSWORD=p@ss/w0rd! в окружении
url: "postgres://user:${DB_PASSWORD}@host:5432/mydb"
# Подставится как postgres://user:p%40ss%2Fw0rd%21@host:5432/mydb

Два исключения: MySQL не кодируется вообще (его DSN — не URL, драйвер сам корректно принимает пароль с любыми символами), и значения похожие на TNS-дескриптор Oracle (см. выше) не кодируются, иначе сломается структура дескриптора.

В Kubernetes пароли обычно приходят через envFrom → Secret, созданный External Secrets Operator из Vault.

Важный нюанс — в отличие от самого конфига (который подхватывается на лету через hot-reload, см. ниже), переменные окружения процесса так не работают. Kubernetes обновляет содержимое смонтированного Secret/ConfigMap на диске, но уже запущенный процесс не видит новых значений envFrom/env — они читаются один раз при старте контейнера. Обновление пароля в Vault/Secret само по себе не подхватится без пересоздания Pod'а (kubectl rollout restart). Это отдельное и не связанное с hot-reload конфига ограничение самого Kubernetes, не нашего приложения.

Для автоматической ротации credentials (без ручного kubectl rollout restart на каждое обновление пароля) — два рабочих варианта:

  • Reloader (или аналогичный checksum-annotation паттерн в Helm chart) — следит за Secret/ConfigMap и сам инициирует rollout при изменении, без дополнительного кода в самом экспортёре.
  • Смонтировать Secret файлом, а не через envFrom, и модифицировать url: так, чтобы читать пароль из файла, а не из переменной окружения — файлы в примонтированном Secret обновляются на лету через тот же механизм ..data-симлинка, что и ConfigMap с самим конфигом (см. Hot-reload), в отличие от environment variables. Это, однако, требует поддержки чтения credentials из файла — сейчас url: поддерживает подстановку только из переменных окружения (${VAR_NAME}), не из файлов.

Health-метрики и мониторинг состояния

Для каждого запроса автоматически публикуются четыре служебные метрики, удобные для алертинга:

app_query_up{query, db}                              # 1 = последнее выполнение успешно, 0 = ошибка
app_query_last_success_timestamp_seconds{query, db}  # unix-время последнего успеха
app_query_errors_total{query, db, reason}            # счётчик ошибок, reason: timeout|db_error|schema_mismatch (cancelled — только исход в логе, счётчик для него не растёт)
app_query_duration_seconds{query, db}                # histogram длительности выполнения

Все четыре снимаются при удалении запроса из конфига или при смене его БД — не остаются замороженными на графиках со старым значением.

Типичный алерт на зависшую метрику:

time() - app_query_last_success_timestamp_seconds > 300

Отдельные метрики — конфликт схемы лейблов, из двух источников с разным владением, поэтому раздельных:

app_query_config_schema_conflict{query, db} 1    # config-level — известен заранее, до выполнения SQL
app_query_runtime_schema_mismatch{query, db} 1   # runtime — обнаружен только при выполнении

Config-level — Prometheus не позволяет поменять набор лейблов уже зарегистрированной метрики без перезапуска процесса. Если в hot-reload одновременно меняются labels/value_column и сам SQL — экспортёр не собирает из этого нерабочий гибрид, а замораживает весь запрос целиком на последнем рабочем состоянии (SQL, БД, всё). app_query_config_schema_conflict появляется в этот момент и держится, пока конфликт не разрешится (откатом лейблов или рестартом процесса) — сам факт того, что замороженный воркер продолжает успешно выполнять старый SQL, эту метрику не трогает.

Runtime — более узкий случай: набор колонок в результате SQL для multi-row-запроса может измениться и без изменения labels/value_column в конфиге (например сам текст sql: стал возвращать другую колонку). Такое узнаётся только в момент выполнения запроса, а не заранее — заморозки в этом случае нет, запрос просто падает с reason="schema_mismatch" в app_query_errors_total, пока прежняя, ошибочная схема сама не устареет (обычно после следующего изменения конфига или рестарта процесса). app_query_runtime_schema_mismatch даёт то же самое удобство, что и config-level метрика — не нужно высчитывать rate()/increase() по счётчику ошибок, чтобы понять, активна ли проблема прямо сейчас.

Эти две метрики принципиально разделены, а не объединены в одну: config-level конфликт управляется исключительно при reload/reconcile, runtime — исключительно при каждом выполнении запроса. Если бы они делили одну серию, любое успешное выполнение замороженного (config-level) воркера тут же стирало бы её, хотя desired-конфиг всё ещё не применён — а любой обычный reload, не трогающий схему конкретного запроса, мог бы наоборот стереть его же ещё не разрешённый runtime-конфликт.

Если конфликт схемы совпал с успешной сменой самой БД (в конфиге одновременно поменялись labels/value_column и url той же БД, и новый хост подключился) — замороженный (старый) SQL не выполняется на новом соединении: это может быть физически другая БД. Вместо этого воркер останавливается, метрика временно пропадает с /metrics.

Дополнительно публикуются метрики состояния пулов соединений:

app_db_pool_acquired_connections{db}   # сколько соединений сейчас занято
app_db_pool_idle_connections{db}       # сколько простаивает
app_db_pool_total_connections{db}      # всего открыто
app_db_connection_errors_total{db}     # счётчик ошибок подключения
app_db_up{db}                          # 1 если последний активный health check (ping) прошёл успешно, 0 если нет

app_db_up — раз в 30 секунд обновляется реальным PingContext к БД, а не просто фактом «когда-то подключились»: тихо умершая сеть отразится в метрике сама, не дожидаясь, пока на неё наткнётся какой-то из запросов. Дополнительно выставляется сразу при (пере)подключении, для быстрой реакции между циклами проверки. Отражает состояние активного соединения, а не «применился ли последний вариант конфига» — если БД когда-то подключилась, а новый конфиг для неё временно не смог (и старое соединение продолжает работать и отвечать на пинг) — метрика всё ещё покажет 1.

Пробы Kubernetes: /healthz и /readyz

Помимо /metrics, экспортёр отдаёт на том же порту:

GET /healthz   # 200 если процесс жив и обрабатывает HTTP-запросы
GET /readyz    # 200 если хотя бы один reload успешно применил структурно валидный конфиг, иначе 503

/readyz намеренно не зависит от доступности отдельных БД — недоступность одной БД штатно переживается (см. Переподключение к недоступным БД), и не должна выталкивать здоровый Pod из Service. Для этого есть свои метрики (app_db_connection_errors_total, app_query_up).

livenessProbe:
  httpGet:
    path: /healthz
    port: metrics
readinessProbe:
  httpGet:
    path: /readyz
    port: metrics

Hot-reload

Экспортёр следит за директорией конфига (не за отдельным файлом) через fsnotify и применяет изменения без перезапуска процесса. Это работает как при прямом редактировании файла, так и при обновлении ConfigMap в Kubernetes.

В Kubernetes kubelet обновляет файлы в Pod через атомарное переключение симлинка ..data на новую директорию — экспортёр реагирует на это событие напрямую. Слежение идёт именно за директорией, а не за файлом: такой вотч переживает переключение ..data без необходимости переподписываться заново.

Директории для отслеживания пересчитываются после каждого reload из реально прочитанных файлов (включая инклюды) — новый инклюд в ранее неотслеживаемой директории подхватывается сам, без рестарта Pod'а.

При reload:

  • Изменённые/новые запросы — воркеры пересоздаются.
  • Удалённые запросы — воркер останавливается, метрика убирается из Prometheus registry (не остаётся "замороженной" на графиках).
  • Изменённые БД — пул пересоздаётся только после успешного подключения к новому; если новое подключение не удалось, старый пул продолжает работать, а запросы на эту БД замораживаются на последнем рабочем конфиге (см. Переподключение к недоступным БД).
  • Невалидные отдельные запросы (нет db и нет default_db, несуществующая БД, плохой timeout) — пропускаются с логированием, но не блокируют загрузку остальных корректных запросов (см. Мягкая валидация).

Debounce 150мс защищает от множественных событий файловой системы при одном логическом изменении.

Hot-reload не работает через subPath. Если volume с конфигом смонтирован с subPath (монтирование одного файла вместо всей директории ConfigMap) — Kubernetes официально не доставляет туда обновления ConfigMap вообще, независимо от того, как долго ждать. Монтируй ConfigMap директорией целиком (как в Helm chart из этого репозитория) — точечный subPath для конфига этого экспортёра не поддерживается.

Если watcher не удалось установить или он перестал работать

Установка fsnotify-вотча на директорию конфига может не удаться — например при исчерпании системного лимита inotify (частая ситуация в Kubernetes при большом числе Pod'ов на одной ноде). Реже, но возможно и обратное: вотч был успешно установлен при старте, но сам fsnotify-вотчер позже внутренне умирает уже во время работы. Оба случая обрабатываются одинаково — по умолчанию это не фатально: экспортёр логирует ошибку и продолжает работать с тем конфигом, что успел загрузить последним — просто без возможности подхватывать дальнейшие изменения без рестарта Pod'а.

app_config_watcher_up 0   # 1 если hot-reload сейчас работает, 0 если недоступен — обновляется
                          # и при старте, и если watcher позже неожиданно умирает в рантайме

Если для твоей эксплуатации hot-reload — не опциональная возможность, а часть контракта (например ты полагаешься на него для ротации credentials без рестарта) — включи --watch-required: тогда при потере способности к hot-reload, будь то при старте или уже в процессе работы, процесс завершится с ненулевым кодом вместо тихой деградации в статический режим, и Kubernetes перезапустит Pod как обычно при падении. В Helm chart из этого репозитория — через values, а не ручное переопределение args: (см. Helm chart):

watchRequired: true

Helm chart

Структура values.yaml

config:
  settings:
    db_reconnect_interval: "5m"
    default_db: "main"

  include_defaults: {}     # маппинг файл → БД
  includes: {}              # map, не список!
  databases: {}
  queries: {}

configIncludes: {}          # содержимое инклюд-файлов прямо в values

Способ 1: папка config/ в chart

Удобно для локальной разработки, когда chart лежит рядом с кодом:

helm/sqlx-exporter/
└── config/
    ├── oracle.yaml
    └── mssql.yaml

Файлы из config/*.yaml автоматически читаются через Files.Glob в шаблоне configmap.yaml и попадают в ConfigMap. Достаточно перечислить их в config.includes values.

Способ 2: configIncludes в values

Нужен когда chart хранится в артефактном репозитории (Nexus, ChartMuseum) и физически недоступен для редактирования — тогда содержимое инклюд-файлов передаётся прямо через values:

# values-oracle.yaml
config:
  includes:
    "oracle-metrics.yaml": true

configIncludes:
  oracle-metrics.yaml: |
    queries:
      active_sessions:
        sql: "SELECT count(1) FROM v$session WHERE status = 'ACTIVE'"
        timeout: "10s"
        interval: "1m"

Оба способа работают одновременно — configIncludes из values имеет приоритет при совпадении имён файлов с тем, что лежит в config/.

ArgoCD multi-source

Типичная конфигурация когда chart в Nexus, а конфигурация метрик и инфраструктурные values — в отдельных Git-репозиториях:

apiVersion: argoproj.io/v1alpha1
kind: Application
spec:
  sources:
    - repoURL: https://nexus.company.com/repository/helm
      chart: sqlx-exporter
      targetRevision: 0.1.0
      helm:
        valueFiles:
          - $values/values.yaml
          - $values/values-oracle.yaml
          - $default/default_metrics/default_postgres.yaml
    - repoURL: https://gitlab.company.com/team/values-repo.git
      targetRevision: main
      ref: values
    - repoURL: https://gitlab.company.com/team/default-metrics.git
      targetRevision: main
      ref: default

Каждый valueFiles путь — это отдельный набор config.includes/configIncludes/databases, которые Helm мержит между собой. Благодаря тому что includes — map, набор инклюдов из разных репозиториев не перезатирает друг друга.

ExternalSecret для паролей деплоится отдельным ArgoCD Application с directory.include, так как Helm chart игнорирует файлы не входящие в его templates/:

spec:
  source:
    repoURL: https://gitlab.company.com/team/values-repo.git
    path: monitoring/sqlx-exporter
    directory:
      include: "externalsecret.yaml"

Переподключение к недоступным БД

Если при старте или reload подключение к БД не удалось — приложение не падает и не блокирует остальные запросы. Конфиг недоступной БД сохраняется отдельно, и фоновый процесс (poolHealthChecker) пытается переподключиться с заданным интервалом:

settings:
  db_reconnect_interval: "5m"   # default: 5m, можно поставить и "30s" для быстрого восстановления

При успешном переподключении воркеры для этой БД автоматически стартуют без необходимости делать reload конфига. Интервал можно менять в конфиге на горячую — следующий тик подхватит новое значение.

Если БД была рабочей, а новый конфиг для неё (например изменённый url) не подключился — старое соединение продолжает работать, а не разрывается. Но запросы, нацеленные на эту БД, при этом не переключаются на новый желаемый конфиг: новый SQL мог бы выполниться не там, где рассчитывал оператор. Если запрос уже выполнялся — он продолжает работать на старом SQL и старом соединении до успешного переподключения; если запрос новый — он просто не стартует, пока БД не станет доступна. Как только новый конфиг подключается — запросы естественным образом подхватывают его при следующей реконсиляции.


Мягкая валидация конфига

Валидация разделена на три уровня, от самого мягкого к самому строгому.

Сломанный инклюд (файл не читается или содержит невалидный YAML) — не блокирует остальной конфиг. Databases/queries именно из этого файла отсутствуют в применённом конфиге, всё остальное (включая другие инклюды) применяется как обычно:

ERROR include failed to load — its databases/queries are missing from this config, the rest of the config was still applied normally detail="\"oracle-metrics.yaml\" (db=azdh_oracle): yaml: ..."

Это осознанный контракт, а не временная деградация: отсутствие databases/queries из сломанного файла обрабатывается точно так же, как если бы их удалили из конфига намеренно — соответствующие пулы закрываются, воркеры останавливаются, их метрики (включая бизнес-метрики) снимаются с /metrics. Предыдущее рабочее состояние этого файла нигде не запоминается и не подставляется вместо сломанного. Если для мониторинга критична непрерывность конкретных метрик — отслеживайте app_config_include_failures отдельным алертом, не полагаясь на то, что старые данные останутся доступны, пока файл не почините.

Аналогично, если у инклюда есть запись в include_defaults, но список БД в ней пуст (file.yaml: []) — инклюд пропускается с явным логом, а не тихо игнорируется.

Проверка отдельных запросов (не критична) — каждый запрос в queries проверяется независимо. Если у одного конкретного запроса проблема (нет db и нет фолбэка, несуществующая БД, плохой timeout/interval/schedule, невалидное или зарезервированное имя метрики) — этот запрос исключается из конфига с явным логом, но все остальные корректные запросы продолжают загружаться и работать:

ERROR skipping invalid query reason="query \"my_query\" skipped: db is required (or set default_db / settings.default_db)"

Имя метрики (ключ запроса или явный metric_name:) должно соответствовать [a-zA-Z_][a-zA-Z0-9_]* — без :, который Prometheus технически допускает, но резервирует за recording rules. Дополнительно запрещены префиксы app_, process_, go_, scrape_ — первый занят собственными метриками экспортёра (app_query_up и т.п.), остальные три — встроенными коллекторами client_golang/самим Prometheus-сервером при скрейпе. Запрос с таким именем либо не пройдёт валидацию явно (как здесь), либо, без этой проверки, рисковал бы либо получить ошибку регистрации в рантайме, либо — что хуже — незаметно начать писать свои значения в чужую, уже существующую метрику с тем же именем и набором лейблов.

Критичные проверки (обрывают весь reload, если не пройдены) — структура секции databases: отсутствующий driver/url, неизвестный driver, некорректные durations, max_idle_conns > max_conns, settings.default_db указывает на несуществующую БД.

Это намеренное архитектурное решение: одна опечатка в одном запросе, и даже целиком сломанный инклюд, не должны останавливать сбор сотен остальных метрик.

Метрики состояния reload

app_config_reload_total{result}                              # счётчик попыток: success|partial_success|load_error|invalid_config
app_config_last_reload_timestamp_seconds                      # unix-время последней ПОПЫТКИ reload, независимо от результата
app_config_last_reload_success_timestamp_seconds              # unix-время последнего успешного (в т.ч. partial_success) reload
app_config_include_failures                                   # сколько инклюдов не удалось загрузить в последнем reload

result=partial_success — reload прошёл, но хотя бы один инклюд не загрузился. Отличать от success важно: раньше наличие сломанного инклюда не отражалось в результате reload, и это легко было пропустить, если смотреть только на факт «reload complete» в логах.


Метрики самого приложения

app_internal_invariant_violations_total{invariant}   # должна быть всегда 0

Не путать с операционными ошибками (недоступная БД, упавший запрос) — ненулевое значение означает баг в самом экспортёре, а не проблему со средой/конфигом/БД. Стоит алертить на неё отдельно и жёстче, чем на остальные метрики ошибок.

Помимо кастомных метрик, на /metrics доступны стандартные метрики Go runtime и процесса (регистрируются автоматически библиотекой client_golang, без явного кода):

go_memstats_heap_inuse_bytes      # heap занятый живыми объектами
go_memstats_heap_idle_bytes       # heap зарезервированный но свободный
go_goroutines                     # количество живых горутин
go_gc_duration_seconds            # длительность пауз GC
process_resident_memory_bytes     # RSS процесса
process_open_fds                  # открытые файловые дескрипторы

Полезны для диагностики потребления ресурсов самого экспортёра, отдельно от метрик которые он собирает с БД.


Память и производительность

GOGC/GOMEMLIMIT намеренно не заданы в самом образе (Dockerfile) — эти значения имеют смысл только в привязке к resources.limits.memory конкретного деплоя, а образ должен оставаться универсальным, не зашивать число, подходящее только для одной, конкретной конфигурации. Задаются через env: в Helm values (helm/sqlx-exporter/values.yaml):

resources:
  limits:
    memory: "64Mi"

env:
  - name: GOGC
    value: "50"          # GC запускается вдвое чаще дефолта (100) — меньше пиковый heap,
                          # небольшой рост CPU; не задавай не измерив на своей нагрузке
  - name: GOMEMLIMIT
    value: "55MiB"        # заметно ниже resources.limits.memory, не впритык — GOMEMLIMIT
                          # управляет только памятью, которую отслеживает сам рантайм Go,
                          # не всем RSS процесса, легко словить OOMKill если выставить вплотную

GODEBUG=madvdontneed=1 не имеет смысла задавать вообще — начиная с Go 1.16 MADV_DONTNEED уже используется на Linux по умолчанию (раньше рантайм использовал MADV_FREE, из-за которого освобождённые страницы могли надолго оставаться в RSS процесса и выглядеть как "утечка" на графиках). Эта переменная больше ничего не меняет в текущих версиях Go.

Типичное потребление для конфигурации с несколькими БД и десятками запросов — 15–20 МБ resident memory после прогрева runtime в первые минуты после старта Pod.

License

Apache License 2.0 — см. LICENSE. Copyright © 2026 Nikita Riabov.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages