Обработка событий по времени в бессерверной архитектуре

Бессерверность  —  мир событий

Когда-то в качестве серверов все размещали непрерывно запущенные демоны, но теперь мы вступаем в бессерверную эру, когда все запускается событиями.

Это очевидно, если подумать о нормальном веб-трафике. Пользователь вводит URL-адрес веб-сайта/конечной точки API, шлюз API запускает лямбда-функцию, затем лямбда запускает DynamoDB для обновления/извлечения данных. Все начинается с пользователя.

Распространенная бессерверная архитектура. Все начинается с действий пользователя

Однако, если система достаточно велика, вы наверняка столкнетесь с ситуацией, которая требует запланированных действий. Как приспособить бессерверную архитектуру для таких случаев?

Мой прошлый проект: система управления зарядным устройством EV

Один из моих прошлых проектов  —  создание облачной системы дистанционного управления зарядными устройствами EV (Electronic Vehicle, электромобилей). Основная функция системы состояла в том, чтобы рассчитать продолжительность каждого сеанса зарядки и остановить зарядное устройство, когда это время истечет (пользователь должен выбрать продолжительность и оплатить ее целиком до начала сеанса).

Дизайн прост. Пользователи взаимодействуют с системой через API-шлюз. Лямбда-функция обрабатывает логику и отправляет команды на локальный Raspberry Pi посредством SQS-очереди, а Raspberry Pi управляет зарядными устройствами через локальную сеть.

Основная проблема вот в чем: как инициировать событие по истечению сеанса? Имей я дело с классическим сервером, можно было бы ежеминутно запускать задание CRON. Или запустить бесконечный цикл для проверки, истёк ли в данную секунду какой-либо сеанс.

while True:
    terminate_expired_sessions()
    time.sleep(1)

А что насчет бессерверной архитектуры? Что же нам сделать?

Вариант 1: CRON

В прежние времена CRON был нужен для выполнения задач по времени, почему бы сейчас не воспользоваться им же? В AWS мы можем применить правило событий CloudWatch для регулярного запуска лямбда-функций. В Azure  —  настроить функциональное приложение с триггером по таймеру.

Можно воспользоваться правилом событий CloudWatch для регулярного запуска лямбды

Нужен компромисс относительно желаемой частоты выполнения функции. Если интервал слишком велик, не выйдет точно запланировать событие. Если он слишком короткий, функция будет вызываться слишком часто, но отрабатывать вхолостую.

Другая проблема в том, что нельзя установить интервалы короче одной минуты. В моем случае это не слишком значимо, так как дать клиенту еще минуту  —  не большая потеря, но все-таки.

Как и в CRON, не получится установить интервал меньше минуты

Вариант 2: просто подождать

Если точность менее минуты недостижима с помощью правила событий CloudWatch, как насчет управления изнутри лямбда-функции?

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

Почему бы не воспользоваться первым вариантом: спланировать минутное задание CRON, чтобы проверить, есть ли в следующую минуту истекающий сеанс, и позволить функции подождать точного времени завершения?

Это распространенный подход для серверов на хостингах. Проще перевести процесс в режим ожидания, чем планировать задачу. Кроме того, процесс в состоянии ожидания не вредит системе, поскольку мы уже подготовили ресурсы, и ожидающие процессы не будут потреблять их много.

Анти-паттерны. Однако, делая это в лямбде, мы попадаем в два анти-паттерна: 1) длительные задачи и 2) недозагруженность функции.

Учитывая, что AWS Lambda допускает до тысячи параллельных операций в каждой области, если применяется для длительных задач, мы быстро истощим лимит. Представьте себе, что в следующую минуту истекает 600 сеансов зарядки, и мы вызываем 600 функций, которые дожидались окончания времени. Тогда для обслуживания прочих запросов у нас осталось всего 400 свободных операций.

Хуже всего, что время выполнения уходит на ничегонеделание. Лямбда-функция перезагружается каждые 100 мс, что делает архитектуру более экономичной. Однако, вызывая функцию, чтобы просто ждать чего-то, мы теряем это преимущество.

Вариант 3: пошаговые функции

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

Например, проектируя систему бронирования путешествий, вы создали функции бронирования гостиничных номеров и авиабилетов. Если вы вызываете эти две функции отдельно, как гарантировать, что обе выполнятся успешно? Вам вряд ли хочется, чтобы клиент полетел в пункт назначения и только потом понял, что не смог забронировать отель.

Через пошаговые функции легко реализовать шаблон. Вы можете определить логику переходов состояний: к примеру, если бронирование рейса прошло успешно, надо приступать к бронированию гостиничного номера. Если наоборот, полет отменяется.

Пошаговые функции и шаблон
Период ожидания в пошаговых функциях

Конечно, в наши пошаговые функции можно добавить периоды ожидания, например, если авиакомпания не позволяет вам отменить только что забронированный рейс, вы можете добавить период ожидания перед его отменой.

Это стоит денег. Step Functions  —  настолько мощный инструмент, что он недешев. Каждые 1000 переходов состояний стоят 0,025 доллара.

В моем случае зарядное устройство просто должно прекратить работу в определенное время, никакой сложной координации не требуется, и пошаговые функции кажутся излишними.

