Что не так с Asterisk Realtime и как с этим жить

от автора

Не так давно я опубликовал пост, в комментариях к которому было высказано мнение, что у астериска есть некоторые проблемы с механизмом realtime. Так вот, на данный момент, вынужден согласиться с этим утверждением, более чем полностью. Как следствие, встал на путь разочарования asterisk’ом как платформой-«конструктором». Почему и как это произошло и при чём тут tarantool, а самое главное, что со всем этим можно сделать? Давайте разбираться под катом.

Да-да, и обезьяны падают с деревьев

Но как так, имея большой опыт в интеграции asterisk’а как voip-платформы, опыт в разработке CTI-приложений, в конце концов, в обработке и сопровождении большого объёма транзитного voip-трафика, кастомным биллингом, отчетной системой и статистикой, узнаю о таких смешных проблемах realtime-механизма в asterisk с определённой долей удивления.

Казалось бы, что может быть проще – запихнём все в базу, а астер сам всё разрулит посредством соответствующего движка. Но нет, этот подход оказался не лучшим решением. Для себя сформировал такое понимание – изначально грамотный подход к проектированию лишил меня «радости» наблюдать и стойко бороться с проблемами такого рода. Как говорится, семь раз отмерь, один раз отрежь.

Вот типичный usecase: куча пиров запиханных в базу, и под большим количеством астер начинает течь. Буквально совсем недавно мне довелось наблюдать такую картину: астериск, рабочая лошадка, ~5K пиров и все в базе, диалплан простой как три копейки – в общем, классика. Со временем астер начинает подтекать со следующими симптомами:

  • Первым отваливается сам стек sip;

  • Операции по сбросу либо не приводят ни к чему, либо подвешивают систему в целом;

  • Всё, что есть на данный момент, кое-как ворочается, новые коннекты/звонки отбиваются с отсылкой к памяти.

Но нужно отдать должное, что при такой организации лошадка кряхтела, стонала и скрипела, но отрабатывала свой «световой» день. Забегая вперёд, скажу, что с идентичной конфигурацией и с последним астериском лошадка иногда отказывалась даже выходить из загона, кто бы что ни говорил.

И вот тут начинается мучительный процесс поиска виновника торжества. Как так? Рядом же legacy-площадка с ~10K пиров и на более старой версии, но с организацией хранения пиров по старинке: один пир – один конфиг-файл. Всё летает – не шевелится еле-еле, а прям летает: отзывчивый reload, память не течёт, есть даже своего рода аналог realtime API под конфиг-организацию. Почему так?

Ответ очень прост, астериск уже не торт, и от релиза к релизу становится всё хуже и хуже. Теперь нужно следить за процессом пищеварения астера, подсовывать кусочки поменьше, а то и вообще разжёвывать за него.

Чем такого лечить, проще нового сделать

Становится очевидным, что в данном конкретном случае проблемы кроются в backend’е движка базы в связке с realtime-организацией – что-то они делают не так и не то, и от этого астериску впоследствии становится плохо. Но, к сожалению, на реализацию движка мы повлиять не можем – sorcery немного исправляет положение, но всё равно не то пальто. Варианты типа поменять СУБД не рассматриваются в виду наличия сложного/запутанного background’а.

Так все же, что мы можем сделать, что бы механизм realtime астериска немного схуднул? Из толпы доносятся голоса: «Напиши нужный тебе модуль работы с СУБД с той логикой и поведением, которое тебе нужно, заоптимизированный по самые уши». «Спасибо, не дождетёсь», – отвечу я. Как представлю этот кошмар с последующими обновлениями, сопровождением, а как итог – возможный форк. Нет, хватит, этого я уже наелся. Так что держим мысль в голове: никакого hardcode, используем по максимуму возможности платформы.

А что, собственно, нам может предложить платформа для решения поставленной задачи?

Выбор не так велик:

  • ODBC: UnixODBC-подсистема, поддерживает множество разных БД.

  • MySQL: нативная поддержка MySQL.

  • PostgreSQL: нативная поддержка PostgreSQL.

  • SQLite/SQLite3: быстрый/родной движок для небольших БД.

  • LDAP: поддержка LDAP-директорий.

  • cURL: поддержка HTTP-запросов в веб-приложения и связанные с ними БД.

ODBC/MySQL/Postgres/SQLite – проблема в командной игре механизма realtime и базы, меняя шило на мыло, фундаментальных проблем не исправить.
LDAP – ну тоже, такое себе решение, переписывать backend под LDAP. Люди меня не поймут – особенно те, кто будет это переписывать.

Так что, остаётся cURL?

Я художник, я так вижу

Собственно, что в этом такого? Разворачиваем «умный» кэш перед основной базой и организовываем доступ к нему поверх HTTP-запросов. Backend остается в первозданном состоянии, разработчики/сопровождающие довольны, и всё работает в прозрачном режиме – и волки сыты, и овцы целы.

