Делаем простой и надежный микросервис рассылки пушей на компонентах 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 $ в месяц.

Похожие статьи:
Анастасія Войтова — Head of Customer Solutions та Security Software Engineer в Cossack Labs. Також вона Security Lead в неурядовій організації Women Who Code Kyiv. У новому...
До вашої уваги дайджест навчальних програм для тих, хто починає свою кар’єру в ІТ. В цьому номері зібрані можливості, актуальні...
Здравствуйте, коллеги. Представляю вам небольшую подборку каверзных вопросов по нашему любимому языку структурированных...
Військова агресія рф проти України триває вже понад 40 днів, і за цей час на воєнні рейки довелось встати не лише...
Добрый день, уважаемые читатели! Меня зовут Кирилл Пшеничный. Я разработчик C++ в TeamDev. Основной моей задачей...
Яндекс.Метрика