Найбільша PHP конференція України, 1 червня: хто буде і чому варто відвідати?
×Закрыть

Делаем простой и надежный микросервис рассылки пушей на компонентах AWS

Всем привет! Я — Андрей Товстоног, DevOps Engineer в компании Genesis. В статье поделюсь опытом построения маленького микросервиса с использованием бессерверной архитектуры AWS. Также расскажу, как работают push-уведомления и с какими проблемами мы столкнулись при реализации этого решения.

Сейчас самое время сказать: «Дружище, да ты чего, зачем разбираться с этой лямбдой и правами доступа для нее! Проще ведь поднять инстанс и кроном дергать скрипт ну или закинуть все в docker, чтобы поднимался, отрабатывал и схлопывался».

Не могу не согласиться: такое решение имеет право на жизнь. Но это добавляет работы. Поднятый инстанс нужно мониторить и в случае чего «тушить пожары». А, как известно, инженеров всегда напрягает, когда что-то идет не так. Да и просто хотелось «потрогать» такие сервисы, как Lambda и DynamoDB, пускай даже в такой небольшой, но жутко интересной задаче. Поехали.

Постановка задачи

Мы работаем в сфере медиа, и нам важно совершать рассылку уведомлений, чтобы держать заинтересованную аудиторию в курсе происходящих событий. До этой задачи у нас были отправки только ручных push’ей, то есть редакторы всегда сами совершали рассылку какой-то новости. Немного поразмыслив, мы прикинули, что было бы весьма неплохо сделать автоматическую отправку уведомлений, основываясь на топе читаемых статей. И нет, ручные push’и никто не отменял, они по-прежнему остаются :)

Окей, идея есть — теперь сформулируем задачу.

Необходимо по заданному расписанию выполнять рассылку push-уведомлений пользователям. Для этого будем использовать сервис аналитики (Analytics Service), который может отдавать по API топ-статьи по количеству просмотров за определенный период. На основании полученной информации формировать тело push’а и отправлять все это добро на API сервиса отправки push’ей (Push Service).

Как работают push-уведомления

Перед тем как приступить к выполнению задачи, предлагаю рассмотреть в общих чертах, как работают push-уведомления. Начнем с небольшой схемы.

Рис. 1. Пример работы push-нотификации (на схеме есть Apple Push Notification service, но все описание далее будет касаться Firebase Cloud Messaging — у Apple принцип работы похожий)

Итак, что мы имеем:

  • Push Service — сервис, который отвечает за отправку push-уведомлений. Это красивая, функциональная обертка (стандартизированный API) над FCM (Firebase Cloud Messaging) / APNs (Apple Push Notification service), которая предоставляет сервис группировки пользователей (например, мы можем отправить push даже одному конкретному человеку, провести A/B-тестирование, просмотреть информативные отчеты и статистику). Этот сервис позволяет также не заморачиваться с форматами push-уведомлений, так как он сам этим занимается, а с нашей стороны необходимо всего лишь дать ему данные (payload).
  • FCM — это Firebase Cloud Messaging (далее — FCM), сервис, который, собственно, и осуществляет непосредственную отправку push-уведомлений пользователям, используя их уникальные идентификаторы. Здесь может возникнуть законный вопрос: а каким образом FCM знает, как доставить push-уведомление? Все дело в том, что браузер (User Agent) держит перманентное TCP-соединение (одно для всего User Agent) с серверами Google, которые являются частью FCM. Это и объясняет, каким образом push доставляется практически моментально после отправки. Вообще, концептуально FCM состоит из двух частей: FCM backend и application server.
  • FCM backend — это балансировщики нагрузки, а также серверы, выполняющие доставку сообщений клиенту. А вот и ответ на то, почему они выполняют и роль балансировки нагрузки. Они посылают клиенту специальные служебные уведомления о том, что необходимо сменить connection-сервер, а это означает, что текущее соединение разрывается и устанавливается новое; так и производится балансировка.
  • Application server — это и есть Push Service, то есть часть, отвечающая за передачу уведомления нашему приложению через FCM backend.
  • User Agent — это наш браузер.
  • Service Worker (см. рис. 2) — это JavaScript-файл, который браузер запускает в бэкграунде, отдельно от веб-страницы, открывая доступ к фичам, которые не требуют запущенной страницы сайта или каких-либо действий со стороны пользователя, а также отвечающий за взаимодействие c Browser APIs, такие как Push API и Notification API. Эта штука является неким прокси, находясь между веб-сайтом, сетью и кешем. Да-да, именно он руководит тем, сходить ли нам в кеш или сделать запрос к серверу. Он и позволяет перехватывать события и выполнять какие-то действия на них. Можно еще отметить, что он работает непостоянно, то есть засыпает, когда не используется, и возобновляет свою работу, когда происходит какое-либо событие, на которое он подписан, в нашем случае это push event.
  • Push API — это web API для управления подпиской на push-уведомления от Push Service, а также для обработки push-уведомлений. Благодаря Push API Service Worker получает возможность обрабатывать событие onpush.
  • Notification API — отвечает за показ уведомления пользователю.

