Java уже много лет является одним из самых популярных языков программирования. Многие корпоративные компании по-прежнему интенсивно используют Java. Но когда дело доходит до AWS Lambda (или бессерверных приложения в целом), у него возникают некоторые проблемы, в первую очередь с временем выполнения программы.
В данной статье мы рассмотрим Lambda проект на языке Java, а также изучим возможности оптимизации 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
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 из набора разработки 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 |
Одним из способов оптимизации является встроенная в 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 |
Плюсы
Минусы
По этой информации можно судить, что Provisioned Concurrency выгоден для ситуаций, когда необходимо иметь предсказуемое время выполнения программы, или в случаях, когда предполагается, что функция будет вызываться сравнительно часто (например, если использовать “лямбду” как backend часть веб-приложения). С другой стороны, Provisioned Concurrency будет невыгоден в случае, когда приложение будет вызываться сравнительно редко (например, только при восстановлении данных какой-либо системы). Отсутствие Lambda free tier также играет немаловажную роль в оценке количества затрат.
Все эти размышления являются общими и могут различаться в конкретных случаях. Для определения оптимального варианта рекомендуется воспользоваться Pricing Calculator для AWS Lambda (https://calculator.aws/#/createCalculator/Lambda). Перед его использованием необходимо иметь представление об ожидаемом количестве вызовов функции и среднем времени её выполнения.
Как уже говорилось, во время создания и инициализации новой среды выполнения функции, производится дополнительная работа по подготовке среды к обработке события.
Чтобы улучшить время отклика, имеет смысл свести к минимуму эффект от этой дополнительной работы. Одним из способов минимизировать время, затрачиваемое на создание новой управляемой среды выполнения 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 |
Плюсы:
Минусы:
Для приложений, многократно повторяющие одни и те же блоки кода, разумно использовать настройки JVM с профилированием. Однако для некоторых легковесных функций (например, функций для запуска других сервисов AWS) данная конфигурация привнесёт ощутимый “буст в перфомансе”. Как и в первом примере, следует рассматривать конкретную ситуацию для использования или неиспользования этого решения.
Одним из способов оптимизации будет использование 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 |
Плюсы:
Минусы:
Так как 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.
Исходя из результатов данной статьи, можно составить краткую памятку по оптимизациям и наиболее подходящим вариантам функций:
Ни одно из этих решений не является универсальным, однако каждое из них может разительно ускорить приложение в подходящем кейсе использования AWS Lambda.
Надеюсь, это статья была вам полезна, и вы воспользуетесь одним из описанных в статье решений в своих Lambda функциях.
Всем спасибо за внимание!
Автор статьи− Никита Гузов