Вариант 4: CRON + SQS

Наконец, я придумал такое решение: CloudWatch Event для реализации CRON и SQS для запуска событий со вторичной точностью.

Это своего рода объединение первых трех вариантов: сначала применяется событие CloudWatch для планирования задания CRON, а затем создается период ожидания для завершения сеанса зарядки в точное время. Вместо того чтобы использовать саму лямбда-функцию или пошаговые функции, для реализации периода ожидания я выбираю SQS.

Во-первых, я создал очередь SQS для хранения этих запланированных действий. Я не задействовал существующую очередь, потому что не хочу напрямую вызывать команду. Мне нужно выполнить функцию для окончательной проверки и посмотреть, есть ли какие-либо изменения (например, пользователь может добавить больше часов в сеанс зарядки). В таком случае прекращать сеанс зарядки не требуется.

AWS SQS позволяет доставлять сообщение с задержкой до 15 минут, поэтому функцию find_expiring_sessions я планирую на каждые 15 минут.

def find_expiring_sessions():
    fifteen_minute_later = datetime.now() \
        + timedelta(minutes = 15)
    expiring_sessions = Sessions.filter(
        expire_time__lte = fifteen_minute_later,
        expire_time__gt = datetime.now()
    )    

for expiring_session in expiring_sessions:
        sqs_client.send_message(
            QueueUrl='xxxxxxxxxx',
            DelaySeconds=expiring_session.expire_time-datetime.now(),
            MessageBody=expiring_session.id
        )

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

Поскольку SQS основан на запросах, функция, запускаемая очередью, нуждается в разрешении на доставку сообщения себе. Поэтому я прикрепил к своей функции handle_expiring_session политику.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "sqs:DeleteMessage",
                "sqs:ReceiveMessage",
                "sqs:GetQueueAttributes"
            ],
            "Resource": "arn:aws:sqs:us-east-1:111111111111:ExpireSessionWaitQueue"
        }
    ]
}

Затем я настроил триггер из очереди в функцию handle_expiring_session.

Теперь, когда время зарядки истечет, запустится handle_expiring_session и сможет немедленно выполнить процесс завершения.

Сравнение вариантов

Воспользуюсь следующим сценарием для сравнения этих четырех вариантов:

  • Строго 240 окончаний сеанса в течение каждого часа.
  • Все сеансы заканчиваются на 30-й секунде (например, в 14:02:30).
  • Фактическое выполнение требует 500 миллисекунд и 128 МБ памяти.
  • Продолжительность всех сеансов зарядки одинакова.

Для варианта 1 будет выполняться 60 вызовов CRON в час. Каждый вызов однократно выполняет лямбда-функцию. Поскольку завершение происходит в пакетном режиме, можно предположить, что необходимое время равно 500 мс.

Стоимость варианта 1 составит: $0.00006 (60 событий CloudWatch) + $0.000012 (60 лямбда-исполнений) + $0.00006249 (30с полное время выполнения) = $0.00013449 в час.

Для варианта 2 будет выполняться 60 вызовов CRON в час. Каждый вызов выполняет 4 лямбда-функции (4 завершения в минуту), и эти функции будут выполняться в течение 30 секунд.

Стоимость варианта 2 составит: $0.00006 (60 событий CloudWatch) + $0.000048 (240 лямбда-исполнений) + $0.01524756 (240 x 30.5 s время выполнения лямбда-функции) = $0.01535556 в час.

Для варианта 3 не будем рассматривать применение CRON  —  учитывая, что допустимое время выполнения пошаговых функций составляет в пределах одного года. Достаточно просто запланировать действие завершения от начала и до конца сеанса. У нас 240 сеансов в час, так что будет 240 переходов состояний в час.

Стоимость варианта 3 составит: $0.006 (240 переходов состояний)

Для варианта 4 будет 4 вызова CRON (15-минутный интервал), каждый вызов генерирует 240 SQS-сообщений (240 завершений в час), каждое сообщение в конечном итоге вызовет функцию завершения.

Стоимость варианта 4 составит: $0.000004 (4 события CloudWatch) + $0.000096 (240 сообщений SQS) + $0.000048 (240 лямбда-функций) + $0.00024996 (240 x 500 мс времени выполнения) = $0.00039796

Среди этих вариантов хуже всего  —  применять лямбду. Это стоит очень дорого, а квота в тысячу параллельных процессов также ограничивает масштабируемость. Единственное, что здесь хорошо,  —  простота в использовании. Внутри кода вы можете реализовать данный вариант как угодно.

Простой CRON и дешев, и масштабируем. Недостаток здесь в том, что достижима точность только в масштабе до одной минуты.

Пошаговые функции хороши для координации сложных ситуаций, но будут излишеством для решения простых временных коллизий.

Самым подходящим вариантом будет CRON + SQS  —  если вам хочется, чтобы события, основанные на времени, были запланированы с точностью до секунды.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи: Richard Fan, “How I handle time-based events in serverless architecture”

Предыдущая статьяПочему ведущие инженеры ненавидят собеседования
Следующая статьяОптимизация работы баз данных с PostgreSQL 12