Еще несколько слов об API.

Рис. 2. Так работает Service Worker

API в контексте нашей темы делятся на два вида:

  • API браузера;
  • сторонние API.

Также они имеют одно общее название — это Web API, которые призваны облегчить жизнь программистам.

Так вот, Push API и Notification API относятся к API браузера и представляют собой конструкции (набор объектов, функций, свойств и констант), построенные на основе языка JavaScript.

Теперь, когда мы разобрались, из каких элементов состоит процесс отправки push-уведомления, можем описать весь рабочий процесс (workflow).

Откуда же берется Service Worker

Прежде чем Service Worker сможет приступить к выполнению своей работы, он должен пройти определенный жизненный цикл, который состоит из 4 шагов.

Рис. 3. Жизненный цикл Service Worker

  1. Регистрация. Происходит один раз при первом обращении к сайту.
  2. Загрузка. Выполняется при первом обращении к сайту и повторно через определенные промежутки времени для того, чтобы предотвратить использование старой версии.
  3. Установка. В случае первой загрузки будет произведена установка, а в случае повторной загрузки выполняется операция побайтового сравнения и, если есть отличия, производится его обновление.
  4. Активация.

Service Worker готов к работе — поехали дальше!

Подписываемся на push-уведомления

Здесь можно отметить два шага:

  • Запрос разрешения пользователя на отображение push-уведомлений. Это именно то назойливое окно, которое всплывает слева вверху с двумя кнопками — Block и Allow, когда мы заходим на сайт в первый раз и на сайте подключены push-уведомления.
  • Подписка пользователя на push-уведомления (Push Subscription).

Подписка содержит всю информацию, которая необходима для отправки уведомления пользователю. Со стороны Push Service это выглядит как уникальный идентификатор устройства — ID. Далее это все добро (Push Subscription) отправляется на наш Push Service, где сохраняется в базе для последующей отправки push-уведомлений зарегистрированному пользователю.

За эти все процедуры отвечает тот самый упомянутый выше Push API.

Отправка push’а

Отправка push-уведомления заключается в триггере API нашего Push Service. Этот вызов должен содержать информацию, которую мы должны показать пользователю (payload), и группу пользователей, которой этот push будет отправлен. После того как мы сделали API-вызов, Push Service сформирует правильный формат для браузера и отдаст его в FCM, который поставит уведомление в очередь и будет ожидать, когда User Agent появится в сети.

Но push-уведомление не может жить в очереди вечно, поэтому у Push Service есть опция TTL (Time to Live), или время жизни уведомления, по истечении которого уведомление будет удалено :)

Получение push-уведомления пользователем

После того как Push Service отправил push-уведомление, FCM доставит (ну или не доставит, если что-то пошло не так) его в браузер, последний создаст такую штуку, как Push Event, на который отреагирует Service Worker и запустит обработку push-уведомления.

Вот мы и узнали в общих чертах, как работают push’и, так что теперь можем приступить к выполнению задачи.

Реализация

Как всегда, у правильных инженеров все веселье начинается с планирования — мы ничем не хуже :) Поэтому:

Рис. 4. Схема микросервиса

