Сегодня я продолжу рассуждать о том, как мы пишем микросервисы. В прошлый раз упор был на теорию: нужно было вспомнить, как код писался раньше, понять сущность архитектуры и связи между компонентами. 

Эта половина статьи сосредоточится на опыте нашей команды BellSoft. Поговорим о том, каким образом мы взаимодействуем с миром микросервисов: здесь будет и про универсальный Java-рантайм, и про крошечные контейнеры, и про Spring. Я разложу микросервис на слои, соберу в образ, запущу и покажу, что влияет на его скорость.

Помним о результате

Мы легко используем крошечные кусочки кода, чтобы связывать удалённые системы, такие как разные микросервисы, хранилища данных или очереди сообщений. И чтобы поддерживать связь с клиентами по общим протоколам.

@RestController
public class HelloController {

   @Autowired
   private WebClient webClient;

   @RequestMapping(path = "/", method = RequestMethod.GET)
   public CompletableFuture<String> greet(Principal principal) {
       return webClient.get()
        .uri("http://api/persons/{id}", principal.getName())
        .accept(MediaType.APPLICATION_JSON)
        .exchange()
        .flatMap(response -> response.bodyToMono(Person.class))
        .map(person -> "Hello, " + person.getFirstName())
        .toFuture();
   }

}

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

Роль веб-сервера

Фреймворки сосредотачиваются на подключении настраиваемых компонентов верхнего уровня. А на нижнем — мир протоколов передачи данных, таких как HTTP; так что между сетевыми подключениями и бизнес-логикой необходим посредник. Кроме того, есть потребность в управлении состояниями (контекстами), ресурсами, безопасностью. За всё это отвечают веб-серверы. Раньше один веб-сервер контролировал сразу несколько приложений, все они требовали независимой конфигурации. Но, например, в контейнере каждый микросервис может быть связан со своим собственным экземпляром сервера, который централизованно настроен через фреймворк, как это происходит с Undertow в Spring или с Embedded Tomcat.

Новая парадигма Serverless, несмотря на название, фактически не исключает веб-сервер. Просто избавляет команду от его конфигурации и запуска. Сервер предоставляется как услуга совместно с выполнением «функций» бизнес-логики и их зависимостей (FaaS).

JVM как фундамент

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

Динамические прокси

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

В Java мы можем создать наблюдателя (facade), который перехватывает вызовы методов интерфейса с помощью стандартного API java.lang.reflect.Proxy и java.lang.reflect.InvocationHandler. Это реализация шаблона Dynamic Proxy, и она широко используется в CDI-контейнерах.

Гибкость

JIT-компиляторы и сборщики мусора гибко настраиваются в зависимости от нужд приложения. Всегда есть требования к пропускной способности и времени отклика, а также ограничения по ресурсам. Их потребление дорого стоит: трафик при деплое, загрузка памяти и процессора хоста, использование диска — всё это не бесплатно.

JRE может обновляться отдельно от бизнес-логики. А значит, патчи безопасности, фичи и оптимизированные дефолты появляются сразу в проде. При этом основной код не требует ни изменений, ни пересборки; только скомпилированные артефакты требуют тестирования на обновленной JVM. Новые функции не только влияют на поведение JVM, но и порой сразу подхватываются фреймворками и веб-серверами (конфигурация при этом может измениться), чтобы обеспечить более эффективное и безопасное исполнение приложений. Пара примеров: 

  • Сборщики мусора стали возвращать память в ОС. Это направление эволюционирует буквально на глазах — на отрезке JDK 11–16 в ряде конфигураций показатели, связанные с latency, улучшились на 40%, причём в нескольких GC! 

  • Строки требуют меньше памяти.

  • Поддерживается TLS 1.3.

  • Появляется и развивается JFR, и его можно использовать для диагностики микросервисов в контейнерах.

Скоро record’ы и легковесные потоки Project Loom в корне изменят работу классов данных и сетевых соединений. Думаю, все уже не раз перечитали по второй теме обе части State of Loom Рона Пресслера.

Я понимаю, что, во-первых, асинхронное или функциональное программирование зачастую сложно. А во-вторых, оно не позволяет полностью избежать нежелательных эффектов синхронных и блокирующих операций, таких как работа с базами данных (даже через асинхронные драйверы!) и прочим вводом-выводом (даже через NIO). Конкретно для случая веб-серверов Рон и упоминает, что параллельная обработка означает обработку в потоках. При этом число параллельных потоков в железе (и, соответственно, эффективных полновесных потоков ОС) сейчас составляет от единиц до сотен, в среднем десятки. В то время как параллельные потоки Loom, на которые можно будет пересадить обработку соединений, исчисляются сотнями тысяч.

