Привет! Я — Дарья, руководитель проектов в Uzum Data. В этой статье поделюсь с вами опытом работы с OpenSource SMS-шлюзом Jasmin: какие у нас были требования, с какими препятствиями столкнулись, как выбирались из трудностей.
C чего всё началось
Нам нужно было подключить новый канал смс-рассылки, т.к. по старым каналам мы начали упираться в лимиты. Новый канал представляет собой коммуникацию с использованием A2P каналов, то есть коммуникации от бизнеса пользователям. A2P используется как в маркетинге, так и в сервисах: для подтверждения покупки, авторизации, напоминаний, рассылок и т.д.
Перед нами стояла задача выбрать стабильное решение, которое будет выдерживать высокие нагрузки и обеспечит быструю отправку больших пачек смс.
Предстоял выбор между тем, чтобы написать собственный sms-шлюз или использовать готовое OpenSource решение.
Для того, чтобы сделать выбор, надо понимать требования. Наши требования были такие:
Шлюз должен подключаться к sms-центру через SMPP или HTTP.
Мониторить статусы сообщений.
Выдерживать объём отправок 150 000 (смс/сутки), скорость в пике — 30 смс/ сек.
Сообщения не распределяются равномерно по суткам, а отправляются большими пачками (батчами), отсюда такое требование к скорости.
А также:
отчёты о доставке на уровне SmsC и Terminal,
статусы сообщений: planned, sent, delivered, failed,
отправка multipart-сообщений.
Мы рассмотрели несколько вариантов OpenSource смс-шлюзов и остановились на Jasmin SMS Gateway. У него тогда было 478 форков на гитхабе, что внушает доверие. Плюс к этому, мы спросили у коллег из других компаний, которые используют jasmin для своих рассылок, и отзыв был: «работает».
Сложно было оценить, сколько времени займёт реализация custom-code решения, т.к. мы ещё могли не знать всех нюансов, а сроки, как обычно, поджимали. К тому же, стараемся придерживаться политики «брать готовый велосипед, а не изобретать свой». Поэтому с некоторыми оговорками решили использовать Jasmin.
Ожидание vs. Реальность
С самого начала мы приняли для себя нюансы, с которыми придётся иметь дело дальше:
реализация на Python (в стеке нашего сервиса основной язык — Scala);
нет уверенности, что можно кастомизировать под все нужные кейсы;
количество открытых issues и непонимание, как масштабировать.
Для A2P рассылок нам также важны были такие функции, как учёт часовых поясов, чёрных списков, и диапазон времени рассылки. Всё это мы учли в новом компоненте и написали собственный a2p‑adapter.
Весь механизм работы по smpp Jasmin реализует сам, а a2p‑adapter вызывает простые REST‑методы: передаёт группу контактов и содержимое сообщения. Далее Jasmin коммуницирует с sms центром (через A2P) и мониторит статусы доставки каждого сообщения.
Основное взаимодействие в Jasmin строится через RabbitMQ.
В теории всё довольно просто, но вот с чем мы столкнулись на практике:
Jasmin написан не столько на Python, сколько на Twisted
Теряется подключение к RabbitMQ
Потеря отчётов о доставке
Перегрузка сервиса
Утечка памяти
Теперь о каждом пункте по порядку.
Jasmin написан не столько на Python, сколько на Twisted
Это событийно-ориентированный Python-фреймворк, своего рода каркас для написания межсервисного взаимодействия. Фреймворк для нас незнакомый и каждый раз при разборе ошибок новым человеком уходило много времени на погружение. Если отдавать задачи по Jasmin одному и тому же человеку, то он конечно погрузится и разберётся с Twisted, но в реальности люди ходят в отпуск и болеют, тогда приходится отдавать задачи кому-то ещё, и другой человек заново погружается. Для шаринга экспертизы неплохо, но занимает много времени.
Теряется подключение к RabbitMQ
При накоплении определенного кол-ва сообщений в очереди DLRLookup (получает статусы переданных на отправку сообщений через Rabbit) Jasmin перестает обрабатывать отчеты и модуль, отвечающий за их обработку, теряет соединение к RabbitMQ. Такое поведение могло привести к аварии на канале A2P в любой момент, а при увеличении нагрузки проблема проявлялась всё чаще.
Причину нашли: оказалось, что при работе консьюмера не было ограничителя на объем unacked‑данных (прочитанные из Rabbit, но не обработанные). В результате, из‑за асинхронной обработки сообщений при чтении из Rabbit (если чтение происходит быстрее, чем обработка), растёт количество асинхронных вызовов, сопряженных с обработкой сообщений. Они копятся, и в какой‑то момент происходит сбой. Дополнительный негативный эффект — потеря части статусов о доставке.
Как решили: ограничили объем unacked-сообщений при чтении из очереди DLRLookup. Запомнили опыт, заодно проверили, не грешат ли другие консьюмеры тем же.
Перегрузка сервиса
Тут стоит пояснить, что A2P коммуникации исполняются инстансами, в зависимости от расписания. Например, есть рассылки, где новые инстансы выполняются раз в сутки, в других — каждый час, в третьих — каждые несколько минут. Инстанс считается финальным тогда, когда мы получаем статусы переданных на отправку сообщений. В предыдущей описанной проблеме одним из негативных эффектов была потеря части статусов о доставке. Так вот, когда не приходят статусы отправки сообщений, инстансы рассылок не закрываются, и происходит их постоянный поллинг в ожидании получении статуса, что также даёт дополнительную нагрузку, т.к. одновременно поллятся очень много инстансов и они накапливаются, что приводит к деградации сервиса. Такое возникает при обработке крупных батчей с сообщениями, или большом количестве мелких батчей.
Начинали сыпаться 503 ошибки в связи с перегрузкой — то есть сервис был настолько загружен, обрабатывая текущие запросы, что уже не мог отвечать на новые.
Утечка памяти
Это одна из самых серьёзных проблем, с которыми столкнулись, работая с Jasmin.
Возникали ситуации, когда компонент работал, работал, доходил до критического объёма потребляемой памяти и под с Jasmin падал по «Out of memory».
В ходе проведения нагрузочных тестов выявили, что:
В очереди DLRLookup был установлен троттлинг на 30 сообщений, который влиял на количество используемой памяти: при увеличении нагрузки и росте кол‑ва сообщений объём памяти увеличивался, т.к. сообщения загонялись в память.
При достижении лимита по памяти происходило отключение от RabbitMQ одним из компонентов Jasmin, а переподключения не происходило. Приходилось рестартовать под с Jasmin вручную.
Оказалось, что проблемы с памятью сразу две:
Росла память working set по причине скопления логов в контейнере smpp. Поняли, что память не растёт бесконечно, а растёт до какого‑то предела, поэтому решили проблему, просто подняв лимит для памяти.
Росла RSS‑память, или память, необходимая для задействованных операционных процессов. Эта проблема заключается в том, что информация для маппинга DLR‑статусов кэшируется в памяти процесса dlrlookup и удаляется либо когда приходит DLR PDU от оператора, либо по таймауту. Если смс состоит более чем из одной части (multipart = 2 и более PDU в сообщении), то кэшируются все PDU‑части сообщения. При этом, оператор присылает статус только по последней части сообщения, даже если оно состоит из 5 PDU, а остальные продолжают висеть в кеше и удаляются только после таймаута. Таким образом, чтобы сократить расход памяти, пришлось уменьшить таймаут dlr_expiry.
Для полноты картины, есть связанный баг на github, где объясняется: «When a SMS‑MT is not acked, it will remain waiting in memory for dlr_expiry seconds, after this period, any received ACK will be ignored»
Скажем честно, с Jasmin было много манипуляций, непредсказуемых проблем, да ещё и пришлось части команды освоить Python (Twisted). Но в результате, канал A2P работает и отвечает нашим требованиям по скорости. А когда придёт время для масштабирования, это будет уже новая история.
beg_1294
Рад что uzum начал свой блог