А производительность? Хм, ну давайте прикинем. Реализация движка работы с базой – не две строчки кода, реляционная модель может нести слишком много накладных расходов. А если посмотреть реализацию cURL, скажем, в 11-й версии (по моему мнению, это последняя версия с оптимальным соотношением «цены» и «качества»), то мы увидим интерполяцию в вызов функции CURL диалплана. И что же нам это даёт? Прежде всего, надежду, что сломается первым «лифт», а не «лестница». Стоимость вызова HTTP-запроса в разы меньше диалога движка с базой. Но это не точно ​.

Ну, хорошо, уговорили – кэш так кэш. Что будем использовать? Redis с nginx’ом? Неее, это не наш метод, мы заюзаем tarantool как «умный» кэш и сервер приложений в одном флаконе. Тем более, давно уже интегрирую решения на базе tarantool с asterisk’ом.

Из описания Backend cURL and Realtime берём на реализацию всего два метода: single и update – вопросы установки/сборки рассматривать тут не будем, так как в более или менее популярных дистрибутивах всё необходимое есть из коробки.

Ловкость рук и никакого мошенства

Для начала прописываем cURL backend в extconfig.conf. Затем определяемся с форматом кэша – для этого берём и проецируем структуру таблицы пиров на lua-таблицу под будущую базу:

; extconfig.conf  [settings] ... sippeers => curl,http://127.0.0.1:65535 ... 
-- format.lua  local frommap = function(format,...)  local peer = ...   return type(peer) == 'table' and (function(p)   local out = {}    for k,v in ipairs(format) do    out[k] = p[v['name']] or box.NULL   end    return out  end)(peer) or {peer or box.NULL} end   return setmetatable({  { name = 'name', type = 'string' },  { name = 'port', type = 'integer', is_nullable=true },  { name = 'host', type = 'string', is_nullable=true },  { name = 'ipaddr', type = 'string', is_nullable=true },   ...   { name = 'dynamic', type = 'string', is_nullable=true },  { name = 'path', type = 'string', is_nullable=true },  { name = 'supportpath', type = 'string', is_nullable=true } },{__call = frommap}) 

Функция frommap – рудимент, досталась в наследство со времён tarantool 1.7.x, тогда ещё не было (или я плохо искал) нативной реализации frommap. По привычке таскаю её по версиям, да и по моим замерам эта реализация отрабатывает побыстрее, чем нативная в текущем tarantool LTS.

Следующим шагом будет перенос текущей таблицы пиров в lua-таблицу (ассоциативный массив). Можно конечно и select’ами надёргать при первом старте, но, мне кажется, так получится быстрее. Вооружаемся vim’ом, select’им нужную таблицу в файл, запускаем макрос и вуаля – ассоциативный массив в файле peers.lua.

Осталось продумать доставку всего этого добра до астериска. Для старта/теста вполне подойдет модуль http для tarantool’а, но, естественно, стоит озаботиться об идеологически правильном nginx’е, на будущее.