Фреймворки

Как сказано выше, фреймворки берут на себя решение рутинных задач, которые зачастую составляют основную массу в типичных проектах. Фреймворков в индустрии достаточно. От мастодонтов до нишевых, под уникальные запросы.

Интересно, что разные фреймворки существуют не сами по себе, а связаны друг с другом и с другими технологиями. Например, Eclipse MicroProfile связан с Java EE, но ориентирован на микросервисы. Вообще, нередко фреймворки поддерживают несколько разных API. И в Spring, и в Quarkus частично реализована спецификация CDI, хотя у них есть собственные инструменты с аналогичным функционалом. Еще один пример — введение аннотаций Spring в Quarkus, что помогает переносить код с минимальными изменениями.

Сложим всё вместе

Итак, мы собрали части микросервиса: JVM, веб-сервер, фреймворки, библиотеки. Также определены целевая ОС, системные пакеты и оборудование. Это единое целое может быть упаковано в контейнерные образы, и гибко деплоиться в разных окружениях с помощью какого-нибудь Docker или Podman.

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

Внимательный читатель возмутится: «как это OS Packages поверх JRE?» Но здесь OS Packages — дополнительные пакеты, которые нужны для работы приложения, а не самой JRE. При настройке приходится учитывать, что все слои должны взаимодействовать друг с другом, корректно работать в операционке на удалённом хосте и сообщаться с внешними системами. Разработчики и DevOps занимаются конфигурациями в каком-нибудь YAML, где в итоге можно увидеть что-то такое:

server:
  port : 8081
eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/

Наконец, есть системы оркестрации, например Kubernetes и Marathon, которые позволяют распределять контейнеры в кластере машин. На этом этапе мы уже имеем дело с полностью сконфигурированными контейнерами, которые развёрнуты в парке серверов и, возможно, связаны с SaaS-компонентами в облаке.

Битва за ресурсы

Микросервис настроен и запущен. Возникает один важный вопрос: как минимизировать потребление ресурсов?

Есть несколько показателей, которые можно попробовать улучшить:

  1. Размер Docker-образа,

  2. Объем памяти,

  3. Время запуска.

Выбор рантайма — отдельная захватывающая тема. Тут моё мнение предвзято. BellSoft выпускает базовые образы Liberica JDK размером 107 МБ. (Бонусом для CLI-приложений и им подобных идёт вариант 41,5 МБ с Alpine Linux musl — самый маленький контейнер с Java.)

Кстати, если вам просто нужен максимально стандартный образ, то Liberica JDK включается по умолчанию в контейнерную сборку Spring Boot. Чтобы её построить, достаточно просто выполнить простую команду вроде

gradle bootBuildImage

Запуск даже небольшого приложения требует относительно много памяти. Мы провели эксперимент с примерами из доклада «Не клади все яйца в один контейнер» и оптимизацией, запустив проект в разных конфигурациях. Напомню, что это простой Spring Boot сервис с Actuator. С thin jar и AppCDS скорость запуска выросла почти в 2,5 раза! Более подробные результаты есть в таблице ниже.

Ускорение до пары секунд — это отлично. Но иногда хочется (или нужно добиться) абсолютно мгновенного старта. И тогда в дело вступает Native Image.

Native Image

При сборке в Native Image тот же проект использовал всего лишь 35 МБ ОЗУ и запускался за 1/10 секунды! При этом размер самого Native Image на диске составил всего 89 МБ. 

Сочетание компилятора Graal в режиме AOT и виртуальной машины Substrate VM нацелено на то, что приложение будет запускаться моментально, съедать совсем немного памяти, занимать мало места на диске, и при этом работать быстро. Что важно, такой особенный способ сборки легко доступен: нужно просто добавить пару зависимостей и немного изменить основной шаг в скриптах. И можно использовать команды вида

gradlew nativeImage

Отличный результат...

Для работы Native Image вообще не нужен слой с полноценной JDK, ведь такой контейнер по сути представляет собой обычный исполняемый файл. Базовый контейнер может в принципе быть «scratch»-образом с минимальной функциональностью. Проект будет работать в случае, если приложение содержит все свои зависимости. Так, например, Native Image может быть статически слинкован со стандартной библиотекой C.

В базовом контейнере для Native Image могут также присутствовать сертификаты, библиотеки SSL и динамически загружаемая библиотека C. Тогда базовый образ будет так называемым distroless, «без дистрибутива». В реестре gcr есть такие distroless images без libc от 2 МБ (base). Это немногим меньше, чем размер минимального образа Alpine Linux, однако вместе с glibc выходит уже намного больше — 17 МБ.

Сравним контейнеры с Native Image, thin jar и fat jar:

Место на диске

RAM

Старт

thin jar unoptimized

13 kb thin jar + 17,4 MB libs + 107 MB базовый образ

135 MB 

2,197 с

thin jar optimized

13 kb jar + 17,4 MB libs + 107 MB базовый образ +

50 MB jsa (CDS archive)

70 MB

1,156 с

fat jar unoptimized

18,02 MB jar + 107 MB базовый образ

135 MB

3,811 с

Native Image

89,22 MB

35 MB

0,111 с

Впечатляет, правда? Но, во-первых, где-то же должен быть подвох (об этом как раз поговорим в следующем разделе). А во-вторых: всем ли нужны доли секунды на запуске, и стоят ли они возможных проблем?

… Но со своими условиями

Java-программы в виде Native Image выполняются и оптимизируются не совсем так, как в «большой» JVM. Более того, такая сборка в принципе доступна не для всех программ.

Ограничения Native Image, из-за которых приложение потребует исполнения через JDK, делятся на три большие группы. Кстати, такое исполнение происходит через так называемый fallback image, который сгенерируется вместо обычного во время сборки. Он задействуется при использовании неподдерживаемых фич либо при их неправильной настройке.

  1. Функции, связанные с метаданными классов. Требуют настройки во время сборки образа, в противном случае fallback image. Это, в частности, динамическая загрузка классов, рефлексия и те самые динамические прокси.

  2. Функции, несовместимые с предположением closed-world. В частности, invokedynamic и сериализация.

  3. Функции, которые в Native Image могут работать не так, как мы привыкли в JVM. Например, инициализация классов и Unsafe.

Полный список ограничений можно посмотреть в Native Image Compatibility and Optimization Guide.

Скажу больше, сборка Native Image — это статическая компиляция, требующая очень много памяти и времени. И она дополняется довольно сложной конфигурацией. На этом этапе нам помогают автоматизировать процесс плагины для Maven и Gradle либо инструменты вроде Tracing Agent, которые собирают данные о требованиях микросервисов во время прогона на JVM.

Дополнительные оптимизации и диагностика входят только в GraalVM EE, на который необходима лицензия для использования в проде. А деплой образа с предельно тонким слоем thin jar, как в нашем эксперименте ранее, возможен только тогда, когда есть jar-файлы, то есть только с обычным рантаймом. Ещё при работе с Native Image приходится постоянно думать о том, будут ли функции выбранного фреймворка с ним совместимы. В общем, подводных камней тут достаточно.

Решает ли Native Image все проблемы?

Похоже, что нет. Всё определяется ситуацией: иногда нам нужен Native Image, иногда — обычная JVM. Выбор того или иного инструмента для микросервисов зависит от поставленных задач.

Преодолеть шероховатости работы с Native Image помогает то, что существуют фреймворки, изначально ориентированные на взаимодействие с ним: Quarkus, Micronaut, Helidon. Благодаря своему дизайну они обходят приведённые ограничения (например, не используют инструментацию байт-кода), но ценой тому зачастую меньшие возможности, ограничения в их коде и архитектуре.

В 2020 году самый популярный Java-фреймворк Spring Boot значительно расширил поддержку Native Image. Она всё ещё в экспериментальном режиме, но уже выдаёт отличные показатели производительности и совместимости. В то же время, GraalVM делает шаги в направлении быстрой и корректной работы с существующими фреймворками.

Для тех, кто отчаялся ждать быстрого старта от обычной джавы: не унывайте. В этом направлении проводится оптимизация JVM и стандартной библиотеки, улучшение существующих связанных фич (AppCDS), а также эксперименты с быстрым восстановлением процессов (Checkpoint/Restore), которые показывают фантастические 50 мс как раз на микросервисных примерах. 

Заключение

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

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