Привет, Хабр!

Знаете это чувство, когда оборудование есть, мониторинг есть, а их совместная работа — нет? Именно так мы ощутили себя, когда столкнулись с 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‑пайплайны.

Что нового в Zabbix 7.0?

По сравнению с версией 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()) при этом не теряются — они остаются в очереди плагина.

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

В течение каждой секунды планировщик обрабатывает разные типы задач в фиксированном порядке. Приоритет к каждой задаче определяется в наносекундах.

  1. ConfiguratorTask — загрузка конфигурации для плагина.

  2. StarterTask — запуск плагина.

  3. CollectorTask — выполнение функций из интерфейса Collector.

  4. WatcherTask — обработка запросов из Watcher.

  5. ExporterTask / DirectExporterTask — опрос метрик через Exporter.

  6. 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.

Пошаговый мануал

Шаг 1. Пишем конфиг

Все плагины настраиваем с помощью параметра Plugins.* Он может быть частью файла конфигурации как Zabbix агента 2, так и самого плагина. Если плагин использует отдельный файл конфигурации, путь к нему указываем в параметре Include файла Zabbix агента 2. Более подробно можно прочитать здесь.

    - Параметры должны иметь структуру: Plugins.<ИмяПлагина>.<Параметр>=<Значение>

    - можно задавать значения по умолчанию: Plugins.<ИмяПлагина>.Default.<Параметр>=<Значение>

    - можно задавать именованные сессии: Plugins.<ИмяПлагина>.<ИмяСессии>.<Параметр>=<Значение>

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

  • пишем имена для параметров плагина с большой буквы,

  • не используем специальные символы,

  • указываем количество параметров.

Скажем несколько слов о приоритете параметров. Если мы упустим это из виду, у нас может переопределиться значение параметра.

  1. Сначала проверяем ключ элемента данных.

  2. Если его нет — ищем в именованной сессии.

  3. Если всё ещё не находим, берём значение по умолчанию. Примечание инженера: в конфиге можно задавать как значение, так и значение по умолчанию.

  4. И только в самом конце учитываем значение в коде.

Когда мы проговорили основные моменты, приступаем к практике. В качестве примера мы взяли свою боевую задачу — мониторинг хранилища 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 - хэш последнего коммита

Шаг 2. Определяем структуру проекта

Примеры можно посмотреть тут.

Файл с конфигурацией, связанный с плагином, рекомендуем хранить с проектом. Поэтому создаем в корневом каталоге файл storwize.conf со следующей конфигурацией:

Итоговая структура получается вот такой:

go-storwize-plugin/
├── plugin/
│   ├── handler/
│   ├── config.go
│   ├── conn.go
│   ├── metrics.go
│   └── storwize.go
├── main.go
├── go.mod
└── storwize.conf

Шаг 3. Импортируем SDK

Определяем имя плагина и пишем структуру, в которую встраиваем 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
}

Шаг 4. Выполняем регистрацию

Необходимо определить метрики, зарегистрировать их и инициализировать 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
}

Шаг 5. Регистрация метрик

Помним, что аргументы должны передаваться в строгой последовательности: 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),
	}
}

Шаг 6. Реализуем интерфейс Configurator

Скрытый текст
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
}

Шаг 7. Теперь к интерфейсу Runner

В методах 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)
}

Шаг 8. Реализуем интерфейс Exporter

Именно тут необходимо определить, какая функция будет вызвана в зависимости от ключа и передаваемых параметров:

Скрытый текст
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
}

Шаг 9. Собираем и конфигурируем

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

Шаг 10. Тестируем плагин и его элементы данных

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 без костылей.

Комментарии (0)