Всем привет.
С момента публикации статьи про «В меру Универсальное Устройство Управления» прошло немало времени (а если быть точным, больше года). Немало, но недостаточно много, чтобы я таки написал нормальную программную начинку для этого устройства. Ведь не для красоты ж оно есть — оно должно собирать данные с датчиков и делать так, чтобы эти данные оказывались в системе мониторинга (в моём случае Zabbix)
Часть первая — software
За прошедшее время из программной начинки было реализовано следующее:
- Тестовый скрипт для демонстрации, что всё подключенное работает
- Скрипт для заббикса, чтобы собирать показания с термодатчиков
Были попытки написать отдельные мониторилки для ntpd и для gpsd. Много времени было потрачено на супер-мониторилку, которая должна была уметь читать конфиг, запускать процессы сбора данных из различных источников согласно конфигу, собирать данные из этих процессов и выводить на экранчик показания, одновременно давая возможность заббиксу читать эти данные. По факту получилось реализовать диспетчер процессов, который читал конфиг и плодил нужные процессы, и рисовалку на экране, которая получилась весьма крутой — даже умеет читать layout из конфига и менять содержимое экрана по таймеру, при этом собирая данные от процессов в тот момент, когда они нужны. Нет в этой супер-мониторилке только одного — собственно процессов, которые бы собирали данные. Ну и плюс были идеи сделать систему сигналов, чтобы функции кнопкам назначать, учитывать приоритеты разных источников данных, ну и так далее, но всё упёрлось в свободное время и в то, что эта супер-мониторилка получалась уж очень раздутой и монструозной.
На какое-то время я забил на разработку полноценной программной начинки. Ненуачо, скрипт же работает, а правило «работает — не трожь», как говорят, святое правило администратора. Но вот ведь незадача — чем больше хочется мониторить, тем больше надо скриптов писать и тем больше надо добавлять исключений в SELinux для заббикса (я ж не только raspi мониторю) — в дефолтной политике заббиксу (как и rsyslog, например) запрещено вызывать произвольные программы, и это понятно. Отключать SELinux для заббикса совсем или писать свою политику под каждый бинарник, который будет дёргаться, очень не хотелось. Поэтому пришлось думать.
А давайте вообще разберёмся, как можно собирать данные в систему мониторинга:
- По тому, кто инициатор:
- Активный мониторинг — наблюдаемый узел инициирует передачу данных (push)
- Пассивный мониторинг — наблюдающий узел инициирует передачу данных (pull)
- По методу сбора данных:
- Через агента на наблюдаемом узле, используя только поддерживаемые агентом метрики
- Через агента на наблюдаемом узле, расширяя агент скриптами
- По SNMP
- Примитивный ping
- По telnet
- … и так далее
Я использую pull-мониторинг, не по религиозным причинам, а просто так сложилось. На самом деле разницы между push и pull немного, особенно на малых нагрузках (на одной из предыдущих работ делал Nagios+NSCA, большой разницы не заметил, элементы всё равно руками создавать). Можно было бы использовать zabbix_sender, если бы у меня уже был push-мониторинг, но его нет, а на нет и суда нет, а мешать одно с другим как-то неаккуратненько. А вот в вопросе, по какому протоколу мониторить, вроде бы выбор большой, да не очень — discovery поддерживается только через агента или через SNMP, что оставляет нас уже только с двумя вариантами. Агент отпадает из-за описанной проблемы с SELinux. Вуаля, у нас остаётся pull-мониторинг через SNMP.
Урраа! А чего ура-то? В линуксе вроде как есть snmpd, но как заставить его отдавать то, что нам нужно, но о чём snmpd не имеет ни малейшего понятия? Оказывается, у snmpd есть целых 3 (принципиально различных) способа отдавать произвольные данные по произвольным OIDам:
- Запустить внешний скрипт (директивы exec/sh/execfix/extend/extendfix/pass/pass_persist) — плохо из-за потенциальных проблем с SELinux и из-за того, что неконтролируемая куча скриптов в конечном итоге превратится в свалку. Да и говорят, у pass_persist всё плохо с передачей бинарных данных. Не знаю, может, бессовестно врут, но мне идея плодить миллион скриптов в любом случае не нравилась;
- Написать что-то на встроенном perl или загрузить .so — не знаю и не хочу знать перл, не хочу писать so-шки, я ж не программист, чтобы на С писать;
- Получить данные у внешнего агента (proxy, SMUX, AgentX) — а вот это звучит хорошо, loose coupling же, от языка не зависит. Давайте разбираться:
- proxy — запросить OID у SNMP-агента на указанном узле. Надо реализовывать целиком протокол SNMP, что мне совершенно ни к чему, да и зачем запрашивать что-то у другого узла, задействовать сеть, когда я хочу данные локально получить. Я знаю про существование 127.0.0.1, но в любом случае, реализовывать SNMP не улыбается совершенно;
- SMUX — нужна поддержка протокола smux в вызывающем агенте в том числе, а man говорит, что по умолчанию net-snmp собирается без поддержки smux (и так уже ntpd пересобирать для поддержки pps, ещё и пересобирать net-snmp на raspi не улыбается). Да и smux этот — всего лишь обёртка для пакетов SNMP, просто добавлена возможность для субагента зарегистрироваться на агенте;
- AgentX — по сути то же, что и SMUX, только протокол проще, а пакет легче. Ну и вкомпилен по умолчанию в net-snmp, что тоже приятно. Звучит как наш выбор.
Я пишу на питоне, поэтому пошёл искать, а не реализовал ли кто уже протокол agentx. И ведь нашлись такие хорошие люди — https://github.com/rayed/pyagentx и https://github.com/pief/python-netsnmpagent. Второй проект вроде поживее, но первый показался проще. Я начал с первого (pyagentx), он работает и делает всё, что надо. Но вот когда я стал думать, а как в эту библиотеку передавать данные, захотелось таки разобраться со вторым пакетом (python-netsnmpagent). Проблема с pyagentx заключается в том, что так, как оно написано, оно не может получать данные от вызывающих функций, а следовательно, запрос свежих данных должен происходить прямо в функции, которая посылает обновления в snmpd, что не всегда удобно и не всегда возможно. Можно было, конечно, отпочковать что-то своё и переопределить функции, но по сути пришлось бы переписать класс почти целиком, чего делать также не хотелось — на коленке ж разрабатываем, всё должно быть просто и быстро. Однако нежелание разбираться с python-netsnmpagent таки победило и я нашёл способ передать данные в updater из pyagentx, но об этом ниже.
Следующий вопрос был такой — а как должна выглядеть архитетура? Попытка написать диспетчер, форкающий источники данных и читающий данные из них, уже была и закончилась не очень хорошо (см. выше), так что было решено отказаться от реализации диспетчера. И так удачно сложилось, что то ли я где-то увидел статью про systemd, то ли просто в очередной раз пощекотало давнее желание разобраться с ним поближе, и я решил, что диспетчером в моём случае будет systemd. Haters gonna hate, а мы будем разбираться, коли оно уже даже на raspi из коробки есть.
Какие полезные возможности systemd для себя я обнаружил:
- Бесплатная демонизация — пишем юнит службы с типом simple (или notify) и получаем демона, не написав ни строчки кода для этого. Прощайте python-daemon и/или daemonize
- Автоматический перезапуск упавших юнитов — ну тут комментарии излишни, спасает от непостоянных ошибок
- Сокет-активация и вообще управление сокетами — очень приятно, когда некто, кто хочет записать в сокет, может это сделать, даже если тот, кто будет читать из сокета, ещё не готов это сделать. Более того, читателя можно активировать по факту записи в сокет, что может сэкономить сколько-то оперативной памяти (впрочем, не то, чтобы её не хватало...)
- Template-юниты — если у меня много одинаковых датчиков, можно наплодить много процессов из одного юнита, передать всем разные параметры и радоваться
- (обнаружено слишком поздно, пока не реализовано) юниты-таймеры — позволяют периодически запускать некий юнит. Почему не cron — потому что у cron минимальный период 1 минута, а я хочу чаще опрашивать датчики. Почему не sleep() — потому что активное ожидание и потому что период начинает дрейфовать — да, датчик мы дёргаем каждые N секунд, но с учётом чтения и обработки данных период обновления данных будет не N секунд, а N+x, то есть при каждом чтении период обновления данных будет съезжать на x
С учётом этих находок в голове нарисовалась архитектура:
- systemd открывает сокет для связи между процессами-датчиками и процессом-коллектором, все процессы-датчики пишут в один и тот же сокет
- systemd запускает юниты для процессов-датчиков
- процесс-датчик читает данные с датчика, пишет их в сокет и засыпает (systemd timer unit я на тот момент ещё не нашёл)
- как только данные с какого-то датчика записаны в сокет, systemd запускает процесс-коллектор, который принимает обновление от датчика, волшебным образом его обрабатывает и сохраняет во внутреннем состоянии. Процесс-коллектор не умирает
- процесс-коллектор порождает отдельный поток (именно поток, не процесс, чтобы избежать IPC между процессами, которое в питоне для данной задачи несколько печальное, ниже напишу, почему я так думаю), в котором происходит передача внутреннего состояния в snmpd по протоколу agentx
Одно очень нехорошее место — это разделяемое внутреннее состояние между потоком-коллектором и потоком-agentx. Но я себе это простил, потому что в питоне есть волшебный GIL, который решает вопрос синхронизации между двумя потоками. Хотя это, конечно, очень плохо и не по книге. Была мысль вынести разделяемое состояние в отдельный процесс и заставить процесс-agentx и процесс-коллектор работать с процессом-состоянием через сокет, но заломало меня делать ещё один сокет, писать ещё один юнит и так далее.
Почему мне не понравилось IPC в питоне применительно к данной задаче:
- Queue работает нормально, но эти очереди неименованные, инстанс Queue надо передавать в форкаемый процесс. В моём случае это означает полностью переписать pyagentx
- Manager, возможно, решил бы мою проблему, но опять же, это означает полностью переписать pyagentx
- posix/sysv ipc великолепно, там есть именованные очереди, но эти очереди ограничены в размере, на некоторых системах — совершенно убого ограничены (пишут [листать до «Usage tips»], что на macos, например, не больше 2КБ на очередь и даже настроить нельзя). Не то, чтобы мне надо было запускаться на куче разных систем с разной степени убогости реализацией sysv ipc, но заниматься тюнингом тоже не хотелось. Хочу, чтобы сразу и хорошо
- снова posix/sysv ipc — очереди блокирующие, то есть какой-то минимальный таймаут должен быть, прежде чем чтение из очереди вернёт «пусто». В случае с pyagentx блокировка на чтении из очереди в update() очень нежелательна, да и вообще убого это
- и снова posix/sysv ipc — проблема с именованием очередей. Несмотря на то, что очереди сообщений именованные, именованные они не именем, а ключом. Так как ключ не является иерархическим или семантически очевидным, легко выбрать неуникальный ключ. В реализации posix/sysv ipc для питона есть возможность сгенерировать ключ очереди автоматически, но вот незадача — если бы я мог что-то передать в pyagentx, я бы передал туда Queue и не мучился. Можно генерировать ключ с помощью ftok, но пишут [листать до «Usage tips»], что ftok даёт не больше уверенности в уникальности ключа, чем
int random() {return 4;}
- (больше ничего на ум не пришло, что не вовлекало бы внешний брокер очередей, а задача не такая уж, чтобы ещё и брокер очередей держать — лишний сервис, лишняя головная боль)
dbus выглядел решением всех бед, да и есть он везде, где есть systemd, но вот беда — pydbus требует GLib >=2.46, чтобы публиковать API, а в raspbian только 2.42. dbus-python объявлен устаревшим и неподдерживаемым. Короче, пока петух жареный в попу не клюнет, буду разделять состояние небезопасным образом.
При использовании SNMP в своих грязных целях есть ещё одна загвоздка — а как выбрать OIDы для своих наборов данных? Для этого есть специальная ветка в private, которая называется enterprises — .1.3.6.1.4.1.<enterprise_id>. Получить уникальный enterprise ID можно у IANA. Когда схема OIDов определена, неплохо было бы написать MIB, чтобы самому не забыть, где что, ну и чтобы системам мониторинга было легче. Введение в написание MIBов есть тут.
В какой-то момент я обнаружил ntpsnmpd с соответствующим MIBом и возрадовался было до плеши, но когда скомпилил это чудо, обнаружил, что автор удосужился только реализовать несколько констант верхнего уровня и на этом выдохся. Я немного поковырялся в коде и так до конца и не понял, каким же хитрым образом автор взаимодействовал с ntpd (или ntpq), чтобы вытащить те константы, не парся вывод. Одно я понял точно — готового python API нет, а значит, ловить нечего, придётся этот MIB самому реализовывать.
В общем и целом, вся эта конструкция работает и является весьма живучей. Discovery в заббиксе работает, итемы создаются, графики рисуются, триггеры шлют алёрты — чего ещё для счастья надо? Код ещё не финализирован, так что не публикую.
Часть вторая — hardware
Не везде можно вкрячить юнитовый корпус, но и вешать сопли по стенам тоже не хочется. Есть очень изящное решение — DIN-рейка. Кучи конструктивов с рейкой продаются на рынке, в которые можно и блок питания реечный поставить (я использую MeanWell DR-15-5), и всякие автоматы-узо-что_угодно. Соответственно, захотелось корпус на DIN-рейку для raspi. В качестве кандидатов рассматривались вот эти два товарища — модель от Italtronic и RasPiBox. Преимущество RasPiBox в том, что там уже есть плата для прототипирования и ввод питания осуществляется через винтовые контакты (через стабилизатор на GPIO), что удобно, но может быть небезопасно. Но стоит он больше, чем в 3 раза дороже, занимает больше места на рейке и не имеет прозрачного окошка. Модель от Italtronic также не идеальна — ширина её такова, что все готовые LCD-экраны 16х2 туда не влазят по ширине, то есть ценность прозрачного окошка резко падает, но за низкую цену я был готов этот недостаток простить.
Корпус оказался достаточно удобен, имеет места под крепления (а точнее, под установку) двух печатных плат или листа чего угодно. Я подложки делаю из акрила, завёрнутого в токонепроводящую ESD-защитную плёнку, пилю дремелем:
Платы внутри держатся только на силе трения и на небольших уступах с двух сторон, то есть никакого жёсткого крепления внутри не предусмотрено. Несмотря на кажущуюся величину, корпус маленький и места над самой raspi остаётся не так уж много, особенно если вставлять плату на нижний уровень. А плата мне нужна, так как нужно разместить несколько светодиодов и плату с RTC.
Подключать к новой мониторилке хочу датчики температуры и влажности. Для температуры наш выбор — ds18b20, он работает, но стоит сравнивать показания с поверенным термометром, датчик может врать на полградуса по спецификации. Для компенсации добавил примитивную коррекцию показаний на константу в конфиг, проверял вот таким термометром:
Оказалось, что мои экземпляры ds18b20 вполне себе не врут. А вот следующий датчик как раз-таки врёт, причём аж на 0,6 градуса. Впрочем, опять же, зависит от экземпляра — один врал, другой почти не врал.
С влажностью оказалось всё не так просто. Дешёвое или не работает с raspi совсем (потому что аналоговое), или нет библиотек (хочу, чтобы сразу и хорошо), или дорогое как авиационные кабели. Компромисс между удобством и жабой был найден в датчике Adafruit BME280, который бонусом ещё и температуру с давлением показывает (но может врать, как я выше отметил).
Если ds18b20 можно просто завернуть в термоусадку и радоваться, с ВМЕ280 такой фокус не пройдёт. Идей про корпус было немало — и оставить как есть, припаяв провода и залив их клеевыми соплями (ушки для крепления уже есть, получается), и сделать мини-корпус из того же акрила, что и подложки под компоненты, и вычудить что-нибудь с 3D-принтером, благо есть один в зоне досягаемости… Но потом я вспомнил про яйца:
Это же идеальный корпус. Для датчика места хватает, разъём поставить можно, удобный доступ для обслуживания, подвесить можно везде или просто забросить куда-нибудь.
Подключать датчики к raspi решил через DB9. В USB линий мало, розетка RJ45 не влезла по габаритам. Датчик-яйцо решил подключить по USB, потому что в шкафу обнаружились остатки резаных USB-кабелей — не пропадать же добру:
Для защиты GPIO-гребёнки на raspi и для удобства разборки корпуса взял ещё одну гребёнку и припаялся к ней. Гребёнка угловая, что дало чуть больше места по вертикали, но я немного не подрассчитал и эта гребёнка уткнулась в резисторы для светодиодов. Всё, конечно, намертво завёрнуто в термоусадку, но момент, который в будущем стоит помнить. В итоге половинки корпуса всё ещё можно разнять, чтобы, допустим, поменять батарейку в rtc или саму raspi. Всё остальное (точнее, флешка) доступно для замены без открывания корпуса.
Одна рекомендация — не экономьте на кнопках. Я вот сэкономил, так кнопка не только дребезжит (с этим можно бороться, в библиотеке RPi.GPIO защита от дребезга предусмотрена), но ещё и срабатывает только в очень конкретном положении. Кнопку я предусматривал для программного отключения устройства на случай, если надо отключить питание (уже несколько раз убил ФС на флешке неаккуратным выключением), но оказалось, что мало что-то предусмотреть — надо ещё и документацию читать. Если вы, как и я, не читаете документацию, то знайте — overlay gpio_shutdown делает вовсе не то, что можно было бы предположить, а всего лишь выставляет на некотором пине высокий/низкий уровень при отключении, чтобы, например, внешний блок питания мог погаснуть. Для того, чтобы отключать raspi по кнопке, есть ядрёный модуль rpi_power_switch (но его компилять надо, а для этого kernel-headers нужны) или пользовательский демон Adafruit-GPIO-Halt. У меня будет свой hostd, который будет мигать светодиодами, вот заодно и на кнопку реагировать будет.
Заключение
Получился программно-аппаратный комплекс для мониторинга, расширяемый, использующий актуальные технологии, устойчивый к сбоям. Части ПО можно обновлять и перезапускать независимо то других частей (спасибо systemd, это не потребовало от меня как от разработчика никаких усилий). А самое главное — получилось получить много удовольствия от процесса и от результата. Ну и маленькая тележка новых знаний добавилась.
Спасибо за чтение!
Автор: homecreate