Управление доступными ресурсами в облачной среде по запросу – тема, которая бывает очень непростой. Но эта работа стоит того, поскольку вы сможете использовать ресурсы гораздо эффективнее. Поэтому многие компании и проекты решаются мигрировать на облачные платформы, в частности, на Azul, AWS, Google Cloud или другие. С точки зрения программиста, есть одна истина, которая сохраняется и в облаке: рекомендуется понимать поведение и ограничения того JVM-приложения, что вы развернули (или не приложений, а подов, минимальных развертываемых единиц в Kubernetes). Платформа Java является многопоточной, и, даже если вы не собираетесь использовать какие-либо доступные для этого API, платформа все равно порождает множество потоков-демонов, работающих в фоновом режиме. Такие потоки нужны не только для очистки и подхватывания неиспользуемой памяти. Они относятся к платформе, а что насчет фреймворков? Фреймворки Java пытаются обслуживать большие эксплуатационные мощности; следовательно, инициируется работа множества вспомогательных потоков. Ниже мы немного заглянем под капот. В этой статье будет подробнее рассмотрено, как устроены популярные фреймворки Quarkus и Spring-Boot, сколько потоков они инициируют, чтобы обслужить все результаты. Давайте вместе пробежимся по примерам и для начала разберемся, какова разница между мониторингом и профилированием.

Построение облачного приложения

Для этой статьи была написана пара приложений под JVM. Сначала назовем всех игроков поименно и объясним, почему они здесь присутствуют. Используемый технологический стек и взаимодействие компонентов очень просты и прозрачны (рисунок 1.)

Рисунок 1. Упрощенная схема взаимодействий подов в ходе каждого из наших тестов.
Рисунок 1. Упрощенная схема взаимодействий подов в ходе каждого из наших тестов.

Все приложения работают в кластере Kubernetes, но узел Gatling остается вне кластера. Узел Gatling имитирует взаимодействие пользователя с развернутыми подами (всеми отдельно взятыми экземплярами).

Поскольку в настоящее время Quarkus и Spring-Boot являются, пожалуй, наиболее популярными фреймворками для создания новых проектов, поговорим именно об этих двух «лидерах». Я написал приложение как на Java, так и на Kotlin, так что и их можем сравнить. Также важно: API, реализованные в обоих вышеупомянутых приложениях — схожие. Они предоставляют конечные точки GET и POST, через которые можно добавлять и извлекать SimpleElement из кэша, находящегося в памяти (рисунок 2). Во внутреннем кэше целенаправленно оставлена неочевидная проблема, и конфигурация кэшей в обоих приложениях также схожа.

Рисунок 2. Как устроены конечные точки для каждого из приложений.
Рисунок 2. Как устроены конечные точки для каждого из приложений.

В каждом из развернутых приложений реализуются схожие сценарии загрузки (рисунок 2.)

Приложения развертываются в кластер Kubernetes таким образом, чтобы они оставались изолированы друг от друга. Также в кластере присутствуют узлы  Grafana и Prometheus, собирающие точки данных из подов с приложениями во время симуляционных тестов (листинг 1.).

$ kubernetes.sh --deploy
$ kubectl get pod 

output:
NAME                                 READY   STATUS    RESTARTS   AGE
grafana-8657696bb4-95rrd             1/1     Running   0          23s
prometheus-7df944b498-v7c8k          1/1     Running   0          23s
quarkus-java-84fff97d65-2knft        1/1     Running   0          23s
quarkus-kotlin-57678d4449-gnrs4      1/1     Running   0          23s
spring-boot-7b8d5df489-f9jbc         1/1     Running   0          23s
spring-boot-kotlin-cc8688d9d-rlt4d   1/1     Running   0          23s

Листинг 1. Скрипт для развертывания инфраструктуры и мониторинга подов

Поработаем с нагрузкой

Рисунок 3. Устройство конечных точек для каждого приложения.
Рисунок 3. Устройство конечных точек для каждого приложения.

