Введение

AWS Lambda - популярная платформа для разработки serverless-функций, и как Java-разработчик мне нравится иметь возможность использовать эту платформу. Однако, есть некоторые важные моменты, о которых стоит помнить при работе с ней:

  1. Стоимость serverless-функций в AWS Lambda может быть высокой при использовании JVM.

  2. Холодные запуски AWS Lambda могут стать серьезной проблемой при использовании JVM.

  3. Увеличение эффективности AWS Lambda для каждого запроса (используя настройки RAM) может быть дорогостоящим, и при использовании JVM оно может быть не очень эффективным.

Две основные цели этой статьи заключаются в следующем:

  • Изучить, как использовать сервисы AWS, например интеграцию DynamoDB с приложением на Quarkus, которое запущено как AWS Lambda функция.

  • Добиться лучшей производительности на AWS Lambda при минимальных затратах

Демо-приложение

Мы будем использовать как пример приложение на Java, разработанное на JDK 11 и Quarkus, которое представляет собой простую функцию AWS Lambda. Эта простая функция принимает имя фрукта в формате JSON и возвращает тип фрукта. Пример входных данных:

{
  "name": "Apple"
}

Типы фруктов для выходных данных:

  • фрукты весеннего сезона (spring season fruit)

  • фрукты летнего сезона (summer season fruit)

  • фрукты осеннего сезона (fall season fruit)

  • фрукты зимнего сезона (winter season fruit)

Схема приложения:

Воркфлоу взаимодействия юзера с системой

Это простое приложение на Java, которое извлекает запрошенную информацию о фруктах (тип фруктов) и возвращает её пользователю:

Создание Java-приложения на Quarkus

Quarkus предлагает четкое руководство, по которому можно сконфигурировать простой шаблон AWS Lambda функции. Этот шаблон проекта можно легко получить с помощью команды Maven:

mvn archetype:generate \
 -DarchetypeGroupId=com.thinksky \
 -DarchetypeArtifactId=aws-lambda-handler-qaurkus \ 
 -DarchetypeVersion=2.1.3.Final

Команда сгенерирует шаблон приложения с использованием AWS Java SDK. Quarkus имеет расширения для DynamoDB, S3, SNS, SQS и т.д., но я предпочитаю использовать AWS Java SDK v2, который предлагает неблокирующие функции. Итак, файл проекта pom.xml должен быть изменен в соответствии с этим руководством. В проекте есть зависимость Lambda в pom-файле:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-amazon-lambda</artifactId>
</dependency>

Чтобы иметь возможность подключаться к DynamoDB нужно добавить необходимые зависимости:

<dependencies>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-amazon-dynamodb</artifactId>
    </dependency>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-apache-httpclient</artifactId>
    </dependency>
    <dependency>
        <groupId>software.amazon.awssdk</groupId>
        <artifactId>apache-client</artifactId>
        <exclusions>
            <exclusion>
                <artifactId>commons-logging</artifactId>
                <groupId>commons-logging</groupId>
            </exclusion>
        </exclusions>
    </dependency>
</dependencies>

Также я буду использовать клиент apache, который можно добавить с помощью зависимости apache-client. В файле конфигурации (application.properties) нужно будет указать это:

quarkus.dynamodb.sync-client.type=apache

Преимущества использования Quarkus для разработки Java-приложения на AWS Lambda

Чаще всего Java-проект на AWS Lambda это довольно простое Java-приложение, однако используя Quarkus можно пользоваться внедрением зависимостей (Dependency Injection):

@ApplicationScoped
public class FruitService extends AbstractService {
    @Inject
    DynamoDbClient dynamoDB;

    public List<Fruit> findAll() {
        return dynamoDB.scanPaginator(scanRequest())
            .items()
            .stream()
            .map(Fruit::from)
            .collect(Collectors.toList());
    }

    public List<Fruit> add(Fruit fruit) {
        dynamoDB.putItem(putRequest(fruit));
        return findAll();
    }
}

