Не так давно я опубликовал пост, в комментариях к которому было высказано мнение, что у астериска есть некоторые проблемы с механизмом 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®seconds=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)
UserAd
05.08.2021 10:34А еще если пир у вас сойдет с ума и начнет слать REGISTER с большой скоростью тоже начнет течь. Может если у вас больше 100 пиров поставить перед всем этим kamailio и заодно получить возможность зарезервировать этот asterisk?
У нас такая схема работает на очень большом количестве пиров и asterisk на которые распределяется через dispatcher.
zmc Автор
05.08.2021 10:50воспринималась и реализовывалась как дополнение к решению на основе выноса регистраций на внешний сервер.
Это как раз и есть решение с registrar server и именно на kamailio. А решение с кэшем - это попытка понять, как победить realtime и много пиров без registrar server и возможно ли такое в принципе
arsperger
06.08.2021 10:23если вам нужен просто registrar middlebox, то можно посмотреть в сторону opensips с его модулем mid_registrar, он работает из коробки. В камаилио такой функционал придеться писать самому.
А вообще, вы правильно написали, делать систему где на астериске >5K регистраций и потом это чинить, затея так себе.
Oakum
Может быть причина в том, что собственный стек SIP они (разработчики Asterisk) забросили. Работает несколько инсталляций на 18 версии с PJSIP, бэкенд на PostgreSQL 13, утечек по памяти не наблюдаю.
zmc Автор
сориентируйте по количеству пиров на единицу астериска
Oakum
~600 пиров
System uptime: 9 weeks, 5 hours, 36 minutes, 37 seconds
zmc Автор
попробуйте увеличит количество пиров до 5000 +/-, картина поменяется кардинально.
Oakum
Утечки должны быть при любом количестве пиров, у меня 600 на 9 недель аптайма. У вас при 5000 за сутки утекает.
zmc Автор
Простите, на основании чего Вы пришли к такому выводу?
Почему за сутки? Месяц +/-. Световой день в посте в кавычках - аллегория.
Oakum
В том смысле, что если утечки есть (ошибки в коде), то они будут вне зависимости от количества пиров. Маловероятно, что бы race condition увеличивал объем утечки. 5000 у нас точно не будет, абонентов разделяем по географическому признаку. Звонки гоняем между узлами.
zmc Автор
А вот не факт, помимо состояния гонки есть куча узких мест, тот же накопительный эффект по буферам.
Вот в этом то и вся соль
Oakum
Это не отменяет того факта, что однажды chan_sip перейдет в состояние поддержки dropped
zmc Автор
Как и факт того, что PJSIP не решает проблемы массового Realtime. Переводить окружение из примера в статье на pjsip, что бы поиметь все те же проблемы, ну такое себе занятие.
Вы же понимаете, что были, есть и будут legacy-площадки где pjsip не то что-бы не нужен, он там просто не дает ни какого профита, как минимум.