В этой статьей пойдет речь о особенностях разработки бекенда под AWS лямбды, о canary деплойменте, версионирование, логгах, трейсинге, мониторинге, маршрутизации и расширениях.
Привет, я, Петер Ибрагимов, и в Whoosh я занимаюсь бекенд разработкой на Python. В этой статье расскажу, как мы делаем наши микросервисы на лямбдах.
Мы используем лямбды для высоконагруженных микросервисов, чтобы оптимизировать расходы, обеспечить мгновенную масштабируемость, отказоустойчивость и легкую интеграцию с другими нашими сервисами.
![Очень хорошо работают Очень хорошо работают](https://habrastorage.org/getpro/habr/upload_files/ec7/e70/0c9/ec7e700c915148df72ec465e84a32506.png)
Что такое AWS Lambda
AWS Lambda — это FaaS (функция как услуга) решение от AWS, которое позволяет запускать любой (почти) код в облаке без необходимости настройки серверов, лоад балансеров, масштабирования и тд. В отличие от классических решений, оно позволяет легко задеплоить код без подготовки инфраструктуры, но для полноценного бекенда все же потребуются девопс-инженеры: кому-то надо настраивать базы, кеши, реверс-прокси, автоматизацию деплоймента и тд.
Для лямбд мы используем Python 3.9, поэтому код в примерах будет написан именно на нем.
Как работают лямбды? Нам достаточно написать код, упаковать его в архив и залить в AWS:
![Интерфейс настройки лямбды Интерфейс настройки лямбды](https://habrastorage.org/getpro/habr/upload_files/112/dd9/44d/112dd944d474a4dc7d258e51c0f78912.png)
Для настройки лямбды базово нужно сделать две вещи:
Выбрать триггер, то есть установить правила, по которым запросы будут направляться в лямбду. Например, это может быть триггер событий из SQS, Kafka, API Gateway. Триггер получает событие из источника, передает его лямбде в формате словаря
Dict[str, Any]
и ожидает ответа. Если пришло событие из API Gateway, то в ответ ожидается такой же словарь с полямиstatusCode
,body
и тд.Настроить доступную память. Единственной настройкой производительности в лямбдах является максимальное потребление ОЗУ. Чем больше ОЗУ, тем дороже становится лямбда, но при этом увеличивается доступное процессорное время и пропускная способность сети.
Для создания эндпоинта "hello world" из примера выше, нужно лишь настроить API Gateway. Однако, настройка этого сервиса достаточно сложная и заслуживает отдельной статьи.
![Примеры триггеров для лямбд Примеры триггеров для лямбд](https://habrastorage.org/getpro/habr/upload_files/9fc/882/c8c/9fc882c8c3d84e0fe6999d6e4bd98e4f.png)
Когда лямбда получает запрос от триггера, AWS автоматически создает инстанс лямбды - экземпляр сервера, на котором запускается наш код. Если инстанс не справляется с запросами, AWS создает новые инстансы, чтобы обработать все запросы. По умолчанию, параллельно может быть запущено до 1000 лямбд. Один инстанс лямбды существует примерно 2 часа при постоянной нагрузке, после чего AWS автоматически перезагружает его. Кроме того, один запрос может обрабатываться максимум 15 минут. Если лямбда не укладывается в этот лимит, то AWS автоматически прекращает ее выполнение с ошибкой таймаута.
Языки
Лямбды нативно поддерживают множество языков: Go 1.x(то есть любой Go), Java 11, Python 3.9, .NET, Node JS и тд. Кроме того, можно использовать докер-контейнеры для запуска кода, но у них есть большой недостаток — холодный старт занимает заметно больше времени по сравнению с нативным запуском.
К интересному эффекту использованния интерпретируемых языков можно отнести то, что при запуске из лямбд они не так сильно уступают в производительности компилируемым языкам. Это происходит из-за того, что каждый запрос обрабатывается отдельным инстансом лямбды, переиспользования ресурсов не происходит, поэтому сборка мусора, переключение тредов и остальные неоптимизированные вещи в интерпретируемых языках не так сильно влияют на производительность.
Из заметных различий:
Java очень медленно стартует и требует сильно больше памяти в сравнением с Go и Python. Недавно появился Snapstart, который снижает скорость холодного старта, но пока мы его не тестировали.
Python медленее Java и Go на CPU-Bound (очевидно)
В остальных задачах большой разницы в языках нет, с IO-Bound справляются все хорошо.
Бенчмарки
В интернете довольно много бенчмарков, например: https://filia-aleks.medium.com/aws-lambda-battle-2021-performance-comparison-for-all-languages-c1b441005fd1
Также мы сами проводили несколько бенчмарков, например, сравнение одинаковых по функционалу лямбд на GO и Python: распарсить запрос, сходить в Postgres базу, провести небольшие вычисления и отформатировать ответ и вернуть. Нагрузка была ~300 RPS.
![Результаты тестирования GO Результаты тестирования GO](https://habrastorage.org/getpro/habr/upload_files/b4d/f21/fd2/b4df21fd2a405f3bcb066d81a140c771.png)
![Результаты тестирования Python Результаты тестирования Python](https://habrastorage.org/getpro/habr/upload_files/e32/937/b74/e32937b74550154c3c2f6603b0fb6fe8.png)
Тесты проводились на небольших объемах данных, но все равно питон оказался почти в 10 раз медленнее.
Если будет интересно посмотреть больше бенчмарков, напиши в комментариях, я проведу более объемные и честные тесты!
Триггеры
Каждый запуск лямбды происходит через триггер. Триггером может служить очередь, SQS, SNS, другая лямбда, поток(Kinesis, Kafka) и бесконечное количество других интеграций.
Таким образом, если вы хотите сделать джобу, которая будет что-то вычитавать из кафки и обрабатывать ивенты, вам не надо настраивать кафка консьюмеров, АВС сделает это за вас. Более того, все интеграции с очередями работают пачками. Вы можете настроить триггер таким образом, чтобы за раз 1 лямбда обрабатывала не 1 ивент, а сразу Х.
У потоковых триггеров есть 2 настройки: batch_window
и batch_seconds
. Если в очереди накапливается достаточное количество сообщений, чтобы заполнить размер batch_window
, они будут отправлены в лямбду одним пакетом. Если же сообщений недостаточно, они будут отправлены в пакетах через batch_seconds
секунд, чтобы максимально эффективно использовать ресурсы и минимизировать время обработки.
Лямбды для бекенда
Для разработки полноценных веб-приложений нужно сделать очень много приседаний:
Подключить роутинг по эндпоинтам для API Gateway лямбд.
Настроить логирование
Написать автоматический деплоймент с IaC, чтобы не надо было каждый раз вручную обновлять и настраивать лямбды.
Настроить сбор технических и бизнес метрик
Роутинг по эндпоинтам для API Gateway лямбд.
Когда в лямбду приходит HTTP-запрос из API Gateway, он выглядит примерно так:
{
"body": "\"{\"hello\": \"world\"}\"",
"resource": "/items/42",
"path": "/items/42",
"queryStringParameters": {
"q": "some query param",
},
"httpMethod": "POST",
"isBase64Encoded": false,
"requestContext": {
"httpMethod": "POST",
"path": "/items/42",
"resourcePath": "/items/42",
}
}
Каждый раз вручную его парсить и маршрутизировать очень трудозатратно, поэтому мы используем FastAPI в связке с Mangum.
FastAPI — веб-фреймворк, mangum — адаптер, который переводит сообщения из API Gateway формата в ASGI формат, который поддерживается FastAPI.
from typing import Optional
from fastapi import FastAPI
from mangum import Mangum
app = FastAPI()
@app.get("/")
def read_root() -> dict:
return {"Hello": "World"}
@app.get("/items/{item_id}")
def read_item(item_id: int, q: Optional[str] = None) -> dict:
return {"item_id": item_id, "q": q}
handler = Mangum(app)
Этого кода достаточно, чтобы запустить полноценный FastAPI в лямбдах и радоваться тому, что не надо настраивать uvicorn
и docker/systemd
.
Логирование
Весь stdout/stderr
записывается в CloudWatch, это сервис AWS для сбора метрик, логов и тд. Он позволяет как просто читать логи по какой-то конкретной лямбде, так и делать запросы по ним. Если тело лога представляет собой json, например, {"level": "WARNING", "message": {"status": "no.such.source"}}
, то найти все указанные логи по конкретно лямбде можно в GUI Cloud Watch Log Insights.
![Найденные логи Найденные логи](https://habrastorage.org/getpro/habr/upload_files/7de/e1d/a7f/7dee1da7f32191977cc9a9b19b695e76.png)
Для логирования мы используем библиотеку aws-lambda-powertools, которая логирует данные в json формате, совместимым с CloudWatch. В коде же достаточно написать:
from aws_lambda_powertools import Logger
logger = Logger()
logger.warning({"status": "no.such.source"})
Metrics
Помимо логов, CloudWatch содержит два мощных сервиса для мониторинга производительности в лямбдах: Metrics и Tracing. По умолчанию, для всех лямбд собираются метрики ошибок при запуске, количество запусков, длительность, количество параллельно запущенных инстансов лямбд, троттлинг лямбд. При необходимости, можно подключить сервис Lambda Insights, который будет формировать метрики по использованию RAM, CPU, Network и тд. Все собранные метрики легко экспортируются в Grafana и позволяют быстро получать актуальную статистику по каждой из лямбд.
![Метрики по умолчанию Метрики по умолчанию](https://habrastorage.org/getpro/habr/upload_files/d1a/ec0/c2f/d1aec0c2f4cd572ccc09c26254045b26.png)
![Метрики из Lambda Insights Метрики из Lambda Insights](https://habrastorage.org/getpro/habr/upload_files/eab/319/7aa/eab3197aa3536f3e6453c4e442b122c7.png)
Также, библиотека aws-lambda-powertools позволяет формировать свои собственные метрики. Это может быть полезно при сборе информации для бизнеса, например, метрик блокировок пользователей:
from aws_lambda_powertools.metrics import MetricUnit, Metrics
metrics = Metrics()
if True:
metrics.add_metric("signup.blocked", MetricUnit.Count, value=1)
Tracing
Последний, но не по значению, сервис XRAY, который позволяет трассировать скорость выполнения запросов. Мы сделали свой декоратор, который навешивается на класс и при выполнении любого метода этого класса пишет лог времени его выполнения.
Пример
from dataclasses import dataclass
from aws_lambda_powertools import Tracer
default_tracer: Tracer = Tracer()
def trace_methods(tracer: Tracer = default_tracer):
def cls_decorator(cls):
for attr_name in cls.__dict__:
attr = getattr(cls, attr_name)
if inspect.isfunction(attr):
decorator = tracer.capture_method(capture_response=False, capture_error=True)
setattr(cls, attr_name, decorator(attr))
return cls
return cls_decorator
@dataclass
@trace_methods()
class AppService:
def hello_world(self) -> None:
print("this method's execution time will be traced")
Дисклеймер
Да, мы знаем, что логи и трассировка снижает скорость выполнения кода, поэтому с этим надо быть аккуратным. Также, нельзя забывать, что Xray и CW — платные и не очень дешевые сервисы. По своей глупости я однажды забыл отключить DEBUG лог на высоконагруженной лямбде, из-за чего на 5 дней ценник на CW повысился на пару сотен долларов…
Xray автоматически формирует схему сервисов, то есть весь путь выполнения запроса, начиная от API Gateway, через все лямбды, очереди, потоки и тд, заканчивая суплаерами: базы данных, S3 бакеты, Redis и так далее.
![Карта сервисов Карта сервисов](https://habrastorage.org/getpro/habr/upload_files/c7b/6a5/eea/c7b6a5eea67270eab4ee099aa1fc5cc3.png)
![Скорость отработки запросов Скорость отработки запросов](https://habrastorage.org/getpro/habr/upload_files/d43/97f/a07/d4397fa07418b88c8475515c3e773cb7.png)
Также XRAY отображает время выполнения метода, его ответ. При работе с некоторыми библиотеками, сервис автоматически добавляет дополнительные данные к трейсу, например, при использовании SQLAlchemy пишется выполненный SQL запрос (без аргументов, конечно).
Alias и Versions
Вы можете вызывать лямбды с разными версиями или тегами(alias). Последней загруженной кодовой базе лямбды можно добавить версию, к которой впоследствии можно обращаться для запуска, либо навесить на нее алиас, который умеет в canary deployment! Иными словами, можно одному алиасу дать 2 версии лямбды и выставить между ними уровень весов, скажем, 80% на stable и 20% на новый релиз.
![Интерфейс создания версий Интерфейс создания версий](https://habrastorage.org/getpro/habr/upload_files/6f9/c19/4ff/6f9c194ff83b110a14551d58f80434df.png)
![Интерфейс настройки алиасов Интерфейс настройки алиасов](https://habrastorage.org/getpro/habr/upload_files/faa/4cc/f01/faa4ccf0138f36d6012a3f30784f1fb9.png)
Тяп-ляп и в прод
По моему мнению, главный плюс и одновременно минус лямбд — скорость выкатки в продакшен. Если у вас настроен IaC, то даже с алиасами вы можете доставить код за 1-2 минуты.
По нашему опыту, деплоймент кода занимает 30 секунд, а переключение версий на горячую — 1.5 минуты.
При этом у AWS есть встроенный редактор кода для интерпретируемых языков, что позволяет буквально в продакшене чинить критические баги в краткий срок.
![Результат деплоймента в продакшен из консоли AWS Результат деплоймента в продакшен из консоли AWS](https://habrastorage.org/getpro/habr/upload_files/487/53a/a5d/48753aa5d3a782fe38e3473ae9fe42ba.jpeg)
Конечно, так делать можно только в критических ситуациях, например, когда откатиться, по какой-либо причине, невозможно.
С другой стороны это очень удобно при отладке и разработке, но юнит тесты, конечно, лучше.
Extensions
У лямбд есть extensions, это отдельные процессы, которые запускаются вместе с лямбдой и что-то делают, например, интегрируются с hashicorp vault.
Ценообразование и что не надо пытаться пихать в лямбды.
Большого смысла оптимизировать расходы на лямбды нет, по одной простой причине: базы данных, редис, очереди и все остальное все равно намного дороже.
Но даже с учетом этого стоит помнить о том, из чего строится цена лямбды и что не стоит затаскивать в лямбды. Биллинг лямбд состоит из 2х показателей: количество запуском и время выполнения в GB/s, где GB — количество гигабайт выделенной ОЗУ на лямбду.
Лямбда на ARM64 стоит 13.3334 mkD за GBs
0.20 долларов за 1M запросов.
mkD - Микродоллары, то есть 0.000001 долларов США. Данные для региона eu-west-1.
Интересный факт, ARM64 дешевле.
![](https://habrastorage.org/getpro/habr/upload_files/414/527/4e1/4145274e12e10283c4c82eb284d42a3b.png)
Второй интересный факт: повышение выделенной ОЗУ может понизить стоимость. Как мы писали выше, повышение ОЗУ также позволяет получить более мощные CPU и повысить пропускную способность сети. Так как биллинг лямбд пропорционален GB выделенной ОЗУ на секунду выполнения, то, иногда, повышение ОЗУ понижает время выполнения, что в свою очередь понижает стоимость. У AWS есть утилита, которая позволяет подобрать идеальные параметр ОЗУ под конкретную лямбду. В Whoosh мы проводили замеры и пришли к выводу, что в 90% случаев на бекенд задачи(много IO-bound, мало CPU-bound) для python3.9 подходит 512MB ОЗУ.
Лямбды очень хорошо справляются с задачами бекенд сервисов: API, обработка данных из очередей, ETL задачи и тд. Но очень-очень худо справляются с долгоработающими джобами по двум причина: очень дорого и нет нормальной обработки timeout ошибок.
Вопрос с ценой очевидный: чем дольше работает лямбда, тем больше вы платите. 1М запусков лямбд с 100мс временем выполнения будет стоить ~1 доллар, а с 1с выполнения - почти 9 долларов.
С обработкой ошибок еще хуже. Когда срок выполнения запроса лямбдой подходит к концу, она принудительно останавливается и в cloud watch пишется один лог:
![Последняя строчка - лог лямбды в случае Timeout ошибки Последняя строчка - лог лямбды в случае Timeout ошибки](https://habrastorage.org/getpro/habr/upload_files/57b/56b/0c1/57b56b0c1fcc38c746484536f89b6281.png)
Если в вашем коде недостаточно логов и вы не подключали XRay, то практически невозможно понять, из-за чего именно произошел таймаут, так как никакого стактреса не пишется, только AWS-Request-Id
, по котором можно найти запрос в логах, если, конечно, вы его пишете. В ином случае вы даже не будете знать какой запрос повлек таймаут.
Autoscalling
Одна из самый "продающих" особенностей лямбд: полноценное автоскалирование. В случае, если 1 лямбда не справляется с загрузкой, то AWS без проблем даст вам еще инстансов. Скорость скалирования поражает, если ваш код запускается быстро, скажем несколько секунд, то AWS без проблем может отскалировать тысячу лямбд за 1 минуту.
![График холодных запусков наших лямбд График холодных запусков наших лямбд](https://habrastorage.org/getpro/habr/upload_files/123/bc2/468/123bc24680ffe85c231a2af879aeceaf.png)
Объяснение графика
Совсем недавно мы проверяли новую базу. Надо было провести нагрузочное тестирование на 3 тысячах RPS на одно эндпоинте. К сожалению, база не справилась и вылетела в 99% загрузки CPU, но при этом лямбдам хоть бы хны, AWS отскалировал нам 1.5 тысячи инстансов лямбд.
Колд старт на питоне очень радует. Большая часть наших лямбд стартует за 8-10 секунд, из которых больше всего времени тратится на сборку кеша, получение коннектов к базам и тд.
![Время затраченное на холодные старты Время затраченное на холодные старты](https://habrastorage.org/getpro/habr/upload_files/c1e/bea/fea/c1ebeafeaefe5be434fab8c32205d616.png)
С одной стороны, это очень приятно, но мы ни один раз почти не ложили продакшен из-за этого. Дело в том, что AWS-то все равно, из-за чего лямбды скалируются, поэтому, например, если ваша база начинает тупить, то AWS просто докинет лямбд из-за чего вашей базе будет только хуже.
Пример
С год назад, мы запускали новый сервис, которые должен был читать данные из базы, отсылать их в SQS очередь, а другая лямбда должна была уже трансформировать их и сохранять в базу обратно. На тестовых стендах все было хорошо, но когда мы включили сервис в продакшен, первая лямбда корректно собирала данные в очередь, но лямбда обработки данных начала очень сильно нагружать базу. В свою очередь AWS отскалировал указанную лямбду и так мы чуть не убили продакшен базу. Дело оказалось классическим: на одной из таблиц не было индексов.
Чтобы такого не происходило, для новых сервисов нужно устанавливать reserved concurrency. Это максимально допустимое количество параллельно работающих лямбд.
Небольшое дополнение
Reserved concurrency также указывает "квоту" на количество параллельных лямбд. Таким образом, что не важно насколько сильно отскалируются другие лямбды, у указанной лямбды все равно будет возможность получить параллелизм в заданом значение.
Provisioned concurrency
У AWS лямбд есть возможность "зарезервировать" инстансы заранее. Не путать с reserved concurrency, о котором писалось выше. Зарезервированные инстансы — это лямбды, которые постоянно включены и поэтому в случае повышения нагрузки, не будет задержки на скалирование. Другая особенность зарезервированных инстансов — они дешевле. Цена запусков такая же, как и обычных лямбд, но GB/S дешевле:
Обычная лямбда на ARM64 стоит 13.3334 mkD за GBs
Аренда лямбды стоит 3.3334 mkD/(GBs)
Выполнение зарезервированной лямбды стоит 7.7778 mkD/(GBs)
Таким образом, суммарно за зарезервированную лямбду мы платим 11.1112 mkD/(GB/s)
Не очень большая экономия, но все равно приятно! Если ваши лямбды быстро включаются, скажем, вы пишете на GO, Python, то provisioned concurrency чуть-чуть снижает ценник. Если же вы используете языки с большим холодным стартом, то такой резерв позволит быстрее скалироваться.
Автоскалирование автоскалирования
![Вы сами понимаете шутку Вы сами понимаете шутку](https://habrastorage.org/getpro/habr/upload_files/fdd/e76/8ae/fdde768aedcdf2e413e5d318608c2f08.png)
Provisioned concurrency имеет возможность автоскалироваться самостоятельно, его можно настроить таким образом, чтобы утилизация зарезервированных лямбд была всегда 100%, в таком случае вы просто понижаете ценник за лямбды, либо чуть-чуть больше, таким образом, у вас всегда будет запас ресурса. К сожалению, у AWS нет UI для установки autoscalling policy, поэтому под спойлером мы оставили пример .sh скрипта для настройки политик.
Некрасивый, но рабочий скрипт
#!/usr/bin/env bash
# https://docs.aws.amazon.com/autoscaling/application/userguide/application-auto-scaling-tutorial.html
lambda_name=$1
alias_name="master"
target_value=$2
echo "Setting $target_value for $lambda_name"
aws --region eu-west-1 application-autoscaling register-scalable-target \
--service-namespace lambda \
--scalable-dimension lambda:function:ProvisionedConcurrency \
--resource-id function:$lambda_name:$alias_name \
--min-capacity 1 \
--max-capacity 50
policy_config=$(cat <<-END
{
"TargetValue": $target_value,
"CustomizedMetricSpecification": {
"Dimensions": [
{
"Name": "FunctionName",
"Value": "${lambda_name}"
},
{
"Name": "Resource",
"Value": "${lambda_name}:master"
}
],
"MetricName": "ProvisionedConcurrencySpilloverInvocations",
"Namespace": "AWS/Lambda",
"Statistic": "Average",
"Unit": "Count"
}
}
END
)
aws --region eu-west-1 application-autoscaling put-scaling-policy \
--service-namespace lambda \
--scalable-dimension lambda:function:ProvisionedConcurrency \
--resource-id function:$lambda_name:$alias_name \
--policy-name ${lambda_name}_ScallingPolicy --policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration "$policy_config"
Альтернативы
Помимо AWS, лямбдами занимаются практически все известные облачные провайдеры, например: Azure, Google, Yandex, Cloud.
Все они чуть-чуть отличаются и имеют свои преимущества. Например, у Yandex очень простая работа с зависимостями для питона: требуется просто положить requirements.txt
файл в рут лямбды, а Yandex Cloud сам скачает все нужные зависимости.
Деплоймент
Одна из болей микросервисной архитектуры — деплоймент. Для каждой лямбды должен быть деплоймент, желательно со всеми фичами: Xray, Alias, Autoscalling, Логирование ошибок и метрик и тд.
Для деплоймента мы используем terraform с terragrunt и самописными модулями. Это необходимо, потому что всем лямбдам нужно задать огромное количество одинаковых политик, переменных окружения, VPC коннектов и всего остального. К счастью у терраформа есть возможность подключать кастомные модули из приватных репозиториев.
К сожалению, это все отнимает огромное время. Хоть терраформ(HCL) имеет хорошую документацию и тернарные операторы, в нем все равно очень много вещей требуют большого погружения и больших ресурсов. Например, деплоймент приватного API Gateway с 1 лямбда интеграцией занимает где-то 200 строк кода.
Вывод
Вот так можно с минимальной тратой времени девопс-инженеров собрать полноценный веб-сервис с метриками, логами, скалированием и всем тем, что требуется для production highload сервисов. Если вы знаете о каких-либо фичах и полезностях лямбд, — пишите в комментарии!