Serverless функции - отличная возможность не думать о масштабировании, легко деплоить, а также использовать free tier для своих пет-проектов. В своей практике я часто использую этот подход и хочу поделиться опытом, когда это действительно удобно, а когда лучше посмотреть в сторону других решений.
Если у нас простая задача, например отправлять уведомления по вызову и событиям, то в целом проблем нет (только нюансы реализации). Но если мы хотим один или несколько микросервисов, или целое приложение разделить на serverless функции - тут начинаются интересные вещи. Нужно так спланировать свое приложение, чтобы оно было разделено по функциям, при этом его легко можно было масштабировать и расширять.
В этой статье хочу разобрать, каким образом проектировать и делить приложение, когда этот подход имеет смысл, а когда нет. За последние годы я реализовал несколько крупных проектов на serverless архитектуре, и постараюсь поделиться основными принципами и подводными камнями, с которыми столкнулся.
Основные концепции
Для того чтобы начать проектировать, нужно понимать основные ограничения и особенности функций. Расскажу про самые важные моменты, которые влияют на архитектуру:
Отсутствие состояния У функции не должно быть состояния. Как правило, провайдер предоставляет папку /tmp или аналогичную, но она требуется только для временного сохранения чего-то во время выполнения функции. На практике это означает, что все важные данные нужно хранить во внешних сервисах.
Время выполнения Функция ограничена по времени выполнения (у разных провайдеров разное) - в среднем 10 минут, после чего наступает таймаут. Поэтому если вам нужно пару часов что-то перемалывать, для этого кейса стоит взять виртуалку/железо, при этом все остальное вы спокойно можете использовать в функциях.
Кстати, недавно начали появляться так называемые "долгоживущие функции", для них например у Яндекса таймаут 1 час, но я пока не вижу выгоды от их использования. Если у вас есть интересные кейсы - поделитесь в комментариях.
Стоимость выполнения Исходя из ограничения по времени и особенностей оплаты за функции - чем дольше выполняется функция и больше потребляет оперативной памяти, тем больше вы заплатите. Это основные параметры тарификации - память и время обработки вызовов.
Принцип единой точки входа В идеале каждая функция должна иметь одну точку входа и выполнять одно логическое действие. Хотя само действие может быть комплексным, важно чтобы оно представляло собой единую логическую операцию.
Хороший пример - телеграм-бот. У него может быть только одна точка входа - webhook для обработки обновлений. И хотя бот обрабатывает разные типы сообщений и команд, это всё части одной логической операции - обработки обновления от Telegram. По требованиям Telegram API мы не можем разделить этот функционал на отдельные serverless функции в рамках одного бота.
Способы вызова Основные способы вызова функции зависят от провайдера, как правило - запрос или тригеры (например передача сообщений из очереди для обработки). Проектировать приложение стоит из этих двух возможностей.
Практический кейс
Давайте, для того чтобы рассматривать архитектуру предметно, сформируем требования к приложению которое будет проектировать. Это приложение для организации технической поддержки пользователей.
Функциональные требования:
-
Обработка обращений через бот:
Создание нового обращения
Просмотр статуса текущих обращений
Возможность добавить сообщение к существующему обращению
Получение уведомлений об изменении статуса обращения
Возможность оценить качество поддержки после решения
Просмотр истории обращений
-
Работа с обращениями в таск-трекере:
Автоматическое создание тикета при поступлении обращения
Отправка ответов клиенту через бот
Изменение статуса обращения
Назначение приоритета
Категоризация обращений
-
Рассылки и уведомления:
Массовая рассылка системных уведомлений всем пользователям бота
Автоматические уведомления при изменении статуса обращения
Публикация новостей сервиса
Информирование о плановых работах
-
Аналитика:
Количество обращений в разрезе каналов и категорий
Среднее время решения обращений по специалистам
Средний рейтинг удовлетворенности по специалистам
Статистика по типам обращений
Пиковые нагрузки по времени суток/дням недели
Процент просроченных обращений
Нефункциональные требования:
-
Масштабируемость:
Обработка растущего количества обращений
Поддержка лимитов мессенджера при массовых рассылках
-
Расширяемость:
Возможность добавления новых мессенджеров
Подключение дополнительных метрик для анализа
Проектирование
После определения требований давайте спроектируем нашу систему, используя serverless подход. Прежде чем углубляться в детали реализации, рассмотрим основных пользователей системы и их взаимодействие:
Клиенты обращаются в поддержку через ботов в мессенджерах, создают обращения и получают ответы
Специалисты поддержки работают с обращениями через таск-трекер, отвечают клиентам и меняют статусы тикетов
Администраторы системы управляют массовыми рассылками и анализируют эффективность работы поддержки через дашборды
В качестве языка разработки выберем Go - он отлично подходит для serverless архитектуры. Go обеспечит хорошую производительность, а главное - позволяет создавать компактные бинарные файлы, которые быстро загружаются и не требуют внешних зависимостей. Это особенно важно для serverless функций, где время холодного старта напрямую влияет на отзывчивость приложения.
Начнем с базы данных. Нам понадобится реляционная БД для хранения информации о пользователях: их идентификаторы в разных системах, права доступа, настройки уведомлений. Для хранения соответствия между чатами и тикетами использовать отдельную таблицу не требуется - эту информацию будем хранить в дополнительных полях тикета (тип мессенджера, id/nickname пользователя и id беседы).
Теперь о точках входа в систему. У нас два канала коммуникации - Telegram и VK. Создадим отдельные функции для каждого мессенджера с собственными эндпоинтами в API Gateway. Такой подход дает нам несколько преимуществ:
Изоляция: проблемы с одним ботом не влияют на работу другого
Простота масштабирования: для добавления нового мессенджера достаточно создать новую функцию по аналогии с существующими
Независимая настройка ресурсов: каждую функцию можно оптимизировать под специфику конкретного мессенджера
Для работы ботов нам понадобится хранить состояние диалогов. Например, когда пользователь создает обращение, мы собираем информацию в несколько шагов: тема, описание, тип. Кроме того, имеет смысл кэшировать часто запрашиваемые данные - список активных обращений пользователя. Для этих целей используем Redis.
Трекер:
Нам нужно обрабатывать следующие типы событий из трекера:
Создание нового комментария
Изменение статуса тикета
Для этого создадим две функции: TrackerHandler и TrackerWorker. TrackerHandler принимает входящие API запросы от трекера, валидирует их и складывает в очередь сообщений. В трекере запрос отправляется за счет триггера по заданным условиям. TrackerWorker забирает события из очереди и выполняет необходимые действия - определяет канал коммуникации из данных тикета и отправляет уведомление в соответствующий мессенджер.
Казалось бы, можно было обойтись одной функцией - получили событие и сразу отправили уведомление. Однако такой подход имеет несколько недостатков. Во-первых, отправка сообщений в мессенджеры может занять время или завершиться с ошибкой из-за недоступности API. В этом случае трекер не получит успешный ответ и будет пытаться повторить запрос, что может привести к дублированию уведомлений. Во-вторых, при большом количестве одновременных событий (например, массовое изменение статусов тикетов) мы можем превысить ограничения API мессенджеров. Разделение на две функции с очередью между ними решает эти проблемы - мы гарантируем доставку уведомлений за счет механизма повторных попыток в очереди и можем контролировать скорость отправки сообщений.
Рассылки:
С массовыми рассылками ситуация интереснее. Основная проблема здесь - ограничения API мессенджеров. Например, Telegram позволяет отправлять не более 30 сообщений в секунду, поэтому для надежности будем использовать лимит в 20 сообщений. Для этого сделаем две функции:
NotificationAPI - функция, которая принимает POST запрос с данными для рассылки (текст сообщения и критерии выборки получателей). Она получает список пользователей согласно критериям и, учитывая ограничения Telegram в 30 сообщений в секунду и стандартный таймаут serverless функций в 5 минут, разбивает получателей на батчи по 6000 сообщений (20 сообщений в секунду * 300 секунд). Каждый такой батч отправляется в очередь как отдельное задание.
NotificationWorker - это рабочая функция, которая просто получает готовый батч из очереди и последовательно отправляет сообщения пользователям с учетом ограничений API мессенджера. При ошибках отправки сообщение может вернуться в очередь для повторной попытки.
Аналитика:
Сбор аналитики в этой системе реализуется достаточно прямолинейно. Создаем функцию, которая по таймеру (например, раз в час) запрашивает через API новые тикеты и обновления по существующим, затем складывает эти данные в ClickHouse. Для начальной загрузки исторических данных предусмотрим скрипт в процессе деплоя - это избавит от необходимости отдельной инициализации и сделает развертывание системы более удобным.
Такая архитектура позволяет эффективно масштабировать систему под нагрузкой, легко добавлять новые каналы коммуникации, новые сервисы и собирать подробную аналитику. А еще это достаточно выгодно.
Если тема действительно интересна и эта статья наберёт больше 20 лайков, обязательно напишу отдельный материал о технической реализации!
Если остались вопросы или хотите обсудить тему подробнее — пишите в комментариях и подписывайтесь на мой канал в телеграмме.
Комментарии (10)
YegorP
14.12.2024 21:36Большой головняк с тем, что драйверы для взаимодествия с БД плохо оптимизированы под жизненный "ритм" функций в serverless. Например, монговский пакет для Node.js долго подключается и создаёт большой пул соединений. Это ок для классических архитектур, но в serverless надо наоборот быстро стартовать и не держать слишком большой пул, потому что параллельной обарботки запросов одним и тем же пулом соединений всё равно не будет. И вот из-за этого начинаются пляски с provisioned concurrency, тюнингом настроек, чтением исходников драйверов и т.п.
ednersky
14.12.2024 21:36полагаю тут для баз, вроде PG, балансеры/баунсеры становятся must have.
для постгри это pgbouncer.
и режим работы ни в коем случае не tcp session, а только потранзакционный переключатель. как-то так
Apokalepsis Автор
14.12.2024 21:36Да, это нужно иметь ввиду и очень сильно зависит от языка/библиотеки. Как выше написали, можно использовать внешние пулы соединений. Еще хороший вариант - ориентироваться на serverless БД которые предоставляет провайдер, с которым вы работаете.
ednersky
14.12.2024 21:36меня всегда больше всего волновал вопрос: "а как тестировать в CI вот это вот всё?"
и кроме варианта в CI создать свой namespace функций, баз данных, очередей итп и после на этом всём запустить набор тестов - кроме такого варианта в голову не приходит ничего.
а как Вы такое тестируете?
Apokalepsis Автор
14.12.2024 21:36Ответ тянет на статью, очень многое зависит от самого приложения и языка. Если коротко: unit на моках, если часть компонентов можно аналогично поднять у себя (не специфичный для облака продукт, например PostgreSQL) - тогда можно поднимать гонять в docker в рамках CI. По интеграционным тестам - большинство облачных провайдеров поддерживают версионирование функций. Можно деплоить тестовую версию функции параллельно с основной и гонять тесты на ней, направляя трафик через отдельные endpoint. БД и остальные компоненты в этом случае можно создавать под тесты - благо есть terraform и API провайдера.
ednersky
14.12.2024 21:36Ответ тянет на статью
меня вопрос serverless занимает давно, но именно из-за того, что никто не написал такую статью я делаю заключение, что ситуация здесь: "каждый ваяет что-то на коленке", а значит "и мне тоже придётся".
Поэтому я так и не удосужился попробовать.
Apokalepsis Автор
14.12.2024 21:36В целом проблема не гигантская, и многие подходы используют классические - основная задача, это повторить окружение. Поэтому провайдеры предоставляют возможности, для тестирования. Есть еще фреймворк для разработки и тестирования - serverless
Если статья наберет достаточно лайков и я пойму что это действительно будет интересно многим, то я напишу гайд по разработке, так как есть много мелких нюансов.
С материалами действительно проблема, даже на английском. По тестированию - везде две строчки с пирамидой без особых подробностей реализации. Я когда первый раз пытался сделать функцию в YC по их гайду - даже просто с выводом логов намучился.ednersky
14.12.2024 21:36Если статья наберет достаточно лайков и я пойму что это действительно будет интересно многим, то я напишу
ну я поставил плюсиков сколько мог, да ещё и подписался.
конечно, один в поле не воин, но сделал что мог :)
буду ждать :)
alhimik45
Из неочевидных подводных камней: проверьте максимальные таймауты который разрешает ваш ApiGateway. У нативного амазоновского жётский лимит в 29 секунд и порой то холодный старт подлагал, то внешнее апи медленнее ответило и упс, ваш клиент уже получил gateway timeout.
В том же AWS как вариант можно использовать Application Load Balancer, который не имеет таких лимитов как Api Gateway (как не имеет и его фичей), но при этом вы всё ещё ограничены упомянутым в статье таймаутом на время выполнения функции, что может быть стоппером для fully serverless архитектуры, если вы обращаетесь к какому-нибудь легаси у которого единственный вид взаимодействия - синхронные http вызовы по 30 минут.
Apokalepsis Автор
Спасибо за совет! Заглянул в Яндексовский - 5 минут. Основной плюс использования, если не большие личные проекты - есть free tier - 100к запросов.
Я стараюсь делать функции так, что бы они отвечали меньше двух минут. Если больше, тогда можно сделать как у меня в одном из примеров - выносить в очередь. Тригер по очереди не имеет такого лимита.