Для целей этой статьи был создан тестовый кластер Kubernetes, сделанный при помощи версии Docker для ПК. Там приложения развертываются и выполняются, и это хорошо. Но, чтобы какие-то данные попали в Gatling, хорошо бы наладить с ним какое-то взаимодействие. Почему Gatling? В таком случае у нас появляется пара вариантов, как генерировать нагрузку. В нашем сценарии создается 2000 активных пользователей за 3 минуты. В ходе симуляции будут создаваться новые экземпляры, затем будет проверяться их наличие внутри системы. Нагрузка весьма немалая, если учесть, сколько у нас активных пользователей. Из вариантов, позволяющих сгенерировать такую нагрузку, можно упомянуть JMeter, но в каждом сценарии есть два этапа, на которых должны совместно использоваться значения (рисунок 2.). В таких случаях JMeter – не лучший вариант, так как у него есть свои ограничения. К счастью, у нас есть Gatling, мы можем обкатать тестовые сценарии, проверяющие работу с данными под нагрузкой, а также получаем из коробки достаточно аккуратный и настраиваемый движок для выдачи отчетов (рисунок 3.). Наши сценарии для Gatling написаны на Scala, поэтому мы без особых усилий можем реализовать все, что нужно нам для этой конфигурации – после чего сможем вполне быстро их выполнить (листинг 3.)

private val scenarioSequence: ArrayBuffer[ChainBuilder] = ArrayBuffer[ChainBuilder](
 postSimpleElement(elementsProvider, sessionSimpleElementPost),
 checkSimpleElement(elementsProvider, sessionSimpleElementNumber)
)
private val completeScenario = scenario("post simple elements").exec(scenarioSequence)
setUp(completeScenario.inject(rampUsers(2000) during (3 minute))).protocols(httpSimpleElementApi)

Листинг 2. Выполнение симуляции в Gatling

После выполнения каждого теста генерируется красивый отчет. Данный отчет можно кастомизировать, и эта возможность Gatling очень симпатична.

$ mvn gatling:test

Листинг 3. Запуск сценариев загрузки в gatling

Впоследствии генерируются данные, которые доставляются к конечным точкам. Далее сможем убедиться, что все работает гладко и без каких-либо проблем. На каждой конечной точке дается ожидаемый отклик. Получим код состояния HTTP 200 в случае, если элемент успешно сохранен и найден внутри системы. Ожидаем код состояния 400 («плохой запрос»), если объект в системе отсутствует (Рисунок 4). Таким образом, сценарии учитывают и негативные случаи.

Рисунок 4. Когда элемент недоступен, получаем код состояния 400 (Bad request)
Рисунок 4. Когда элемент недоступен, получаем код состояния 400 (Bad request)

Пока все идет по плану. Но достаточно ли этого, чтобы утверждать, что у приложения нормальная пропускная способность, и все хорошо? Разумеется, нет, даже если наши тесты пройдут успешно, а приложение может справиться с некоторой нагрузкой – мы все равно знаем не все.

Мониторинг и профилирование

Поскольку мы собираемся предоставлять в кластере образцы приложений, было бы хорошо, чтобы работа конечных точек не прекращалась, и их не приходилось постоянно перезапускать. Важный аспект – обеспечение их высокой доступности. Мы знаем, что работа этих приложений предполагается без сохранения состояния, но есть некоторое обременение и проблемы, присущие подходу “всегда перезапускаем”, как, например, непрерывное инициирование ресурсов, управление множественными репликами, платежами, т.д. Всегда лучше понимать этот компромисс, конфигурировать и задействовать ресурсы наиболее разумно.  

Сейчас давайте ответим на следующий важный вопрос: чем нужно заняться по готовности приложения – мониторингом или профилированием? Что ж, давайте кратко разберем эти различия.

Мониторинг

Мониторинг предполагает, что мы будем присматривать за работающим приложением на основе выборки данных (CPU, использование памяти, т.д.) (рисунок 5).

Рисунок 5. Мониторинг кучи в Spring-Boot kotlin grafana демонстрирует точку данных, выбранную с заданной детализацией. Судя по данным, происходит что-то заметное, но насколько это серьезно? (рисунок 7.)
Рисунок 5. Мониторинг кучи в Spring-Boot kotlin grafana демонстрирует точку данных, выбранную с заданной детализацией. Судя по данным, происходит что-то заметное, но насколько это серьезно? (рисунок 7.)

