Prometheus-экспортёр на Go: периодически выполняет SQL-запросы к базам данных и публикует результаты как метрики на /metrics. Поддерживает PostgreSQL, MySQL, MSSQL и Oracle, hot-reload конфига без рестарта Pod, multi-row метрики с динамическими лейблами и гибкую систему инклюдов для конфигурации в Kubernetes.
- Быстрый старт
- Поддерживаемые БД
- Формат конфигурации
- Запросы: single-value и multi-row
- Расписание (schedule)
- БД по умолчанию (default_db)
- Инклюды конфига
- Подключение к Oracle
- Переменные окружения в конфиге
- Health-метрики и мониторинг состояния
- Hot-reload
- Helm chart
- Переподключение к недоступным БД
- Мягкая валидация конфига
- Метрики самого приложения
- Память и производительность
- License
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).
Карта подключений. Ключ — произвольное имя, используется в 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.
Карта запросов. Ключ — имя запроса, становится именем метрики в 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: встретится в инклюде (подключённом файле), он будет проигнорирован с явным предупреждением в логе, а не тихо применится:
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 (по умолчанию) — 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, а не зависнет с последним известным значением.
При ошибке запроса (таймаут, обрыв соединения) все метрики предыдущего успешного запуска удаляются — устаревшие данные не остаются на дашбордах молча.
Вместо 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 взаимоисключающие — указывается либо один, либо другой.
Если у запроса не указано db:, оно подставляется по следующему приоритету (от самого специфичного к самому общему):
- Явный
db:внутри самого запроса — высший приоритет. - Локальный
default_db:в корне файла, где определён запрос (см. инклюды ниже). default_db, унаследованный от родительского файла (если дочерний файл не задал свой).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, поэтому ключи обрабатываются в отсортированном порядке для детерминированности.
Файл с метриками можно сделать полностью независимым от конкретной БД — без 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: внутри запроса по-прежнему допустим и просто побеждает: дублирования там не возникает, потому что файл в этом случае применяется только один раз.
databases:
myoracle:
driver: "oracle"
url: "oracle://user:${ORACLE_PASS}@host:1521/service_name"Если у вас есть 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}), не из файлов.
Для каждого запроса автоматически публикуются четыре служебные метрики, удобные для алертинга:
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.
Помимо /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Экспортёр следит за директорией конфига (не за отдельным файлом) через 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 для конфига этого экспортёра не поддерживается.
Установка 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: trueconfig:
settings:
db_reconnect_interval: "5m"
default_db: "main"
include_defaults: {} # маппинг файл → БД
includes: {} # map, не список!
databases: {}
queries: {}
configIncludes: {} # содержимое инклюд-файлов прямо в valuesУдобно для локальной разработки, когда chart лежит рядом с кодом:
helm/sqlx-exporter/
└── config/
├── oracle.yaml
└── mssql.yaml
Файлы из config/*.yaml автоматически читаются через Files.Glob в шаблоне configmap.yaml и попадают в ConfigMap. Достаточно перечислить их в config.includes 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/.
Типичная конфигурация когда 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 указывает на несуществующую БД.
Это намеренное архитектурное решение: одна опечатка в одном запросе, и даже целиком сломанный инклюд, не должны останавливать сбор сотен остальных метрик.
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.
Apache License 2.0 — см. LICENSE. Copyright © 2026 Nikita Riabov.