Не так давно я опубликовал пост, в комментариях к которому было высказано мнение, что у астериска есть некоторые проблемы с механизмом 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 обсуждались уже много раз. По всем остальным вопросам добро пожаловать в комментарии.

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


  1. Oakum
    05.08.2021 10:22
    +1

    Может быть причина в том, что собственный стек SIP они (разработчики Asterisk) забросили. Работает несколько инсталляций на 18 версии с PJSIP, бэкенд на PostgreSQL 13, утечек по памяти не наблюдаю.


    1. zmc Автор
      05.08.2021 10:44

      сориентируйте по количеству пиров на единицу астериска


      1. Oakum
        05.08.2021 10:54

        ~600 пиров

        System uptime: 9 weeks, 5 hours, 36 minutes, 37 seconds

                      total        used        free      shared  buff/cache   available 
        Mem:          5.8Gi       577Mi       149Mi       149Mi       5.1Gi       4.8Gi


        1. zmc Автор
          05.08.2021 11:07

          попробуйте увеличит количество пиров до 5000 +/-, картина поменяется кардинально.


          1. Oakum
            05.08.2021 11:11

            Утечки должны быть при любом количестве пиров, у меня 600 на 9 недель аптайма. У вас при 5000 за сутки утекает.


            1. zmc Автор
              05.08.2021 11:16

              Утечки должны быть при любом количестве пиров

              Простите, на основании чего Вы пришли к такому выводу?

              У вас при 5000 за сутки утекает.

              Почему за сутки? Месяц +/-. Световой день в посте в кавычках - аллегория.


              1. Oakum
                05.08.2021 11:29

                В том смысле, что если утечки есть (ошибки в коде), то они будут вне зависимости от количества пиров. Маловероятно, что бы race condition увеличивал объем утечки. 5000 у нас точно не будет, абонентов разделяем по географическому признаку. Звонки гоняем между узлами.


                1. zmc Автор
                  05.08.2021 11:41

                  В том смысле, что если утечки есть (ошибки в коде), то они будут вне зависимости от количества пиров. Маловероятно, что бы race condition увеличивал объем утечки.

                  А вот не факт, помимо состояния гонки есть куча узких мест, тот же накопительный эффект по буферам.

                  5000 у нас точно не будет

                  Вот в этом то и вся соль


                  1. Oakum
                    05.08.2021 12:13

                    Это не отменяет того факта, что однажды chan_sip перейдет в состояние поддержки dropped


                    1. zmc Автор
                      05.08.2021 12:42

                      Это не отменяет того факта, что однажды chan_sip перейдет в состояние поддержки dropped

                      Как и факт того, что PJSIP не решает проблемы массового Realtime. Переводить окружение из примера в статье на pjsip, что бы поиметь все те же проблемы, ну такое себе занятие.

                      Вы же понимаете, что были, есть и будут legacy-площадки где pjsip не то что-бы не нужен, он там просто не дает ни какого профита, как минимум.


  1. UserAd
    05.08.2021 10:34

    А еще если пир у вас сойдет с ума и начнет слать REGISTER с большой скоростью тоже начнет течь. Может если у вас больше 100 пиров поставить перед всем этим kamailio и заодно получить возможность зарезервировать этот asterisk?

    У нас такая схема работает на очень большом количестве пиров и asterisk на которые распределяется через dispatcher.


    1. zmc Автор
      05.08.2021 10:50

      воспринималась и реализовывалась как дополнение к решению на основе выноса регистраций на внешний сервер.

      Это как раз и есть решение с registrar server и именно на kamailio. А решение с кэшем - это попытка понять, как победить realtime и много пиров без registrar server и возможно ли такое в принципе


      1. arsperger
        06.08.2021 10:23

        если вам нужен просто registrar middlebox, то можно посмотреть в сторону opensips с его модулем mid_registrar, он работает из коробки. В камаилио такой функционал придеться писать самому.

        А вообще, вы правильно написали, делать систему где на астериске >5K регистраций и потом это чинить, затея так себе.