Состав нашего стека:

  1. AWS Lambda Function — собственно центр нашего микросервиса, исполняет написанный в нашем случае на Python код. Эта штука сама выделяет ресурсы, которые необходимы для вычисления кода, и оплачивается только за фактическое время работы и за потребляемую память. Если верить описанию документации AWS, то облако даже гарантирует высокую доступность сервиса, что, конечно же, не может не радовать.
  2. AWS DynamoDB — это база данных пар «ключ — значение» и документов, обеспечивающая задержку менее 10 мс при работе в любом масштабе. Это полностью управляемая база данных, которая работает в нескольких регионах с несколькими ведущими серверами и обладает встроенными средствами безопасности, резервного копирования и восстановления, а также кеширования в памяти для интернет-приложений. DynamoDB может обрабатывать более 10 трлн запросов в день с возможностью обработки пиковых нагрузок — более 20 млн запросов в секунду. В процессе формирования и отправки push-уведомления на Push Service выполняется проверка, было ли push-уведомление с таким ID уже отправлено или нет, а эти данные достаются из DynamoDB и пишутся в ней.
  3. AWS CloudWatch — в нашем случае «двуглавый змей», который выполняет роль лог-сервиса, записывающего лог-выполнения Lambda и Event rule, который триггерит функцию по указанному времени, фактически выполняя роль планировщика (cron).
  4. Analytics service — сервис с API, в котором «происходит магия», и в итоге мы можем доставать топ-статьи, например по количеству просмотров.
  5. Push Service —  сервис, позволяющий отправлять наши push’и.
  6. Slack — старый добрый «слак», куда же без него, в который делаем нотификацию по отправке.

Итак, начинаем с того, что пишем код для Lambda-функции. Особенность его в том, что необходимо определить входную точку, так как Lambda-функция вызывает функцию (2) внутри Lambda-функции, которая объявляется как Handler (1) :)

1) Handler —  это и есть входная точка, которую и вызывает Lambda. Название Handler должно совпадать с именем функции в коде; 2) имя функции, которая будет входной точкой в Lambda; 3) Event и Context — встроенные параметры, которые передаются вызываемой функции

Здесь возникает законный вопрос: а что же такое эти переменные, которые мы передаем функции?

Документация говорит исчерпывающе:

Event — этот параметр используется для передачи данных о событиях обработчику. В нашем случае мы достаем из поля event тело ответа от API Analytics Service. Прилетает оно в формате json.

Context — передает контекст объекта обработчику. Этот объект предоставляет методы и свойства, которые предоставляют информацию о вызове, функции и среде выполнения. Перейдя по ссылке, можно увидеть методы и свойства, а также здесь доступен пример логирования информации контекста. Из контекста мы берем request_id, который используется для того, чтобы Lambda не запускалась несколько раз.

А вот как это все работает под капотом. CloudWatch event rule по расписанию триггерит Lambda-функцию, она в свою очередь выполняет код, выполняет запрос / обновление данных в DynamoDB, записывает логи через CloudWatch и делает уведомление в Slack.

Ниже представлена блок-схема, которая описывает алгоритм работы сервиса.

Рис. 5. Алгоритм работы микросервиса

Работа скрипта начинается с того, что он делает запрос в сервис аналитики (Analytics Service) и достает 100 топ-статей. Почему именно 100? Это связано с поддоменами, так как на каждый поддомен должна отправляться статья, опубликованная именно на нем, например example.com и subdomain.example.com. На example.com должна уйти статья, опубликованная на example.com, а на subdomain.example.com — опубликованная на поддомене subdomain. Так как поддомен subdomain является более специфическим или конкретизированным, то статьи на нем появляются реже. А если совсем просто, то Analytics Service API не умеет возвращать список статей для конкретного поддоменного имени, а только для домена целиком. Вот как-то так :)

Далее выполняем проверку, был ли push с таким article_id отправлен или нет. Для этого мы вызываем функцию, которая достает записи из DynamoDB, сравнивает и обновляет записи в DynamoDB и в итоге возвращает нам значение переменной uniq.

После этого выполняется отправка push-уведомления пользователям с уникальной статьей.

Проблемы, с которыми столкнулись

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

Изначально скрипт представлял собой, условно говоря, 5 строчек:

  • брали одну топ-статью с сервиса аналитики;
  • формировали json;
  • отправляли на Push Service.

Первая проблема заключалась в повторной отправке push-уведомления пользователям, так как статья могла держаться в топе целый день. Но забавно в этой ситуации было то, что на повторный push реагировало больше пользователей :)

