Loadable-плагин для Zabbix c помощью суслика
- пятница, 7 ноября 2025 г. в 00:00:10
Привет, Хабр!
Знаете это чувство, когда оборудование есть, мониторинг есть, а их совместная работа — нет? Именно так мы ощутили себя, когда столкнулись с IBM Storwize в экосистеме Zabbix. «Из коробки» поддержка отсутствует, а костыли в виде скриптов и UserParameters работают так, что хочется плакать:
медленно: каждый раз запускается отдельная программа/команда — это тратит время и ресурсы.
сложно отлаживать: если что‑то сломалось, трудно понять, где именно проблема — в скрипте, в настройках Zabbix или в связи.
плохо масштабируется: если у вас 10 таких хранилищ, придётся копировать и настраивать эти скрипты для каждого, а потом следить, чтобы все работали одинаково.
Пора было что‑то менять.
В процессе изучения темы попался отличный материал: про то, как устроен агент, и как правильно писать плагины. И хорошая новость — после 2020 года Zabbix серьёзно облегчил эту задачу. Новые инструменты позволяют: быстрее стартовать (готовые шаблоны и примеры), писать меньше кода (API берёт рутину на себя), проще тестировать (встроенная отладка). Теперь на разработку плагина уходит в разы меньше времени.
В этой статье мы расскажем, как создать загружаемый (loadable) плагин, который будет работать как полноценный компонент Zabbix Agent 2 — без пересборки всего агента, с поддержкой конфигурации и логирования. Более того, покажем все на реальном примере — мониторинге Storwize через [WBEM].