DynamoDbClient это класс из AWS Java SDK v2, который Quarkus создаст и сделает доступным в своём контексте внедрения зависимостей. Класс FruitService является наследником абстрактного класса AbstractService, и этот абстрактный класс будет предоставлять базовые объекты, которые требуются для DynamoDbClient, например ScanRequest, PutItemRequest и т.д.

Механизм рефлексии (reflection) популярен в фреймворках Java, но он создаёт проблемы для нативного образа GraalVM (читайте больше информации - рефлексия в GraalVM), который мы будем собирать дальше. Но у Quarkus есть простое решение этой проблемы - аннотация к классам @RegisterForReflection. Это упрощает работу с классами и рефлексией в GraalVM.

@RegisterForReflection
public class Fruit {
    private String name;
    private Season type;

    public Fruit() {}

    public Fruit(String name, Season type) {
        this.name = name;
        this.type = type;
    }
}

Также Quarkus предлагает множество других интересных возможностей для работы с AWS Lambda, но я рассмотрю их в серии будущих статей.

Развертывание демо-приложения на AWS Lambda

Настало время развернуть наше приложение на AWS, и это будет относительно просто используя Maven и Quarkus. Однако перед развертыванием и запуском приложения требуется дополнительная настройка нашего окружения AWS. Процесс развертывания состоит из следующих этапов.

Создание таблицы Fruits_TBL в DynamoDB:

$ aws dynamodb create-table --table-name Fruits_TBL \
   --attribute-definitions AttributeName=fruitName,AttributeType=S \ 
   AttributeName=fruitType,AttributeType=S \ 
   --key-schema AttributeName=fruitName,KeyType=HASH \
   AttributeName=fruitType,KeyType=RANGE \ 
   --provisioned-throughput ReadCapacityUnits=1,WriteCapacityUnits=1

Заполнение этой таблицы тестовыми записями:

$ aws dynamodb put-item --table-name Fruits_TBL \ 
      --item file://item.json \ 
      --return-consumed-capacity TOTAL \ 
      --return-item-collection-metrics SIZE

Вот содержимое файла item.json:

{
  "fruitName": {
    "S": "Apple"
  },
  "fruitType": {
    "S": "Fall"
  }
}

Чтобы убедиться, что у нас есть элементы в таблице DynamoDB, запустим запрос:

$ aws dynamodb query \
     --table-name  Fruits_TBL \ 
     --key-condition-expression "fruitName = :name" \
     --expression-attribute-values '{":name":{"S":"Apple"}}'

Определим роль в IAM, чтобы иметь доступ к DynamoDB, и назначим ее нашему приложению Lambda:

$ aws iam create-role --role-name fruits_service_role --assume-role-policy-document file://policy.jsonjson

Содержимое файла policy.json:

{
  "Version": "2012-10-17",
  "Statement": {
    "Effect": "Allow",
    "Principal": {
      "Service": [
        "dynamodb.amazonaws.com",
        "lambda.amazonaws.com"
      ]
    },
    "Action": "sts:AssumeRole"
  }
}

Затем назначим этой роли разрешение на доступ к DynamoDB:

$ aws iam attach-role-policy --role-name fruits_service_role --policy-arn "arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess"
$ aws iam attach-role-policy --role-name fruits_service_role --policy-arn "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole""le"

Также может потребоваться следующее разрешение для роли:

$ aws iam attach-role-policy --role-name fruits_service_role --policy-arn "arn:aws:iam::aws:policy/AWSLambda_FullAccess"

Наконец, платформа AWS готова к развёртыванию нашего приложения!

Чтобы продолжить процесс развертывания, нам нужно собрать наше приложение:

$  mvn clean install

Quarkus позаботится о создании файла артефакта JAR и подготовит SAM-шаблон для AWS. Сейчас мы хотим запускать приложение в версии для JVM, и вот как это можно сделать. Добавьте определенную роль в Lambda, чтобы иметь правильный доступ:

