Способы оптимизации AWS Lambda на языке Java

  • 2 месяца назад
  • читать 10 мин.

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

В данной статье мы рассмотрим Lambda проект на языке Java, а также изучим возможности оптимизации Lambda приложения на этом языке. Приятного чтения!

 

Немного про AWS Lambda

AWS Lambda – сервис компании Amazon, который позволяет запускать программный код без выделения серверов и управления ими. Оплата производится только за фактическое время выполнения кода. Одним из главных плюсов Lambda (как и любого другого облачного сервиса) является то, что сервис позволяет выполнять код без администрирования – все ресурсы, необходимые для исполнения, масштабирования и обеспечения высокой доступности приложения уже предоставлены пользователю. Имеется возможность настроить автоматический запуск программного кода из других сервисов AWS или вызывать его непосредственно из любого мобильного или интернет‑приложения.

На текущий момент AWS Lambda поддерживает Java, Go, PowerShell, Node.js, C#, Python, и Ruby code, а также имеется возможность настроить своё кастомное окружение для выполнения любых других не представленных языков.

 

Введение в проблему

Java – это проверенный и очень распространённый язык программирования для серверных приложений. В нём одним из первых была использована концепция виртуальной машины (JVM) и внедрен механизм автоматической сборки мусора (Garbage Collection), что значительно облегчило жизнь программистам. Именно гибкость и надежность работы виртуальной машины, хорошая совместимость с прошлыми версиями языка, компиляция без привязки к платформе, а также встроенные возможности оптимизации кода сделали Java одним из лидеров индустрии языков программирования для серверов.

Java использует Just In Time (JIT) компилятор, который, как следует из его названия, сначала компилирует минимум того, что необходимо для запуска приложения, а затем компилирует дополнительные классы по мере работы программы. Это дает ему возможность эффективно использовать классы, загружая только необходимые. JIT также позволяет оптимизировать часто вызываемые блоки кода в процессе работы приложения (так называемое профилирование).

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

JavaScript, например, не имеет компилятора. Это интерпретируемый язык, поэтому он сразу же загружается, а затем проходит через интерпретатор, который выполняет код.

Рассмотрим эту особенность в контексте использования Java как языка для AWS Lambda:

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