Теоретический блок ответит на вопросы «зачем?», «почему?» и «из чего все это состоит?»
Практический блок – мануал из 10 шагов
Встроенных возможностей Zabbix обычно хватает на 80%. Но остаются кейсы, где без кастома не обойтись. По нашему опыту, в таких ситуациях обычно нужно:
достать данные из нестандартного сервиса (например, внутреннего API или legacy‑системы);
собрать метрики по специфическому протоколу (не поддерживаемому стандартными шаблонами);
унифицировать сбор данных для группы разнородных устройств;
избежать «зоопарка» внешних скриптов, которые сложно поддерживать и отлаживать.
Плагин — это не костыль, а штатный механизм расширения Zabbix Agent 2. Вы получаете не «заплатку», а поддерживаемое, документированное решение, которое легко передавать коллегам и включать в CI/CD‑пайплайны.
По сравнению с версией 6.0 появилось два способа написания плагина:
built-in plugins — агент Zabbix, который требует перекомпиляции самого агента;
loadable plugins — отдельные бинарные файлы, которые подключаются без пересборки всего агента.
Это очень удобно: плагин хранится в отдельном репозитории и загружается агентом динамически. Теперь, чтобы добавить собственные метрики, не нужно перекомпилировать весь Zabbix Agent — достаточно собрать плагин и указать путь до него.
Loadable-плагины поддерживают три интерфейса: Exporter, Runner, Configurator. Watcher и Collector здесь недоступны. Рассмотрим доступные поближе.
plugin.Exporter отвечает за опрос метрик.
В плагине количество задач ограничено. Число конкурентных задач задается в конфиге или с помощью методаSetMaxCapacity():
type Exporter interface {
Export(key string, params []string, context ContextProvider) (interface{}, error)
}Дадим описание параметров:
key — ключ метрики;
params — передаваемые параметры в ключе;
context — метаданные о ключе.
Стоит учитывать особенности plugin.Exporter:
единственный интерфейс, выполняющийся конкурентно (до 1000 параллельных вызовов, настраивается через Capacity);
реализует polling, то есть срабатывает только по запросу агента.
plugin.Runner отвечает за инициализацию и корректное завершение работы плагина:
type Runner interface {
Start()
Stop()
}plugin.Configurator позволяет загружать и проверять параметры конфигурации плагина из zabbix_agent2.conf. Агент не запустится, если конфиг невалиден:
type Configurator interface {
Configure(globalOptions *GlobalOptions, privateOptions interface{})
Validate(privateOptions interface{}) error
}Теперь поговорим про взаимодействующие компоненты. Начнем с архитектурной части zabbix-agent2 - Scheduler. Он управляет очередью задач в соответствии с расписанием и настройками конкурентности. У планировщика есть pluginHeap — очередь агентов, отсортированная по временным меткам их следующих запланированных задач.
У каждого плагина есть внутренняя очередь задач performerHeap. Планировщик держит список «активных плагинов», которые могут выполнять работу.
Если плагин достиг лимита (maxCapacity) и не может обработать новую задачу, он временно исключается из активной очереди. Но сами задачи (например, вызовы Export()) при этом не теряются — они остаются в очереди плагина.
Как только освобождается слот (падает количество активных горутин), планировщик снова возвращает плагин в работу, и накопившиеся задачи выполняются.
В течение каждой секунды планировщик обрабатывает разные типы задач в фиксированном порядке. Приоритет к каждой задаче определяется в наносекундах.
ConfiguratorTask — загрузка конфигурации для плагина.
StarterTask — запуск плагина.
CollectorTask — выполнение функций из интерфейса Collector.
WatcherTask — обработка запросов из Watcher.
ExporterTask / DirectExporterTask — опрос метрик через Exporter.
StopperTask — остановка плагина.
taskBase реализует общие свойства и функциональность задач.
Следующая компонента — LoadablePlugin — внешний плагин, который находится в директории go/plugins/external. Реализует Configurator, Exporter, Runner. В методах осуществляется проверка, реализован ли интерфейс в вашем плагине.
Controller — логика взаимодействия LoadablePlugin и PluginContainer.
Плагины для Zabbix Agent 2 взаимодействуют с агентом не напрямую, а в виде внешних процессов. Именно здесь на помощь приходит PluginContainer — промежуточный слой, обеспечивающий взаимодействие между Zabbix Agent 2 и вашим плагином, а также это часть официального Go SDK от Zabbix. Он выступает в роли посредника и принимает запросы от агента, передаёт их плагину, обрабатывает ответ и возвращает результат обратно.
Основные компоненты пакета — это конструктор NewHandler() и метод Execute().
Когда Zabbix Agent 2 запускает плагин, он передаёт путь к сокету в качестве аргумента командной строки. NewHandler() считывает этот аргумент и устанавливает соединение с агентом. Вызов Execute() запускает бесконечный цикл, в котором вызывается handle() — основной обработчик входящих запросов от агента.
Handle() считывает данные из соединения и обрабатывает их в соответствии с типом запроса.
Plugin — это код, в котором вы реализуете интерфейсы и пишете бизнес-логику: опрос метрик, инициализацию соединений, работу с конфигурацией.
Теперь посмотрим, как это работает изнутри.
Первый этап плагина — его регистрация (registration). Для этого приводим схему:

Тут проходит проверка совместимости плагина с самим агентом. Процесс выглядит вот так:
агент инициализирует LoadablePlugin (Initialize())
вызывает RegisterMetrics()
выполняется register request — в процессе проверяется соответствие имени плагина определенным критериям. Затем возвращаются зарегистрированные метрики и реализованные интерфейсы.
если у вас реализован Configurator, то выполняется validate request для отправки конфига. PluginContainer вызывает Validate().
при возникновении ошибки LoadablePlugin вызывает kill.
Вторым шагом происходит запуск (startup). Схема:

Здесь выполняются технические операции. Выглядит это так:
если реализован Configurator, вызывается Configure();
затем — Start() из интерфейса Runner.
На этом шаге плагин готов к работе.
Переходим к выполнению запросов (operation). Схема:

Здесь происходит сбор метрик.
Когда серверу нужны данные, агент вызывает Export().
Выполняется export request, передаются key, params.
Тут у нас только два сценария: возвращаются данные (значение) или ошибка. Вне зависимости от результата, продолжаем работать.
Переходим к логированию (logging) — схема:

Плагин в любой момент может писать в лог агента Debug(), Info(), Error() и прочее.
Переходим к заключительному этапу — к завершению работы (shutdown). Схема:

При остановке агента или выгрузке вызывается Stop(), где можно закрыть соединения и освободить ресурсы.
Мы внимательно посмотрели на жизненный цикл плагина и теперь понимаем принципы взаимодействия плагина и агента. Для инженеров: чтобы наш будущий самописный плагин заработал, необходимо соблюдать строгую последовательность: registration -> startup -> operation -> logging -> shutdown.
Все плагины настраиваем с помощью параметра Plugins.* Он может быть частью файла конфигурации как Zabbix агента 2, так и самого плагина. Если плагин использует отдельный файл конфигурации, путь к нему указываем в параметре Include файла Zabbix агента 2. Более подробно можно прочитать здесь.
- Параметры должны иметь структуру: Plugins.<ИмяПлагина>.<Параметр>=<Значение>
- можно задавать значения по умолчанию: Plugins.<ИмяПлагина>.Default.<Параметр>=<Значение>
- можно задавать именованные сессии: Plugins.<ИмяПлагина>.<ИмяСессии>.<Параметр>=<Значение>
Обращаем внимание на требования, иначе конфиг окажется невалидным и плагин не запустится:
пишем имена для параметров плагина с большой буквы,
не используем специальные символы,
указываем количество параметров.
Скажем несколько слов о приоритете параметров. Если мы упустим это из виду, у нас может переопределиться значение параметра.
Сначала проверяем ключ элемента данных.
Если его нет — ищем в именованной сессии.
Если всё ещё не находим, берём значение по умолчанию. Примечание инженера: в конфиге можно задавать как значение, так и значение по умолчанию.
И только в самом конце учитываем значение в коде.
Когда мы проговорили основные моменты, приступаем к практике. В качестве примера мы взяли свою боевую задачу — мониторинг хранилища storwize.
Берем во внимание единственное требование — Zabbix Agent 2 версии 6.0.0 или новее
Устанавливаем компилятор Go, создаем каталог проекта и инициализируем go.mod:
cd ~/plugins/storwize
go mod init storwize
Шаг 1 - загружаем зависимость: go get golang.zabbix.com/sdk@$LATEST_COMMIT_HASH
Где // LATEST_COMMIT_HASH - хэш последнего коммита
Примеры можно посмотреть тут.
Файл с конфигурацией, связанный с плагином, рекомендуем хранить с проектом. Поэтому создаем в корневом каталоге файл storwize.conf со следующей конфигурацией:
Итоговая структура получается вот такой:
go-storwize-plugin/
├── plugin/
│ ├── handler/
│ ├── config.go
│ ├── conn.go
│ ├── metrics.go
│ └── storwize.go
├── main.go
├── go.mod
└── storwize.confОпределяем имя плагина и пишем структуру, в которую встраиваем plugin.Base — «скелет» каждого плагина.
На выходе мы получаем в своё распоряжение встроенный функционал, который делает ваш плагин «дочерней» сущностью в экосистеме агента. На всякий случай описываем поля, чтобы никто не запутался:
type Base struct {
log.Logger
name string // имя плагина
maxCapacity int // Максимальное количество одновременных вызовов Export()
external bool // Флаг, указывающий, что плагин внешний (loadable)
handleTimeout bool // Управляет обработкой таймаутов. Если true, агент будет прерывать выполнение по таймауту
}
Встраиваем в нашу структуру дочерней сущности:
const (
Name = "Storwize"
)
var (
_ plugin.Exporter = (*storwize)(nil)
_ plugin.Runner = (*storwize)(nil)
_ plugin.Configurator = (*storwize)(nil)
)
type storwize struct {
plugin.Base
config *PluginConfig
metrics map[storwizeMetricKey]*storwizeMetric
connectionsManager *ConnectionsManager
}Необходимо определить метрики, зарегистрировать их и инициализировать PluginContainer:
func Launch() error {
p := &storwize{
config: &PluginConfig{},
metrics: make(map[storwizeMetricKey]*storwizeMetric),
}
err := p.registerMetric()
if err != nil {
return err
}
h, err := container.NewHandler(Name)
if err != nil {
return errs.Wrap(err, "failed create new handler")
}
p.Logger = h
err = h.Execute()
if err != nil {
return errs.Wrap(err, "failed to execute plugin handler")
}
return err
}Помним, что аргументы должны передаваться в строгой последовательности: plugin.RegisterMetrics(<p plugin.Accessor>, <plugin name string>, <item key string>, <description>)
Описание метрики (ВАЖНО!) начинаем строго с большой буквы и заканчиваем точкой.
Лирическое отступление:
Автор этого текста просидел целый день над задачей, не понимая, что не так. Совершенно не получалось регистрировать метрики. Но потом автор познал великую истину большой буквы и точки в конце. Возвращаемся к мануалу.
const (
keyVolumeDiscovery = "svc.volume.discovery"
keyVolumeGetMetric = "svc.volume.get"
)
type storwizeMetricKey string
type storwizeMetric struct {
metric *metric.Metric
}
func (p *storwize) registerMetric() error {
p.metrics = map[storwizeMetricKey]*storwizeMetric{
keyVolumeDiscovery: {
metric: metric.New(
"Retutn discovery volume.",
p.getParams(),
true,
),
},
keyVolumeGetMetric: {
metric: metric.New(
"Return volume metric.",
p.getParams(),
false,
),
},
}
metricSet := metric.MetricSet{}
for k, m := range p.metrics {
metricSet[string(k)] = m.metric
}
err := plugin.RegisterMetrics(p, Name, metricSet.List()...)
if err != nil {
return errs.Wrap(err, "failed to register metrics")
}
return err
}
func (p *storwize) getParams() []*metric.Param {
return []*metric.Param{
metric.NewParam(addressParam, "Address for wbem connection.").SetRequired(),
metric.NewParam(userParam, "User for WBEM connector.").
WithDefault(DefaultUser),
metric.NewParam(passwordParam, "Password for wbem connection.").
WithDefault(""),
metric.NewParam(schemaParam, "Schema http or https.").WithDefault(DefaultSchema),
}
}type session struct {
User string `conf:"optional"`
Password string `conf:"optional"`
}
type PluginConfig struct {
System plugin.SystemOptions `conf:"optional,name=System"` //nolint:staticcheck
// Timeout.
Timeout int `conf:"optional,range=1:30"`
// Sessions stores pre-defined named sets of connections settings.
Sessions map[string]session `conf:"optional"`
}
func (p *storwize) Configure(global *plugin.GlobalOptions, option any) {
pConf := &PluginConfig{}
err := conf.UnmarshalStrict(option, pConf)
if err != nil {
p.Errf("cannot unmarshal configuration options: %s", err.Error())
return
}
p.config = pConf
if p.config.Timeout == 0 {
p.config.Timeout = global.Timeout
}
}
func (*storwize) Validate(options any) error {
var opts PluginConfig
err := conf.UnmarshalStrict(options, &opts)
if err != nil {
return errs.Wrap(err, "failed to unmarshal configuration options")
}
return nil
}В методах Start() и Stop() управление пулом коннектов происходит инициализация и освобождение ресурсов:
func (p *storwize) Start() {
// create connection WBEM
p.connectionsManager = NewConnections()
p.Logger.Infof("start plugin %s", Name)
}
func (p *storwize) Stop() {
p.connectionsManager.Close()
p.Logger.Infof("stop plugin %s", Name)
}Именно тут необходимо определить, какая функция будет вызвана в зависимости от ключа и передаваемых параметров:
func (p *storwize) Export(
key string, params []string, _ plugin.ContextProvider,
) (any, error) {
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
m, ok := p.metrics[storwizeMetricKey(key)]
if !ok {
return nil, errs.Wrapf(
zbxerr.ErrorUnsupportedMetric, "unknown metric %q", key,
)
}
metricParams, _, _, err := m.metric.EvalParams(params, p.config.Sessions)
if err != nil {
return nil, errs.Wrap(err, "failed to evaluate metric parameters")
}
conf := connConfig{
Address: metricParams[addressParam],
User: metricParams[userParam],
Password: metricParams[passwordParam],
Namespace: DefaultNamespace,
Timeout: DefaultTimeout,
Schema: metricParams[schemaParam],
}
conn, err := p.connectionsManager.Get(conf)
if err != nil {
return nil, errs.Wrap(err, "connection not found or failed connection")
}
handlerFunc, err := p.getHandler(key)
if err != nil {
return nil, errs.Wrap(err, "err get function")
}
result, err := handlerFunc(ctx, conn)
if err != nil {
return nil, errs.Wrap(err, "error get data")
}
return result, nil
}Также не забываем, что Export — это единственный метод, который вызывается конкурентно. Поэтому необходимо синхронизировать доступ к данным. В нашем случае нужно получить коннектор gowbem.WBEMConnection:
func (c *ConnectionsManager) getConn(addr connConfig) *gowbem.WBEMConnection {
c.mu.RLock()
defer c.mu.RUnlock()
conn, ex := c.connections[addr]
if !ex {
return nil
}
conn.lastTimeAccess = time.Now()
return conn.conn
}PATH_TO_STORWIZE_EXECUTABLE=~/projects/plugins/go-storwize-plugin/storwize
Сначала собираем проект:
go mod tidy
go build -o $PATH_TO_STORWIZE_EXECUTABLE main.go
Теперь к конфигурации. Создаем файл и заполняем storwize.conf:
### Option:Plugins.Storwize.System.Path
# Path to external plugin executable.
#
# Mandatory: yes
# Default:
Plugins.Storwize.System.Path=~/projects/plugins/go-storwize-plugin/storwize
### Option: Plugins.Storwize.Timeout
# Example of a default timeout set in configuration if not , timeout for my ip call
#
# Mandatory: no
# Range: 1-30
# Default:
Plugins.Storwize.Timeout=10Копируем файл в конфиг агента:
cp ~/projects/plugins/go-storwize-plugin/storwize.conf /etc/zabbix_agent2.d/plugins.d/storwize.conf
Для регистрации нашего плагина в очереди обязательно перезагружаем агента: systemctl restart zabbix-agent2
zabbix_agent2 -t svc.volume.discovery[10.0.80.40,zabbix,Password]
На выходе получается такой вид:
svc.volume.discovery[10.0.80.40,zabbix,Password][s|[{"{#TYPE}":"volume","{#NAME}":"OGG_DB_DATA05","{#ID}":"0"},{"{#TYPE}":"volume","{#NAME}":"ESX_AZSLOCDB","{#ID}":"1"},{"{#TYPE}":"volume","{#NAME}":"OGG_DB_DATA04","{#ID}":"2"}]]
Если вам понадобится исправить что- то в коде, пересоберите Loadable-плагин.
Мы собрали Loadable-плагины в Zabbix 7.0 и увидели, что:
не нужно пересобирать весь zabbix-agent2;
можно подключать и обновлять плагины как отдельные бинарники;
код становится чище и поддерживаемее по сравнению с UserParameter или внешними скриптами;
легко масштабировать и адаптировать под нестандартные сервисы и протоколы.
Loadable-плагин можно интегрировать в агент как полноценный компонент: с конфигурацией, логированием и безопасным управлением соединениями.
По сути, loadable-плагины делают экосистему Zabbix открытой и расширяемой, а разработчику дают простой SDK, с которым можно писать собственные интеграции под любые нужды.
Если у вас в инфраструктуре есть «нестандартное железо» или сервисы, для которых нет готовых шаблонов, loadable-плагин — это тот самый инструмент, который позволит интегрировать их в Zabbix без костылей.