Role: arn:aws:iam::{Your-Account-Number-On-AWS}:role/fruits_service_role

Увеличьте тайм-аут:

Timeout: 30

Теперь шаблон SAM готов к развертыванию на AWS Lambda:

$ sam deploy -t target/sam.jvm.yaml -g

Эта команда загрузит файл jar в формате zip в AWS и развернет его как функцию Lambda. Следующим шагом будет тестирование приложения.

Производительность демо-приложения на платформе AWS Lambda + JVM

Пришло время запустить развернутую лямбда-функцию, протестировать ее и посмотреть, насколько хорошо она работает:

$ aws lambda invoke response.txt --cli-binary-format raw-in-base64-out --function-name {:fruitApp} --payload file://payload.json --log-type Tail --query LogResult --output text | base64 --decode

Мы можем выяснить FUNCTION_NAME, используя следующую команду:

$ aws lambda list-functions --query 'Functions[?starts_with(FunctionName, `fruitAppJVM`) == `true`].FunctionName'

fruitAppJVM - это имя нашей Lambda-функции, которое я задал используя SAM CLI в процессе развертывания. Чтобы увидеть результаты вызова функции, мы можем обратиться к веб-консоли AWS.

На изображении видно, насколько плоха может быть производительность для такого простого приложения из-за холодного запуска функции AWS Lambda.

Что такое холодный запуск AWS Lambda?

При запуске Lambda-функции она остается активной, пока ей активно пользуется, это значит, что контейнер с кодом функции остается запущенным и готовым принимать запросы. Однако, AWS выключает контейнер после некоторого периода бездействия (обычно очень короткого), и ваша функция станет неактивной ("холодной"). Холодный старт происходит, когда приходит запрос на простаивающую Lambda-функцию (контейнер которой был остановлен). После этого функция Lambda будет снова инициализирована, чтобы иметь возможность отвечать на запрос.

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

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

Доступные решения для проблемы холодного запуска AWS Lambda

Есть два подхода к решению этой фундаментальной проблемы:

  • Использование функциональности Provisioned Concurrency. Она не является предметом нашей статьи, подробности читайте в документации Predictable start-up times with Provisioned Concurrency.

  • Повышение скорости инициализации и времени ответа приложения, что поднимает вопрос о том, как мы можем добиться лучшей производительности в нашем приложении Java. Решение - скомпилировать двоичный исполняемый файл из нашего Java-приложения и развернуть его на AWS Lambda с Oracle GraalVM.

Что такое GraalVM?

GraalVM - это высокопроизводительный дистрибутив JDK, разработанный для ускорения выполнения приложений, написанных на Java и других языках JVM, а также с поддержкой JavaScript, Ruby, Python и ряда других популярных языков. Native-Image - это ahead-of-time компилятор в составе GraalVM, который позволяет компилирорать Java код в бинарный исполняемый файл. Этот исполняемый файл включает все классы приложения, классы из его зависимостей и связанный код из JDK. Таким образом, скомпилированное так приложение не работает на JVM, а включает в себя необходимые компоненты, такие как управление памятью или планирование потоков из другой системы, называемой "Substrate VM".

Создание бинарного исполняемого файла из Java-приложения

Во-первых, нам нужно установить GraalVM native-image, используя это руководство. Затем, установив GraalVM, мы можем преобразовать приложение Java в бинарный исполняемый файл с помощью GraalVM. Quarkus упрощает эту задачу и имеет плагин для Maven и Gradle, поэтому в типичном приложении на основе Quarkus у нас будет профиль, называемый native.

$ mvn clean install -Pnative

Maven создаст бинарный исполняемый файл на основе используемой вами ОС. Если вы разрабатываете на Windows, этот файл можно будет запускать только на компьютерах с Windows, однако для AWS Lambda требуется исполняемый бинарный файл на для Linux. Для таких случаев Quarkus предоставляет параметр -Dquarkus.native.container-build=true.

