Как стать автором
Обновить
34.91
Failover Bar
Митап-бар для разработчиков, айтишников и гиков

Двадцать бабушек – уже рубль. Как GraalVM Native Image позволяет экономить джавистам и девопсам деньги на облако

Время на прочтение 14 мин
Количество просмотров 5.7K

Первая статья в нашем блоге. Я постарался написать хороший текст на основе докладов из Failover Bar и прошлого митапа Альфы, и человеческими словами объяснить, зачем нужен GraalVM.

Мы поговорим об экономии на облаках в мире победившего Кубернетиса, обсудим перформанс современного приложения, и какие ништяки Native Image помогут нам во всём этом.

Mad skills bonus: вы сможете писать на Java приложения для командной строки так, что они не будут тормозить по сравнению со скриптами на Bash. Но вначале небольшая телега вместо вступления.

Когда-то, сто лет назад в 2018 году, Леша Федоров зачем-то выпустил меня на JUG в Питере, где я затирал дичь про GraalVM.

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

"Хаос - это не провал, хаос - это лестница" говорил известный персонаж Петир Бейлиш из Игры Престолов. В этом году случилось много не самых приятных вещей, но есть и плюсы - я наконец-то работаю в компании, которая делает свой собственный дистрибутив Java - Axiom JDK, и у нас как у Java-сообщества есть почти свой собственный meetup-бар Failover Bar, где можно на халяву делать JUG и тренировать доклады на Joker и Highload.

Что еще произошло

  • GraalVM стал очень нужен;

  • Kubernetes всех победил;

  • Компании массово мигрируют на российские облака.

Тут надобно бы поговорить о ресурсах, но есть нюанс: на проде о закупке оперативной памяти обычно думают спецпально обученные люди, и для разработчика это не так больно. Поэтому сделаем, чтобы было максимально больно.

Представьте, что вы пилите какой-то пет-проект. Например, вы взяли Spring PetClinic и решили сделать сайт для собачего приюта в родном городе. Кстати, это хорошая идея - вы не представляете, скольким "обычным" людям пригодились бы наши знания в разработке.

Два ядра, четыре гига и 200 гигов диска для нашего хэлловорлда должно быть достаточно. Сходим на Амазон и создадим такую виртуалку.

Теперь сходим в МТС Клауд и создадим такую же виртуалку.

Можно сходить куда-нибудь во flops.ru и получить все то же самое дешевле, но в обмен на стремную скорость дисков. Или в Селектел, или в Сберклауд, или в Яндекс-Облако.

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

Если вам кажется, что 8 тысяч - не деньги, и вообще-то один поход в магазин, то вспомните, что "два ядра, четыре гига" - это минимальный сетап. У вас же Java, а не C++ какой-нибудь. Если купить ядра 4, памяти 16, а SSD - 400, то это уже 20 тысяч в месяц.

Хаброкарьера в 2021 году проводила исследование зарплат разработчиков. Возьмем зарплату джава-тимлида, и воспользуемся нехитрой формулой: 20 000 руб / 300 000 руб * 100% = 7% зарплаты тимлида нужно отдать на наш чудесный пет-проект. Дальше разговаривайте со своей жабой.

Моя жаба давно победила, поэтому тесты для пет-проектов я гоняю не в облаках, а на старых компьютерах, стоящих дома в гардеробе. С учетом, что эти компьютеры уже давно куплены, экономия - гигантская.

Можно ли убежать на другие технологии?

Большая проблема в том, что на другие технологии убежать можно, но радости от этого не будет. Будет геморрой.

Например, у нас есть чудесный Rust. Чудесный до того, как ты осознал время компиляции (на больших проектах может оказаться сравнимо с GraalVM Native Image). Или то, что у Rust огромные проблемы с тулингом для поиска проблем. Например, если у тебя потекла память, все что ты можешь сделать - поставить специальный аллокатор, который может быть тебе что-то скажет (или нет). Опять же, поинт не в том, чтобы пнуть Rust, тем более что я сам им пользуюсь и нежно люблю. А в том, что всё имеет свою цену.

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

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

Пойдем по несколько более технологичной ветке, и порассуждаем, где можно сэкономить.

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

Раньше мы жили в мире монолитов. Монолитам нужна голая пропускная способность. Приложение может долго запускаться и прогреваться, но будучи прогретым - жарит как не в себя.

Возьмем какую-нибудь условную Jira, которой для запуска нужно ресурсов как на обсчёт полёта до Альфы Центавры, которая дико тормозит и переваливается на старте, но если уж запустилась - работает довольно прилично. Пока не упадет, но это уже другая история.

Этот профиль использования отлично подходит для JIT (just-in-time compiler), и именно поэтому Java когда-то 20 лет назад настолько хорошо выстрелила и зашла корпорациям.

Времена меняются, и теперь у нас повсеместно облака и Kubernetes.

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

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

И нам уж точно не хочется иметь классический массив пред-прогретых JVM, которые "ничего не делают и только топливо жрут", как Скрипач в "Кин-Дза-Дза".

Для этого профиля использования гораздо лучше подходит что-то предварительно скомпилированное. Нам нужен AOT (ahead-of-time compilation).

Размер или смерть

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

Игры с размером базового образа существуют, мы их рассмотрим в конце (тем более, что это шанс попиарить российский Axiom JDK).

Переписать приложение на Go, Rust или C++ заманчиво, но какой же бизнес на это даст денег? А если у тебя свой собсвенный мелкостартап на три человека, задумайся - осилишь ли ты тратить время на утечки памяти и процессора в Go/Rust, согласен ли забиндить if err != nil на тачбар своего макбука (придется купить старый макбук с тачбаром!). Ну и конечно, никто без особой причины не будет трогать С++.

К счастью, у нас есть компромиссный выход в виде GraalVM Native Image.

Что это такое?

Я постараюсь выбросить весь маркетинговый буллшит и ответить как есть. Получится не супер пафосно, но уж как есть. GraalVM - это Java-машина, построенная на основе классической OpenJDK, с набором дополнительных интересных фичей.

Вообще, раньше это было что-то типа игрушки для ученых, которые тестировали на ней свои идеи и писали диссертации, пользуясь красивым и человекопонятным кодом (не как в ad-файлах OpenJDK). Такой свет в темном царстве Java 8, когда люди и не надеялись, что свежие релизы джавы будут выходить два раза в год. Например, GraalVM может на интепретаторах поверх Truffle запускать что угодно, включая Python и C++. Не совсем понятно, зачем это нужно широкому потребителю, но звучит крайне волнующе, как особая граальная магия.

Но в какой-то момент ученые и инженеры понаписали достаточно качественного кода, чтобы он победил JIT C2 в OpenJDK, а это уже реальные деньги и совсем другой бизнес.

Насколько хорош этот JIT? Бенчмарк Ренессанс считает, что там можно выжать процентов 10 легко. Чем сразу же воспользовались всякие Твиттеры и другие большие энтерпрайзы (вспоминаем доклады Криса Талингера). Десять процентов на проде - это классическое "двадцать бабушек - уже рубль". Миллионы баксов.

Но у нас речь не о джите, а о маленькой утилите из стандартного набора, которая называется GraalVM Native Image. Она умеет компилировать JAR-файлы в исполняемые бинарники (например, EXE на Винде).

Идея простая - если мы можем что-то скомпилировать just in time в рантайме, то наверное, мы можем сделать то же самое и заранее, до запуска? Звучит как отличная идея для научной работы :) И вот, результаты всех этих работ у нас в руках.

5 шагов к успеху

  1. Скачать архив (для глобального использования подойдет Community Edition с сайта Oracle, для российских разработок запросите триал Axiom Native Image Kit).

  2. Распаковать: tar -zxf …

  3. export GRAALVM_HOME= …

  4. export PATH=GRAALVM_HOME/bin:PATH

  5. gu install native-image

Последняя команда нужна потому, что дистрибутив GraalVM и так слишком жирный. Если впихнуть туда все на свете, включая Native Image, он начнет весить не 300 мегабайт, а все 3 гигабайта, а этого нам не нужно. Поэтому там есть встроенный пакетный менеджер (прямо как в дистрибутиве GNU/Linux), который позволяет добавить туда дополнительные технологии за одну короткую команду.

Разница в фазах работы

У обычного приложения есть всего одна фаза - рантайм.

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

В случае с Native Image, фазы две: вначале сборка, и потом рантайм.

Фазы эти разнесены во времени и пространстве. Например, сегодня можно скомпилировать ваше гигантское приложение в датацентре, скачав миллиард зависимостей из Maven Central, а уже завтра уехать в отпуск в Сочи и запускать его на маломощном ноутбуке без интернета. Профит.

Если у классической JVM-ки сложным был рантайм, то здесь он очень прост - это обычное выполнение бинаря в операционной системе. А вот на этапе сборки происходят ракетные технологии.

Вначале Native Image собирает все доступные классы. Туда попадают не только классы из твоего приложения и зависимостей, но и стандартная библиотека JDK.

Дальше оно докладывает к этому нечто под названием Substrate VM. Можно думать об этом как о минимальном наборе нативных костылей, чтобы рантайм взлетел. Например, туда входит сборщик мусора и шедулер тредов.

Дальше оно в статическом анализе смотрит, к каким членам классов есть доступ, следует по всем вызовам методов и конструкторам, инициализирует все встреченные классы.

Только с этими найденными классами оно будет работать в дальнейшем, только они попадут в бинарник. Это снижает размер файлов и скорость запуска.

Полученный код компилируется и пишется в секцию TEXT.

Дальше инициализируются структуры данных и классы, сериализуются в Image Heap, и записываются в бинарник, в секцию DATA.

Проблемы инициализации

Во первый, вот вам небольшой рофл. Сдампить при инициализации можно все что угодно, достаточно записать это в поле как статические данные. Веселые картинки, ролики с ютуба, Lord of The Rings в режиссерской версии. После чего вас увезут в дурку, конечно.

Здоровые люди сохраняют туда карренси, локали, дейт-тайм конвертеры и прочий мусор, тормозящий на старте приложение.

Выглядит красиво. К сожалению, система эта даже не вполовину такая умная, как хотелось бы. В часности, оно полностью пропускает:

  • Reflection

  • Proxies,

  • Method Handles

  • Serialization

  • JNI

  • Resources

  • Наверняка что-то еще, что я забыл

Поэтому вам нужно вручную писать конфиги типа:

[
  {
    "name" : "java.lang.String",
    "fields" : [
        { "name" : "value", "allowWrite" : true },
        { "name" : "hash" }
    ],
    "methods" : [
      { "name" : "<init>", "parameterTypes" : [] },
      { "name" : "<init>", "parameterTypes" : ["char[]"] },
      { "name" : "charAt" },
      { "name" : "format", "parameterTypes" : ["java.lang.String", "java.lang.Object[]"] }
    ]
  }
]

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

Можно запустить ваше приложение на обычной JVM со специальным джава-агентом, после чего использовать приложение в хвост и гриву некоторое время. Например, отдать реальным пользователям или тестировщикам. Вот как это делается:

$JAVA_HOME/bin/java -agentlib:native-image-agent
=config-output-dir=META-INF/native-image 
-jar App.jar

Сразу после остановки приложения у вас сгенерится целая пачка жирных JSON-ов с конфигами, и писать вручную почти ничего не придется. Ну или такова идея. Дальше можно скомпилировать приложение с этими конфигом, и оно должно скомпилироваться без ошибок: native-image -H:ResourceConfigurationFiles=config.json

Тесты

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

К счастью, для автоматического поиска тестов у нас есть еще один джава-агент, но на этот раз, лежащий во внешнем репозитории Native Build Tools.

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

Когда инициализировать?

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

Вопрос - когда нужно этим заниматься?

Вопрос топовый, потому что раньше поля инициализировались на этапе сборки. А потом перестали, и сейчас вариант по-умолчанию - это рантайм.

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

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

Во-вторых, не все классы по смыслу совместимы с идеей "заморозки" внутри бинарника. Если ты заморозишь Random, он начнет отдавать одни и те же цепочки чисел, и наверное, это не то, что мы от него ждем. А что рантайм должен сделать с сохраненным внутрь бинарника тредом? Не знаете? И никто не знает.

В-третьих, эффект лавины. Чем больше классов, тем больше проблем накладывается друг на друга. Например, у тебя есть реальное приложение с асинхронными логгерами, которые ты пытаешься инициализировать на этапе сборки. А у логгеров внутри очередь, очередь работает на тредах, а треды мы сохранять не можем - все, приплыли.

Самая жесть в том, что таким образом на обычного разработчика перекладываются проблемы фреймворков. Ты случайно инициализировал у себя часть чужого фреймворка (например, кусок Hibernate), а он не собирается под Native Image с какой-то непонятной ошибкой. Вообще-то это проблема авторов Hibernate, что он не собирается. Но теперь это и твоя лично проблема, решать которую придется дикими костылями.

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

Время сборки

И вот мы подошли к самой большой и страшной проблеме GraalVM Native Image - время сборки.

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

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

Эту проблему давным-давно прочувствовали на себе разработчики C++. Вот чудесный гайд для новичков в проекте Chromium:

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

Быстро эту проблему не решат, но тем не менее, перспективы довольно хорошие. Разработчики активно пилят GraalVM, новые коммиты приходят каждый день. За последнее время скорость сборки GraalVM Native Image увеличилась раза в два, а со временем туда могут добавить кэш компиляции (прямо как в C++) и проблема просто исчезнет.

Для нас же сейчас стоит осознать, что стандартная практика подключения к своему проекту половины Maven Central резко потеряла свою актуальность. Думайте, что подключаете.

Помочь в этом может дашборд, который встроен в GraalVM:

native-image -H:+DashboardAll -H:DashboardDump=dumpfileoversized

Идея очень простая: чтобы бинарник занимал меньше места и быстрее собирался, нужно выбросить весь ненужный код. Какой код не нужен? Об этом скажет Native Image с параметром +DashboardAll.

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

Дополнительное сжатие

Native Image достаточно хорошо сжимает бинарники. Но upx и gzexe сожмут их еще сильнее, минимум на треть.

upx --best -k my_native_image

Настройка сборщика мусора

В Native Image есть собственный сборщик мусора. По сути, стандартный сборщик мусора с поколениями (generational scavenger), вроде того, что был во времена Java 9.

В платной версии GraalVM от Oracle есть еще и G1. В следующем году в GraalVM добавят ParallelGC, он окажется и в Axiom Native Image Kit. У ParallelGC throughput даже лучше, чем в G1, и это куда лучше подходит для облаков. Но надо понимать, что это платные коммерческие решения, а сейчас мы поговорим о том, что есть сразу и на халяву: наш generational scavenger.

У него есть несколько настроек, вот часть из них.

В рантайме:

  • -Xmx – максимальный размер кучи в байтах

  • -Xms – минимальный размер кучи в байтах

  • -Xmn – размер молодого поколения в байтах

  • -XX:+PrintGC – распечатать информацию о сборках мусора

  • -XX:+VerboseGC – можно добавить и увидеть подробности

Во время сборки:

  • -R:MaxHeapSize – максимальный дефолтный хип в байтах

Для любого джависта эти настройки достаточно понятны, но все-таки, пара нюансов. Во-первых, сборщик вылезает за лимиты -Xmx, поэтому ставить размер контейнера в точности как Xmx не стоит. Это происходит из-за особенностей реализации сборщика мусора, когда он перемещает объекты - он их складывает в специальное отдельное место, которое в Xmx не входит. Строго говоря, и обычная JVM-ка тоже занимает больше своего Xmx. Какой вывод для использования в Кубере? Сделайте контейнер чуть больше.

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

Зачем это может быть нужно? Ну например, если вы пишете какую-то свою команду вроде lscp или rm -rf --no-preserve-root /*, и вам хочется иметь минимальное время старта. Все понимают, что на обычной джаве можно написать утилиту cp, но запускаться она будет миллион лет. Можно иметь для этого пул пред-прогретых JVM, и другие извращенские технологии, но с появлением Native Image все это больше не нужно.

Конечно же, такую утилиту командной строки можно написать на каком-то другом нормальном языке, типа Go или Rust. Go еще и компилируется в миллион раз быстрее. Но используя Java, вы можете пошарить код консольного приложения с вашим основным продуктом. Очень удобно писать всякие консольные клиенты для веб-сервисов, они делаются простым переносом структур данных сетевого протокола (какие-то DTO-шки на Java) внутрь консольного приложения.

Уменьшение размера контейнера

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

В первом приближении, можно использовать любой slim образ. Например, Debian Slim или Axiom Runtime Container, с интегрированными внутрь дистрибутивами Java.

Debian Slim бесплатный, но огромный. И еще его иногда мантейнеры собирают неправильно, и он становится совсем огромный (400 мегабайт вместо 200).

Axiom Runtme Container платный, но сильно меньше (около 50-100 мегабайт), и подходит серьезным энтерпрайзам (сейчас на нем работает весь российский банкинг). По факту, Axiom Runtime Container - это самые маленькие контейнеры в мире, на чем они и зарабатывают себе на хлеб с маслом (стоимость лицензии на Axiom рядом не стоит с экономленными на облаках деньгами).

Спускаясь ниже по кроличьей норе, можно использовать сборки без полного дистрибутива Linux. Например, Google Distroless. Для того, чтобы Native Image там заработал, нужно собрать его со специальным флагом: native-image -H:+StaticExecutableWIthDynamicLibC …. Результат будет очень спорный и менее универсальный, чем в случае с Debian Slim и Axiom Runtime Containers, но что не сделаешь ради уменьшения размера образа.

Ну и если вы совсем любитель жесткого BDSM, можно использовать scratch-контейнер. Для этого ничего не нужно качать, нужно написать from:scratch в вашем докерфайле, а Native Image нужно статически собрать с маслом: --static --libc=musl. Результат будет совсем-совсем спорный, но двадцать бабушек - уже рубль.

Боль и удовольствие

Native Image - очень новая, крутая и неоднозначная технология.

С одной стороны, это боль.

  • Возможно, придется переписывать приложение;

  • Специфичные паттерны нагрузки (плоский профиль AOT vs пиковый профиль JIT);

  • Не самый продвинутый сборщик мусора (мы еще добавим ParallelGC в Axiom Native Image Kit, но не сейчас);

  • Молодая технология с возможными багами;

  • Нужно разбираться в куче хитрых конфигов;

  • Нативный код слабо отлаживаем, нужно отлаживать на обычной JVM;

  • IDE не подсвечивает ошибки, связанные с Native Image, и нескоро научится (если вообще);

  • … (список ада можно продолжать).

С другой стороны, это огромное удовольствие.

  • Маленькие бинарники, которые мгновенно запускаются;

  • Счета за облако радуют глаз;

  • Отлично выполняющиеся SLA радуют клиентов (а они вас - своими деньгами);

  • Красивый, понятный, хорошо отрефакторенный код фреймворков;

  • Куча новых современных технологий, с которыми приятно разбираться;

  • Отлично дополняет slim образы (типа Debia Slim или Axiom Runtime Container).

Выбирать или нет GraalVM Native Image прямо сейчас - это большой вопрос, но уже прямо сейчас понятно, что за этой технологией - будущее.

Теги:
Хабы:
+19
Комментарии 21
Комментарии Комментарии 21

Публикации

Информация

Сайт
failover.bar
Дата регистрации
Численность
2–10 человек
Местоположение
Россия
Представитель
Олег Чирухин

Истории