Решили мы эту проблему, подключив в нашу логику DynamoDB для записи отправленного article_id. После маленьких правок мы стали доставать из сервиса аналитики по 5 топ-статей и сравнивали article_id c записью article_id из базы; если повторялся, брали следующую статью из топ-выборки, обновляли запись в базе и отправляли push.

Проверяем, полет нормальный, но недолгий :)

Настигла нас проблема, что push’и начали повторяться через 1–2 отправки (ну оно и логично :)), так как у нас в базе лежит только один ID статьи и он перезаписывался — небольшой просчет в архитектуре сервиса.

Поэтому следующим шагом стало то, что мы начали создавать массив items, состоящий из article_id, и записывать его в базу DynamoDB. Длину массива решили определить равную 5 — этого более чем достаточно, но в случае необходимости всегда можно увеличить. Проверяем, полет нормальный, но недолгий, хотя дольше, чем в предыдущем случае.

Следующая проблема — это перезапуск Lambda-функции, что влекло за собой отправку 3 push-уведомлений с интервалом в 1 мин. Это происходило, когда сервис отправки push’ей отваливался по тайм-ауту и Lambda-функция считала, что она завалилась, и запускалась повторно. Это, как оказалось, стандартное поведение для Lambda-функции, которая работает в асинхронном режиме, и, если при выполнении функции она вернет ошибку, Lambda попробует выполнить ее еще раз. По умолчанию Lambda будет пробовать дополнительно 2 раза с интервалом в 1 мин.

Решили эту проблему добавлением в DynamoDB такого поля, как request_id. При каждом новом запуске Lambda генерирует уникальный request_id (он не меняется при перезапуске функции), который мы и вытягиваем из context. Проверку уникальности request_id выполняем перед проверкой article_id, а обновляем его каждый раз, когда article_id уникален. В итоге мы обрываем повторное выполнение функции, если такие попытки появляются.

Следующий вопрос может стать таким: «Так если Push Service отваливается по тайм-ауту, то как вы понимаете, ушел push или нет?». И это тоже очень правильный вопрос. На самом деле на протяжении всего полета «не было ни единого разрыва» :) То есть push отправлялся всегда, даже если мы не получали никакого ответа от API Push Service. Об этом говорит статистика отправки в административной панели Push Service, а также уведомления в Slack.

Подводя итоги

Используя части весьма интересного стека бессерверной архитектуры AWS, получилось сделать весьма годный микросервис, работающий так же надежно, как пружина от дивана. Это решение выполняет полезную функцию, способствующую развитию бизнеса, и избавляет нас от некоторых небольших проблем, связанных с поддержкой такого же решения у себя. И самое интересное то, что плата за все это составляет меньше 1 $ в месяц.

LinkedIn

18 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

спасибо за статью
для меня она была интересна со стороны сервиса пушей

Об этом говорит статистика отправки в административной панели Push Service, а также уведомления в Slack

не могли бы Вы более подробно раскрыть это предложение?
— как Вы собираете аналитику пушей (отправка, доставка, отображение, открытие)?
— что есть «административная панель Push Service» — это FCM или обличие Вашего микросервиса на AWS?
— что Вы отправляете в Slack — статистику за сутки или что-то еще?

как Вы собираете аналитику пушей (отправка, доставка, отображение, открытие)?

— в качестве «Push service» мы используем OneSignal.

что есть «административная панель Push Service» — это FCM или обличие Вашего микросервиса на AWS?

— тот же OneSignal.

что Вы отправляете в Slack — статистику за сутки или что-то еще?

— в Slack отправляем нотификацию по отправке и статусом, если что-то не так мы это увидим (пример нотификации ниже):
----------Slack---------
StackTrace: Push was sent successfully
Domain name: example.com
Article name: article example
Status code: 200
Reason: OK
----------Slack---------

спасибо за ответ
а почему Вы не стали отправлять на FCM уведомления напрямую через их API, а подключили сторонний сервис, который, я так думаю, является оберткой над FCM? это было ради того, чтобы не городить свой велосипед или другие какие-то причины? у меня подобное направление работы и хочу узнать Ваш опыт выбора сервиса. если ради статистики, то я пока остановился на хендлинге событий через сервис воркер и отправки их на бекенд

а почему Вы не стали отправлять на FCM уведомления напрямую через их API, а подключили сторонний сервис, который, я так думаю, является оберткой над FCM?