$ mvn clean install -Pnative -Dquarkus.native.container-build=true

Среды выполнения AWS Lambda

AWS Lambda поддерживает несколько различных сред для запуска функций:

Runtime

Amazon Linux

Amazon Linux 2

Node.js

nodejs12.x

nodejs10.x

Python

python3.7 и 3.6

python3.8

Ruby

ruby2.5

ruby2.7

Java

java

java11 , java8.al2

Go

go1.x

provided.al2

.NET

dotnetcore2.1

dotnetcore3.1

Custom

provided

provided.al2

Так мы ранее развернули Java-приложение на Lambda с помощью Java 11 (Corretto 11), и оно не показало хорошей производительности. Теперь же мы хотим попробовать два последних в таблице варианта чистой платформы Linux для Lambda-функций - provided и provided.al2. Стоит отметить, что provided будет использовать Amazon Linux, а provided.al2 будет использовать Amazon Linux 2, поэтому из-за долгосрочной поддержки версии 2 настоятельно рекомендуется использовать версию 2.

Развертывание бинарного исполняемого файла на AWS Lambda

Как мы видели, Quarkus создаст для нас два SAM-шаблона - один предназначен для Lambda-функции на JVM, а второй - для бинарного исполняемого файла. На этот раз мы должны использовать второй SAM-шаблон, это потребует небольших изменений в нём.

Перейти на AWS Linux V2:

Runtime: provided.al2

Добавить определенную роль в Lambda, чтобы иметь надлежащий доступ:

Role: arn:aws:iam::{Your-Account-Number-On-AWS}:role/fruits_service_role

Увеличить тайм-аут:

Timeout: 30

Окончательная версия SAM-шаблона будет иметь вид final.sam.native.yaml, теперь всё готово к развертыванию на AWS:

$ sam deploy -t target/sam.native.yaml -g

Эта команда загрузит двоичный файл в формате zip в AWS и развернет его как функцию Lambda, точно так же, как версию JVM. Теперь мы можем перейти к самой интересной части - мониторингу производительности.

Производительность демо-приложения на основе AWS Lambda + Custom runtime

Пришло время запустить развернутую лямбда-функцию, протестировать ее и посмотреть, насколько хорошо она работает:

$ aws lambda invoke response.txt --cli-binary-format raw-in-base64-out --function-name {:fruitApp} --payload file://payload.json --log-type Tail --query LogResult --output text | base64 --decode

Чтобы узнать FUNCTION_NAME, мы можем воспользоваться следующей командой:

$ aws lambda list-functions --query 'Functions[?starts_with(FunctionName, `fruitAppNative`) == `true`].FunctionName'

fruitAppNative - это имя Lambda-функции, которое я задал, используя SAM CLI в процессе развертывания.

Теперь мы можем открыть веб-консоль AWS, чтобы увидеть результаты вызова функции.

Ого, это впечатляющий результат!

Сравниваем производительность Lambda-функций на JVM и Native Binary

Мы можем проанализировать и сравнить обе версии приложения на платформе AWS Lambda в двух категориях.

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

Между JVM и бинарной версией видна значительная разница - время инициализации нативной бинарной версии почти в восемь раз меньше, чем у версии JVM.

Время выполнения запроса - я вызвал лямбда-функцию девять раз после инициализированного шага, и вот результат производительности:

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

Заключение

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

Версия бинарного исполняемого файла имеет значительно лучшую производительность по сравнению с версией JVM:

  • версия бинарного файла использует всего 128МБ оперативной памяти, тогда как версия JVM использует 512МБ, что приводит к экономии значительного количества ресурсов на AWS Lambda;

  • версия бинарного файла показывает лучшее время выполнения запросов чем версия JVM, что означает большую экономию времени на AWS Lambda.

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

Ссылки

От переводчика: если вам интересна тема Quarkus, я приглашаю вас присоединиться к моему телеграм-каналу о Quarkus - https://t.me/quarkusnews.