Таким образом, на практике данные в режиме реального времени не интерпретируются. Данные для мониторинга собираются путем выборке, поэтому, по сути процесса, некоторые существенные значения могут в них отсутствовать  (рисунки 5 и 7). Еще менее детализированные данные вообще могут выглядеть совсем иначе, чем хорошо детализированные. Тем не менее, предложенный процесс выборки данных достаточно хорош, чтобы организовать на его основе тревожные оповещения, поскольку здесь предусмотрено пороговое значение. Наш механизм позволяет что-либо предпринять, пока еще не слишком поздно (на рисунке 6 показано, как растет время, затрачиваемое на отклики и на выделение памяти). В процессе мониторинга усваиваем: если понимать ограничения приложений, то удобно определять информативные пороги, переходить которые нельзя.

Рисунок 6. Наблюдаем Quarkus для Java, время отклика увеличивается
Рисунок 6. Наблюдаем Quarkus для Java, время отклика увеличивается

Профилирование приложения

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

Рисунок 7. При помощи Java Flight Recorder зафиксированы сложности с работой сборщика мусора в каждом из подов.
Рисунок 7. При помощи Java Flight Recorder зафиксированы сложности с работой сборщика мусора в каждом из подов.

При помощи профилирования можно внимательно рассмотреть, как обстоит дело с возвращением памяти в работу, то есть, со сборкой мусора  (рисунок 7). Пожалуй, наилучший инструмент для этого - Java Flight Recorder(JFR), сделанный в составе проекта Java Mission Control (JMC). Применяя JRE, можно поднять его внутреннюю аналитику, наблюдая за выдачей событий со стороны JFR. Давайте им пользоваться, ведь он есть в открытом доступе со времен Java SE 11, а все примеры работают на OpenJDK 17.

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

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

Рисунок 8. По записям JFR видно, что каждый из рассматриваемых фреймворков по умолчанию инициирует множество вспомогательных потоков.
Рисунок 8. По записям JFR видно, что каждый из рассматриваемых фреймворков по умолчанию инициирует множество вспомогательных потоков.

Анализируя поведение потоков, можно выявить те или иные подозрительные действия, происходящие за кулисами и невидимые при мониторинге, либо при тестировании приложения вручную, либо при нагрузочном тестировании. В целом может оказаться довольно непросто отыскать в коде импортированной библиотеки конкретную проблему, тем более, что работа идет в кэше, расположенном прямо в памяти (Рисунок 9).

Рисунок 9. Подсвечен активный метод, из-за которого возникли проблемы. Мы смогли его обнаружить, проанализировав записи Java Flight, сделанные  на основе целенаправленно выбранных потоков.
Рисунок 9. Подсвечен активный метод, из-за которого возникли проблемы. Мы смогли его обнаружить, проанализировав записи Java Flight, сделанные на основе целенаправленно выбранных потоков.

Заключение

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

Также мы показали, что некоторые фреймворки, сконфигурированные по умолчанию, инициируют достаточно много потоков, особенно это касается  Quarkus. Spring-Boot в этом отношении более консервативен. Это может серьезно повлиять на пропускную способность, коль скоро запросы не работают ни с какими операциями ввода/вывода.

Также в статье проиллюстрировано, что ни под Java, ни под Kotlin эти популярные фреймворки (Quarkus и Spring-boot) не защищены от неправильного управления памятью. Такие проблемы легко могут быть спровоцированы из-за работы с внешней библиотекой. Показано, как выявить их при помощи профилирования.

Использованные технологии

  • Quarkus, версия: 2.8.2.Final, для Java и Kotlin

  • Spring-boot, версия: 2.6.4 для Java, 2.6.7 для Kotlin

  • Gatling, версия: 3.7.6

  • Базовый образ Docker: eclipse-temurin:17-centos7

Ссылки

  1. Gatling

  2. Quarkus

  3. Spring-Boo

  4. OpenJDK - Java Mission Control

  5. Kubernetes

Комментарии (0)