Время “холодного старта” не учитывается при оценке стоимости выполнения “лямбды”, однако время, затраченное на загрузку и подготовку окружения, увеличивает задержку к общей продолжительности вызова функции. На сайте AWS указано, что сервис имеет возможность сохранить подготовленное окружение после запуска программы, и  использовать его повторно если функция будет вызвана в дальнейшем (“горячий старт”). Требуется учесть, что это время никак четко не определено, и AWS имеет возможность в любой момент выгрузить приложение из сервера (https://aws.amazon.com/blogs/compute/operating-lambda-performance-optimization-part-1/). Исходя из особенности Java выполнять код медленно при первом запуске, оптимизация “холодного старта” является важным фактором при проектировании Lambda функции на этом языке.

Давайте же рассмотрим возможности оптимизации выполнения Java кода в сервисе AWS Lambda

 

Общие рекомендации

Избегайте использования Reflection API

Java Reflection API позволяет коду Java обнаруживать и использовать информацию о других классах, интерфейсах, полях и методах. Поскольку рефлексия оперирует типами, которые разрешаются динамически, некоторые оптимизации JVM не могут быть выполнены. Следовательно, операции, использующие рефлексию, имеют более низкую производительность, чем их аналоги.

Используйте фреймворки с внедрением зависимостей во время компиляции, а не во время выполнения

Если необходимо использовать фреймворк с внедрением зависимостей, то на этот случай рекомендуется рассмотреть некоторые хорошие альтернативы Spring — Dagger и Micronaut. Ключевым преимуществом этих фреймворков является то, что они проводят внедрение зависимостей во время компиляции, а не во время выполнения, что ускорит время выполнения функции.

Следует упомянуть, что у создателей Spring есть собственное serverless решение Spring Cloud (https://spring.io/projects/spring-cloud-function), однако в рамках этой статьи оно не будет рассмотрено.

Минимизируйте количество сторонних библиотек и незначительного кода

Минимизация количества сторонних библиотек и незначительного кода уменьшит размер пакета вашей функции. Это сократит время, необходимое для загрузки и распаковки приложения перед вызовом. Например, для функций, созданных на Java, имеет смысл не загружать всю библиотеку AWS SDK. Вместо этого можно выборочно указать модули, необходимые для работы вашей программы (например, модули DynamoDB, Amazon S3 SDK и т.д.).

 

Практические методы

Способ оценки эффективности методов оптимизации

Для оценки эффективности описанных далее методов была написана небольшая функция, работающая по следующему алгоритму:

– инициализация клиента для взаимодействия с базой данных DynamoDB;

– запрос на выборку данных по заданному ключу (указанному в запросе при обращении к функции);

– получение данных и представление этих данных в виде коллекции POJO объектов.

За подключение к базе данных отвечает модуль DynamoDB из набора разработки AWS для Java второй версии (https://github.com/aws/aws-sdk-java-v2). Исходный код функции доступен по ссылкам https://github.com/rilmay/film-catalogue, https://github.com/rilmay/native-film-catalogue. На функцию выделено 512 Мб оперативной памяти.

Описанные далее в статье временные показатели представлены в виде среднего арифметического времени выполнения минимум по 10 измерениям. Также в таблицах использованы английские термины.

Исходные показатели:

Cold start, ms

Warm start, ms

10464

768

 

  1. Provisioned concurrency

Одним из способов оптимизации является встроенная в AWS опция под названием Provisioned Concurrency.

Provisioned Concurrency — это рекомендованное AWS’ом решение, обеспечивающее минимально возможную задержку и предсказуемое время запуска функции. Provisioned Concurrency сохраняет ваши функции инициализированными и готовыми. При использовании этой опции нужно указать необходимое количество подготовленных экземпляров программы. Например, функция с параметром Provisioned Concurrency, равным 6, имеет 6 сред выполнения, подготовленных до того, как произойдут вызовы.

Отдельные измерения для Provisioned Concurrency не проводились, так как они будут совпадать с временем “горячего старта” исходной функции.

 

Cold start, ms

Warm start, ms

Baseline

10464

768

Provisioned Concurrency

768

768

 

Плюсы

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

Минусы

— Изменение в способе расчёта затрат на использование AWS Lambda. При использовании Provisioned Concurrency необходимо платить за время, когда эта опция была включена, а также необходимо платить за количество запросов и продолжительность выполнения функции (тариф расчёта затрат за время выполнения отличается от обычной Lambda в меньшую сторону). Если объем вызовов функции превышает объем, который может обработать указанное количество подготовленных окружений, избыточное выполнение функции рассчитывается по тарифу обычной AWS Lambda (https://aws.amazon.com/lambda/pricing/). Также на функции с включенной Provisioned Concurrency не распространяется пакет Lambda free tier (Lambda free tier – бесплатный пакет AWS, включающая в себя 1 миллион запросов + 400,000 Гб/секунд на выполнение Lambda функций в месяц).

По этой информации можно судить, что Provisioned Concurrency выгоден для ситуаций, когда необходимо иметь предсказуемое время выполнения программы, или в случаях, когда предполагается, что функция будет вызываться сравнительно часто (например, если использовать “лямбду” как backend часть веб-приложения). С другой стороны, Provisioned Concurrency будет невыгоден в случае, когда приложение будет вызываться сравнительно редко (например, только при восстановлении данных какой-либо системы). Отсутствие Lambda free tier также играет немаловажную роль в оценке количества затрат.

Все эти размышления являются общими и могут различаться в конкретных случаях. Для определения оптимального варианта рекомендуется воспользоваться Pricing Calculator для AWS Lambda (https://calculator.aws/#/createCalculator/Lambda). Перед его использованием необходимо иметь представление об ожидаемом количестве вызовов функции и среднем времени её выполнения.

 

  1. Изменение настроек компилятора

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

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

Оптимизация виртуальной машины достигается путём настройки функции JVM, называемой многоуровневой компиляцией (tiered compilation). Начиная с версии 8 Java Development Kit (JDK) два интерактивных компилятора C1 и C2 используются в комбинации. C1 предназначен для использования на стороне клиента и для обеспечения коротких циклов обратной связи для разработчиков. C2 предназначен для использования на стороне сервера и для достижения более высокой производительности после профилирования (https://slideplayer.com/slide/12376325/).

Для более гибкого выбора способа компиляции были введены уровни сборки. Они представлены в виде пяти уровней:

Для большинства случаев используются уровень 0 (интерпретация), уровень 3 (C1 + полное профилирование) и уровень 4 (C2).

Профилирование сопряжено с дополнительными временными и ресурсными затратами, и повышение производительности достигается только после того, как метод был вызван большое количество раз, по умолчанию это 10 000 раз.

Для достижения более быстрого запуска, в данном примере будет использован уровень 1 с небольшим риском снижения производительности “горячего старта”, так как оптимизация во время выполнения программы не будет производиться. Большинство компиляций происходит во время запуска (https://jpbempel.github.io/2020/05/22/startup-containers-tieredcompilation.html), поэтому использование более быстрого компилятора в теории должно привести к уменьшению времени выполнения рассматриваемой функции.

Есть несколько способов настройки компилятора в среде выполнения Lambda. В данном случае будет сконфигурирована переменная окружения JAVA_TOOL_OPTIONS, которая предоставляет возможность указывать дополнительные аргументы при запуске Java приложения (https://docs.aws.amazon.com/lambda/latest/dg/runtimes-modify.html). В эту переменную будет установлено значение “-XX:+TieredCompilation -XX:TieredStopAtLevel=1”. Благодаря этому будет применен первый уровень компиляции.

Рассмотрим результаты измерения времени выполнения функции:

 

Cold start, ms

Warm start, ms

Baseline

10464

768

Level 1 compiler

5586

171

 

 

 

Плюсы:

  • Улучшение показателей времени выполнения исходной функции при “холодном старте” в 1.87 раза
  • Улучшение показателей времени выполнения исходной функции при “горячем старте” в 4.5 раза.

Минусы:

  • Отказ от встроенных способов оптимизации приложения во время работы. Можно утверждать, что для функций, выполняющих многократную обработку (допустим, графический рендер или физические симуляции), эта конфигурация не подойдёт.

Для приложений, многократно повторяющие одни и те же блоки кода, разумно использовать настройки JVM с профилированием. Однако для некоторых легковесных функций (например, функций для запуска других сервисов AWS) данная конфигурация привнесёт ощутимый “буст в перфомансе”. Как и в первом примере, следует рассматривать конкретную ситуацию для использования или неиспользования этого решения.

 

  1. Компиляция нативного образа

Одним из способов оптимизации будет использование GraalVM для компиляции нативного образа приложения.

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

По сути, GraalVM объединяет всё то, что требуется для запуска программы, в свой собственный двоичный файл, а не в байт-код, тем самым устраняя среду выполнения Java. Он делает это путем компиляции приложения до его использования (AOT), поэтому во время выполнения не требуется дальнейшей компиляции.

Для упрощения работы с GraalVM, была написана идентичная по функционалу программа с использованием фреймворка Micronaut (ссылка на программу https://github.com/rilmay/native-film-catalogue). Использование Micronaut обусловлено тем, что он имеет хорошую совместимость с GraalVM, а также производит внедрение зависимостей до выполнения программы, что значительно приблизит измеренные результаты к ситуации без применения фреймворка.

Рассмотрим результаты измерения времени выполнения функции:

 

Cold start, ms

Warm start, ms

Baseline

10464

768

Native image

1039

72

 

Плюсы:

  • улучшение показателей времени выполнения исходной функции при “холодном старте” в 10 раз;
  • улучшение показателей времени выполнения исходной функции при “горячем старте” в 10.7 раз.

Минусы:

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

— увеличению времени компиляции (в рассматриваемом примере увеличение в среднем с 14 секунд до 2 минут 55 секунд, компиляция производилась на одной машине);

— увеличению размера скомпилированного пакета (в рассматриваемом примере увеличение с 14.2 Мб до 24.2 Мб).

— На текущий момент не все Java библиотеки могут быть скомпилированы в нативный образ.

— GraalVM CE для сборки мусора поддерживает только Serial GC.

— GraalVM имеет проблемы с динамически разрешаемыми ресурсами. В первую очередь страдают проекты и библиотеки, где много используется рефлексия. Так как GraalVM компилирует один нативный файл со всеми зависимостями и необходимыми инструментами, то по умолчанию GraalVM не переносит метаданные о классах в получившийся образ. Из-за этого, например, можно получить сообщения от библиотеки Jackson о том, что десериализация JSON файла невозможна, так как у выбранного Java объекта отсутствуют какие-либо конструкторы. Для того чтобы указать GraalVM классы, метаданные о которых следует перенести, требуется описать информацию о них в файле reflect.json в корне проекта (при использовании Micronaut название файла и его путь немного различаются). Необходимо повторить это для каждого класса, к которому будет совершен доступ при помощи рефлексии, независимо от того, содержится ли этот класс в проекте, либо используется в подключенных сторонних библиотеках.

 

Итоговая таблица и графики

 

Cold start, ms

Warm start, ms

Baseline

10464

768

Provisioned Concurrency

768

768

Level 1 compiler

5586

171

Native image

1039

72

 

 

Заключение

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

Исходя из результатов данной статьи, можно составить краткую памятку по оптимизациям и наиболее подходящим вариантам функций:

  • большая и активно используемая функция без учета затрат – Provisioned Concurrency;
  • легковесная функция – конфигурация JIT компилятора;
  • функция любого размера и сложности без учета дополнительных трудностей на конфигурирование и компиляцию – использование нативного образа.

Ни одно из этих решений не является универсальным, однако каждое из них может разительно ускорить приложение в подходящем кейсе использования AWS Lambda.

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

Всем спасибо за внимание!

Автор статьи Никита Гузов

title

content