Дешевле подключить сторонний сервис, чем городить что-то свое. У нас был опыт такой системы и мы пришли к выводу, что больше такого не хотим. И да, все подобные сервисы — это обертка над FCM/APN.

Немного коментов
1.

DynamoDB может обрабатывать более 10 трлн запросов в день с возможностью обработки пиковых нагрузок — более 20 млн запросов в секунду.

Тут вы погорячились взяв с заглавной страницы фразу. У вас же одна таблица/индекс ? тогда вам сюда docs.aws.amazon.com/...​eveloperguide/Limits.html
Миллионами tps там и не пахнем. А если копнуть проблему еще глубже — то отваливатся сервис по хот кий будет еще раньше.
2.

Следующая проблема — это перезапуск Lambda-функции

Ламбда перезапускает только если ваш код крешанул или был таймаут.
насчет первого — все понятно, насчет второго — счас лимит 15 минут, и да from context всегда можно определить остаток времени на выполнение и перезапускать функцию самому без ожидания что она будет terminated.
3. не уверен что понял Вашу архитектуру правильно, но Вы в одной lambda invocation отправляете все push в цикле ? Если так то я бы рекомендовал Fan out подход, когда CW вызывает LM а та распределяет массив push между worker LM.

1. Это просто было описание того, что такое DynamoDB. Там при правке затерялась фраза: «если верить офф документации....», так что никакого контекста более в это предложение не вкладывалось. Но спасибо Вам за замечание в дальнейшем буду учитывать такие моменты.
2. У нас наблюдалась проблема исключительно с тайм аут (который был задан).
3. Флоу такой:
Есть n-доменов, допустим 5. На каждом домене ведётся своя аналитика. Под каждый домен создана своя Lambda, которую триггерит отдельный Cloud Watch rule (5 раз в сутки, ночью конечно же, не отправляем). У нас целевая аудитория находятся в разных временных зонах, поэтому и CW rules сконфигурированны под разные домены с учётом времени бодрствования.

3. На каждую Lambd’у создана отдельная таблица в DynamoDB.

Цікаво-цікаво.

И самое интересное то, что плата за все это составляет меньше 1 $ в месяц.

Скейл малий, на великих лоадах там космічні прайси.

Ми розсилаємо 5кк пуш-меседжів на місяць. Коштує 0 $. Тому, що без AWS і пуші це частина моноліту, який так чи інакше має хоститися. Імплементація зайняли 1 день.

Меньше 1$?
Какое приблизительное количество нотификаций в месяц проходит через микросервис?

А user pool куда нотификации шлете насколько большой? Сколько времени уходит на пуш всем клиентам?

Так как у нас проектов много, соответсвенно, под каждый есть свой юзер пул. Доставка пуш-сообщения практически моментальная и никак не зависит от количества пользователей на которых делается рассылка, так как за доставку отвечаем не мы, а серверы Google.

Интересно, считали ли вы после какой цифры использование AWS станет для вас слишком дорогим? Статья заинтересовала, в свое время сам сильно смотрел в сторону подобного решения, но судя по местному прайсу рассылка пушей начиная от 100k в день становится очень дорогим удовольствием :(

Нет, не считали. Этот сервис используется для автоматической рассылки и их количество небольшое, соответственно и прайс небольшой. А «ручные» пуши отправляются через админку, которая отправляет payload в Onesignal и таких пушей достаточно много.

Вполне возможно, под not expiring free tier :
DynamoDB (25 Units of Read Capacity and 25 Units of Write Capacit)
lambda < 1mm requests
sns < 1mm

-----
Если больше миллиона , увеличиться затратность dynamo r/w операций . Lambda/sns mobile push event довольно такие дешевые

Не думаю что у них миллионы клиентов, хотя хз — узнаем от втора

нуууууу, 55кк пушей/месяц — это по всем проектам + ежемесячный рост до 35% — судите сами

если все это до 1$ вмещается, думаю это супер бюджетное решение ) Неплохо.. неплохо . Как вы тестируете данный солюшн?

Onesignal позволяет создавать проекты со своей конфигурацией. У нас есть продакшн и стейджинг. Тестируем на стейджинге. Можно создавать группы пользователей и рассылки делать конкретно по группам. Т.е. можно и на продакшн создать группу из нескольких человек и пуши слать на эту группу, но, по-моему, лучше стейдж использовать.

Подписаться на комментарии