-- routes.lua  local utils = require('http.utils')  -- Маршрут обработки обновления regserver/useragent/fullcontact/regseconds/lastms/etc -- Запрос приходит в виде /update?name=.... -- Параметры запроса в POST useragent=user%20agent%20v1.x.x&fullcontact=test%20user&regseconds=5&lastms=0 local update = function(req)  local keys = {}  local who = req:query_param() or {}  local what = req:post_param() or {}   if who['name'] then   for k,v in pairs(format(what)) do    if v ~= box.NULL then     -- заполняем таблицу форматов для update     keys[#keys+1] = {'=',k,format[k]['type'] ~= 'integer' and v or tonumber(v)}    end   end   -- собственно обновление записи   box.space.sippeers:update(who['name'],keys)    return req:render{ status = 200 , text = '1\n'}  end    -- default  return {   status = 400,   headers = { ['content-type'] = 'text/plain' },  } end  -- заглушка, реализация запросов на основе LIKE не нужна local multi = function(req)  return {   status = 400,   headers = { ['content-type'] = 'text/plain' },  } end  -- поиск пира по primary ключу[name] или составному[port/host/ipaddr/callbackextension] secondary local single = function(req)  local who  local out = {}  local what = req:post_param() or {}   if what['name'] then   -- выборка по primary ключу   who = box.space.sippeers:select(what.name)[1] or {}  else -- по secondary   local skip = 0   local keys = {}   -- получаем части составного ключа   local parts = box.space.sippeers.index.secondary.parts    -- формируем таблицу с пропусками(если есть) составных, в виде: {5060,_,_,'7XXXXXXXXXX'}   for k,v in ipairs(parts) do    table.insert(keys,k,(function(p)     if p then      return p     end     skip = skip + 1 return _    end)(what[format[v['fieldno']]['name']]))   end   -- проверяем на {_,_,_,_} с последующим запросом   who = (#parts > skip) and box.space.sippeers.index.secondary:like(keys,1)[1] or {}  end   for _,v in ipairs(format) do   -- формируем таблицу ответа   table.insert(out,who[v['name']] and ('%s=%s'):format(utils.uri_escape(v['name']),utils.uri_escape(who[v['name']])))  end   if #out~=0 then   -- отдаем ответ в виде: host=dynamic&secret=password&fromdomain=domain   return req:render{text = table.concat(out,'&')}  end   -- default  return {   status = 400,   headers = { ['content-type'] = 'text/plain' },  } end  -- reload маршрутов/сервера без остановки tarantool'а if server and server['is_run'] and restart then  server:stop()  server:start() end  return {  multi = multi,  update = update,  single = single, } 

Надеюсь, из комментариев в листинге всё понятно, разве что маршрут multi – есть у меня такое предположение, что именно multi в интерпретации realtime-db срывает «башню» астеру. На это указывает ещё тот факт, что в прайм-тайм из, скажем, 5-и reload один да и подвесит систему. Лично моё мнение – в том виде, в котором используется multi, он не особо пригоден. Но отработку, скажем, register-учёток по callbackextension реализовать можно. Именно этим и хорош cURL-подход – мы буквально на коленке можем кастомизировать поведение как нам угодно.

Теперь немного подправим init.lua и запустим сервис.

#!/usr/bin/env tarantool  -- init.lua  -- будущие маршруты local routes = {}  -- server & router модули http local server = require('http.server').new('127.0.0.1', 65535) local router = require('http.router').new({charset = "utf8"})  -- формат нашего кэша local format = require('format')  -- переподгружает маршруты, если передать параметр true, перегрузит http.server function reload_routes(...)  routes = setfenv(loadfile('routes.lua'),setmetatable(   {    server = server,    format = format,    restart = ...   },{__index = _G}))() end  -- указываем, что за маршрутизацию будет отвечать router объект server:set_router(router)  -- подгружаем наши маршруты как chunk reload_routes()  -- добавляем наши маршруты в router for _,path in ipairs({'multi','single','update'}) do  router:route({path = ("/%s"):format(path)},function(...)   return type(routes[path]) == 'function' and routes[path](...) or false  end) end  -- магия tarantool'а box.cfg{}  -- LIKE функция поиска по составному ключу box.schema.index_mt['like'] = function(...)  local test  local code = {}  local result = {}  local index, pattern, count = ...   pattern = type(pattern) ~= 'table' and {pattern} or pattern  -- формируем условия  for k,v in pairs(pattern) do    code[#code+1] = v and (" match(str(t[%d]),'%s') "):format(index.parts[k]['fieldno'],v)  end  -- прелести кодогенерации  test = setfenv(load(([[    local t = ...    if %s then     return insert(result,t) or true    end  ]]):format(table.concat(#code>0 and code or {'true'},'and'))),{   str = tostring,   result = result,   match = string.match,   insert = table.insert  })  -- выборка с учетом limit  for _,t in index:pairs(_,{iterator = 'GE'}) do   if test(t) then    if count then     if count > 1 then      count=count-1     else      break     end    end   end  end   return result end  -- инициализация ключей box.once('sippeers',function()  local peers = require('peers')   box.schema.space.create('sippeers',{format = format})  box.space.sippeers:create_index('primary',   { type = 'TREE', parts = {1, 'string'}})  box.space.sippeers:create_index('secondary',   { type = 'TREE', unique = false, parts = {     {'port','integer'},     {'host','string'},     {'ipaddr','string'},     {'callbackextension','string'}   }})  -- первичное заполнение  for _, peer in ipairs(peers) do   box.space.sippeers:insert(format(peer))  end end) -- запускаем сервер приложений server:start() -- парадный вход :) require('console').start() 

Оно живое и светится

Сама затея, в моем понимании, поначалу была бесперспективной, воспринималась и реализовывалась как дополнение к решению на основе выноса регистраций на внешний сервер. Но, по мере внедрения, пришло осознание, что пациент скорее жив, чем мёртв. Realtime реально «похудел», и это заметно – возможно, утечки памяти просто размазались по времени (тесты/время покажут). Но, по факту, при такой организации астериск перестал течь, и это не может не радовать. Плюсом в копилку поимели новый уровень кастомизации sip-стека платформы.

За кадром остались телодвижения связанные с консистентностью данных в кэше и базе. Так как эта реализация узкоспецифическая, то рассматривать её в рамках данной статьи я не вижу особого смысла – кто как хочет, тот так и хохочет. Вопросы репликаций, масштабирования тоже достаточно тривиальны, на habr’e обсуждались уже много раз. По всем остальным вопросам добро пожаловать в комментарии.


ссылка на оригинал статьи https://habr.com/ru/articles/571338/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *