SIGILL при старте: как AOT-кэш JDK уронил Axelix в Docker

—

от автора

Введение

Представьте, что вы разворачиваете open source Java-приложение в Docker. Вы скачиваете образ, запускаете его, и ещё до первой строчки лога JVM падает вот с таким сообщением:

# A fatal error has been detected by the Java Runtime Environment:##  SIGILL (0x4) at pc=..., pid=1, tid=...## Problematic frame:# v  ~AdapterBlob

Именно это произошло у некоторых пользователей Axelix при запуске Axelix Master. SIGILL означает illegal instruction (недопустимая инструкция): процессор получил команду выполнить инструкцию, которую он не поддерживает. От Java-приложения такого не ждёшь. Java должна следовать принципу «write once, run anywhere», а байткод не содержит процессорных инструкций.

Мы разобрались в проблеме, исправили её и написали об этом статью. Это хорошая история о новой возможности JVM, AOT-кэше, и о небольшой детали его устройства, которая оказалась очень важной для всех, кто распространяет заранее собранный Docker-образ.

Что мы увидели

Баг был заведён как #1744. Все крэш-логи, которые нам прислали пользователи, указывали на один и тот же фрейм, ~AdapterBlob. Отличалась только недопустимая инструкция:

  • На машине с ARM64 JVM падала на ptrue p7.b. Это инструкция SVE (Scalable Vector Extension), а SVE отсутствовал в списке возможностей процессора в крэш-логе.

  • Мы также воспроизвели проблему сами на x86-64. Мы запустили Axelix 1.1.1 на эмулируемом процессоре без AVX, и JVM упала на vzeroupper, это инструкция AVX.

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

Чтобы в этом разобраться, сначала нужно посмотреть, как работает AOT-кэш.

Что такое AOT-кэш?

При каждом запуске JVM заново выполняет много одной и той же работы. Она читает классы из JAR-файлов, разбирает и верифицирует байткод, линкует классы и выполняет статические инициализаторы. Затем какое-то время исполняет код в интерпретаторе, собирает профили, и только после этого JIT-компилятор может выдать хорошо оптимизированный машинный код. Для Spring Boot-приложения с тысячами классов эта работа составляет значительную часть времени старта.

Project Leyden ставит целью переиспользовать эту работу между запусками. Его первый результат: AOT-кэш:

  • JEP 483 (JDK 24) — классы хранятся в кэше уже загруженными и слинкованными.

  • JEP 514 (JDK 25) — упрощённые параметры командной строки.

  • JEP 515 (JDK 25) — в кэше также хранятся профили исполнения методов, поэтому JIT может начать оптимизацию раньше.

Тренировочный запуск  →  сбор данных  →  создание app.aot                                              ↓Следующие запуски     ←  используют данные из app.aot

Три фазы

Работа с AOT-кэшем состоит из трёх фаз:

  1. Тренировка (training). Вы запускаете приложение с репрезентативной нагрузкой. JVM записывает, какие классы были загружены и слинкованы и как исполнялись методы.

  2. Сборка (assembly). Отдельная JVM берёт записанную конфигурацию и создаёт файл кэша.

  3. Продакшн (production). Последующие запуски стартуют с -XX:AOTCache=... и используют подготовленные данные вместо того, чтобы снова проделывать всю работу.

В Dockerfile это естественным образом ложится на multi-stage сборку. Упрощённая версия Dockerfile для Axelix Master выглядит так:

FROM eclipse-temurin:25-jre-alpine AS trainingWORKDIR /applicationCOPY build/libs/master.jar master.jar# 1. Тренировка: запускаем приложение до обновления контекста и записываем, что оно делаетRUN java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \    -Dspring.context.exit=onRefresh \    -jar master.jar# 2. Сборка: создаём кэш из записанной конфигурацииRUN java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot \    -jar master.jarFROM eclipse-temurin:25-jre-alpine AS finalWORKDIR /applicationCOPY --from=training /application/master.jar /application/app.aot ./# 3. ПродакшнENTRYPOINT exec java -XX:AOTCache=app.aot -cp master.jar org.springframework.boot.loader.launch.JarLauncher

-Dspring.context.exit=onRefresh это возможность Spring Framework, созданная как раз для этой цели: приложение стартует, обновляет ApplicationContext и завершается. Этого достаточно, чтобы «прогреть» всё, что важно для старта, и при этом не нужны ни база данных, ни сеть.

Что лежит внутри кэша?

В JDK 25 один файл AOT-кэша содержит несколько видов данных:

  • метаданные классов — классы, которые уже разобраны, верифицированы и слинкованы;

  • профили методов — статистика исполнения, собранная во время тренировки;

  • объекты кучи — некоторые заранее построенные Java-объекты, например граф модулей;

  • кэш AOT-кода — настоящий машинный код.

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

AOTAdapterCaching

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

Когда метод вызывается впервые, интерпретатор исполняет его байткод. Когда метод становится «горячим», JIT компилирует его в машинный код. Интерпретируемый и скомпилированный код используют разные соглашения о вызовах. Интерпретатор передаёт аргументы через собственный стек, а скомпилированный код передаёт их через регистры, как того требует ABI платформы. Поэтому JVM нужны небольшие фрагменты связующего кода между ними, которые называются адаптерами:

  • i2c (interpreted-to-compiled) — используется, когда интерпретируемый метод вызывает скомпилированный;

  • c2i (compiled-to-interpreted) — используется, когда скомпилированный метод вызывает метод, который всё ещё интерпретируется.

Адаптеры генерируются для каждой сигнатуры метода, и большому приложению их нужны тысячи. Их генерация при старте занимает время, поэтому в JDK 25 добавили опцию AOTAdapterCaching, которая включена по умолчанию: адаптеры генерируются на фазе сборки и сохраняются в AOT-кэше в виде готового к исполнению машинного кода.

И вот в чём подвох. Как и любой код, который генерирует HotSpot, адаптеры генерируются под процессор, на котором JVM работает в данный момент. Если этот процессор поддерживает AVX-512, адаптер может использовать регистры AVX-512. Если он поддерживает SVE, адаптер может использовать инструкции SVE. На машине, где шла сборка, такой код корректен. На машине без этих возможностей первый же вызов через такой адаптер выполняет инструкцию, которую процессор не знает, и ядро посылает SIGILL.

Почему JVM нас не защитила

JVM действительно валидирует AOT-кэш. При продакшн-запуске она проверяет сборку JDK, операционную систему, архитектуру процессора (x64 или aarch64), classpath, GC, compressed oops и ещё ряд настроек. Если что-то не совпадает, JVM в режиме по умолчанию -XX:AOTMode=auto выводит предупреждение и продолжает работу без кэша. Старт получается медленнее, но ничего не ломается.

Однако одну вещь JVM в JDK 25 не проверяла: возможности процессора в пределах одной архитектуры. «x64» означает и современный серверный процессор с AVX-512, и старый процессор ноутбука без него. Кэш принимался, а адаптеры внутри него использовались как есть.

Теперь посмотрим, как собирался наш образ. Релизный job запускается на раннере, который предоставляет GitHub, и собирает multi-platform образ:

docker build --platform linux/amd64,linux/arm64 --file master-oss/Dockerfile ...
  • Вариант amd64 тренируется на том процессоре, который случайно окажется у раннера. Раннеры GitHub являются облачными виртуальные машины, и у некоторых из них есть AVX-512.

  • Вариант arm64 тренируется под эмуляцией QEMU (docker/setup-qemu-action), потому что сам раннер работает на x86. Эмуляция QEMU в user-mode по умолчанию эмулирует модель процессора max, в которой есть SVE. Однако у многих реальных ARM64-процессоров, на которых люди запускают контейнеры, SVE нет: у Mac на Apple Silicon, AWS Graviton2 и Ampere Altra.

Multi-platform сборка прогоняет весь Dockerfile отдельно для каждой платформы, поэтому каждый вариант образа получает собственный кэш, а docker pull выбирает вариант, соответствующий хосту. Архитектура всегда совпадала. А вот возможности процессора в её пределах не совпадали: у наших сборочных машин их было больше, чем у многих машин наших пользователей. Ровно такая комбинация и приводила к падению.

Баг в апстриме

В ходе расследования мы нашли adoptium/adoptium-support#1507, «Launching with AOT cache fatally crashes with SIGILL (0x4) depending on available CPU instructions». Проблема была та же самая: кэш, созданный на процессоре Zen 5 с AVX-512, падал в ~AdapterBlob на процессоре Zen 2 без AVX-512. Автор обнаружил её так же, как и мы: у раннеров GitHub Actions AVX-512 то есть, то нет.

Issue перенесли в OpenJDK как JDK-8388703. Реакция разработчиков HotSpot была прагматичной. Адаптеры являются единственным AOT-кодом в JDK 25 (кэширование стабов уже выключено), и производительность они улучшают не сильно. Поэтому вместо того, чтобы бэкпортировать проверки процессора, они решили просто выключить кэширование адаптеров по умолчанию в JDK 25. Именно это исправление вошло в JDK 25.0.5:

// Disable adapters caching which requires CPU features matching checks// (implemented by JDK-8377507 and JDK-8381975) which we don't// have in this version of code.FLAG_SET_ERGO_IF_DEFAULT(AOTAdapterCaching, false);

Полноценное исправление находится в JDK 27:

  • JDK-8377507 — возможности процессора машины, на которой шла сборка, сохраняются в кэше AOT-кода;

  • JDK-8381975 — при старте сохранённые возможности должны точно совпадать с возможностями текущего процессора.

Если они не совпадают, JVM пишет в лог AOT Code Cache disabled: cpu features are incompatible, игнорирует только машинный код и продолжает использовать всё остальное из AOT-кэша. Так и должно быть: в худшем случае ускорение будет чуть меньше, но падения не будет никогда.

Наше исправление

Исправление: #1767. Мы вернули AOT-кэш, но создаём его без адаптеров:

# Create AOT cache without CPU-specific adapters to avoid SIGILL on other CPUsRUN java \    -XX:+UnlockDiagnosticVMOptions \    -XX:-AOTAdapterCaching \    -XX:AOTMode=create \    -XX:AOTConfiguration=app.aotconf \    -XX:AOTCache=app.aot \    -jar /application/master.jar

AOTAdapterCaching является диагностическим флагом, поэтому нужен -XX:+UnlockDiagnosticVMOptions.

С этим флагом в кэше вообще нет машинного кода, и JVM подтверждает это при старте:

[info][aot] Opened AOT cache app.aot.[info][aot,codecache,init] AOT Code Cache is empty[info][aot] Using AOT-linked classes: true (static archive: has aot-linked classes)

Остаются метаданные классов, профили и объекты кучи, которые одинаковы на любом процессоре данной архитектуры. Адаптеры генерируются во время работы, на машине пользователя и под его процессор. И их удаление не лишает нас выигрыша. При локальной проверке образа, собранного из исправленного Dockerfile, около 72% классов, загруженных при старте (10 933 из 15 201), были взяты из AOT-кэша, а время до обновления контекста сократилось примерно с 7,3 с до 5,4 с на ноутбуке разработчика.

Альтернативы мы обсуждали в PR. Первая идея была дождаться JDK 25.0.5 и оставить кэширование адаптеров включённым. Но 25.0.5 не делает адаптеры безопасными. Он просто выключает их, так что результат тот же, что и с нашим флагом. Вторая идея была перейти на JDK 27, где проверка процессора есть. Большинство запускает Axelix Master в Docker или Kubernetes, так что новая базовая версия Java их бы не затронула. Но Spring Boot 4.1 официально поддерживает Java до 26-й версии, а поддержка Java 27 ожидается в Spring Boot 4.2. Поэтому мы смёржили явный флаг. Он работает на сегодняшних сборках JDK 25 и остаётся корректным после 25.0.5, где просто совпадает с новым значением по умолчанию.

А что с JDK 27?

Как только выйдет Spring Boot 4.2, мы планируем перевести Axelix на JDK 27. Там снова включать AOTAdapterCaching безопасно: на несовпадающем процессоре JVM отбросит закэшированный код, а не упадёт.

Но «безопасно» не значит «полезно». JDK 27 требует точного совпадения возможностей процессора. Для образа, собранного на раннере GitHub или натренированного под QEMU, шанс, что у процессора пользователя окажется ровно тот же набор возможностей, невелик. У большинства пользователей закэшированные адаптеры просто будут выброшены. Чтобы заранее собранный образ действительно выигрывал от AOT-кода, JVM должна генерировать его под общий, переносимый базовый набор возможностей процессора. Для этого есть открытое улучшение, JDK-8377630 «Restrict AOT code to a generic set of CPU features». Пока оно не появилось, AOT-кэш в распространяемом образе лучше воспринимать как кэш данных, а не кода.

Выводы

  • AOT-кэш JDK является реальным и простым способом ускорить старт Spring Boot-приложений в Docker. Достаточно multi-stage Dockerfile с этапом тренировки.

  • На JDK 25 до версии 25.0.5, если вы собираете кэш на одной машине, а запускаете на другой, создавайте его с -XX:+UnlockDiagnosticVMOptions -XX:-AOTAdapterCaching. Иначе процессоры ваших пользователей должны поддерживать все возможности, которые были у вашей сборочной машины. А у CI-раннеров и QEMU их обычно больше.

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

  • JVM валидирует AOT-кэш и безопасно откатывается, если что-то не совпадает. Единственное исключение до JDK 27: машинный код, хранящийся в кэше.

Исправление войдёт в следующий релиз Axelix. Если вам интересны подробности, вся история описана в #1744 и #1767.

ссылка на оригинал статьи https://habr.com/ru/articles/1100616/