Как мы обновляли продакшн до Spring Boot 3

В уже уходящем 2024 году мне удалось побывать на конференции JPoint, которая проходила в апреле. В числе прочего там активно обсуждалась тема обновления проектов на Spring Boot 3. Однако из тех, кого мне удалось послушать, и с кем пообщаться, ни у кого не было реального опыта такого обновления. Опасения в первую очередь были связаны с Hibernate 6, который сильно изменился по сравнению с предыдущей пятой версией.

Как я уже позже выяснил на собственном опыте, опасались не зря. Именно из-за изменений в поведении Hibernate мы получили аварию на проде: наша база начала грузить CPU под 100%. Это была самая серьёзная, но далеко не единственная проблема, с которой пришлось столкнуться. Далее опишу в деталях, что, как делали и какие проблемы поймали.

b9d12afbc6e449ffedfda708547e46cf.jpg

Общий контекст

Что именно мы обновляли:

  • JVM с 11 до 17;

  • Kotlin с 1.6.21 до 1.9.24;

  • Spring Boot — с 2.7.12 до 3.3.1;

  • Все остальные зависимости, которые тянет за собой обновление Spring Boot.

Это было самое большое обновление бэкэнда в истории нашего проекта, которому уже исполнилось пять лет. Сам проект немаленький: содержит около 200 тысяч строк кода. При обновлении изменениями было затронуто примерно 2000 файлов. Весь процесс занял около трёх месяцев.

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

  1. Берём сервис, обновляем зависимости, добиваемся того, чтобы все автотесты работали.

  2. Отдаём QA инженерам, которые проверяют сервис в ручном режиме и запускают на него свои функциональные тесты.

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

  4. Берём очередной сервис и начинаем процесс заново с первого пункта.

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

@EntityGraph меняет поведение

В нашем проекте мы активно используем hibernate. Изменений по нему было очень много, однако, все проблемы с ним были найдены на этапе тестирования. Кроме одной: той, которая влияет на производительность.

У нас есть функция, которая помечена аннотацией @EntityGraph:

@EntityGraph(
	attributePaths = [
		"prop1”,
		"prop2”,
		"prop3”
	]
)
fun findAllByStatus(status: AdZoneStatus): List

Эта функция выбирает из базы список объектов AdZone с заданным статусом. Проблема с этой функцией в том, что внутри объекта AdZone находится большое количество полей, которые обеспечивают связь с другими зависимыми объектами (через стандартные аннотации @OneToMany, @OneToOne, @ManyToOne).

Если вызывать эту функцию без аннотации @EntityGraph, то все связанные таблицы тоже начинают загружаться из базы, что приводит к огромному количеству select запросов. Чтобы этого не происходило, а загружались только нужные поля, можно, указав аннотацию, перечислить все необходимые поля в списке. Тогда загружаться из базы будет только выбранный список полей, и не будет создаваться лишняя нагрузка на систему.

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

После нескольких часов возни в итоге удалось выяснить, что проблема возникает, если в коде используется несколько @Entity на одну и ту же таблицу в базе. Например у нас кроме класса AdZone был также класс AdZoneView, который работал с той же таблицей ad_zones. Проблема ушла после того, как я объединил эти два класса в один общий.

Кто-то может спросить, а зачем вообще так делать? Пишите нормальный код и тогда всё будет в порядке. На это у меня есть несколько аргументов:

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

  2. Факт в том, что эта функция работала хорошо до обновления и перестала работать после него. И связано это именно с изменениями, которые произошли в hibernate.

Эта проблема привела к тому, что на нашем проде postgres загрузил CPU своих серверов до 100%. Это продолжалось 10–15 минут, пока мы не нашли в чём причина и не предприняли действий по ее купированию.

HighLoad при выдаче метрик

Для сборки и анализа метрик на нашем проекте используется Prometheus. Каждый сервис по HTTP запросу выдаёт свои метрики через Spring Actuator. Периодичность сбора метрик в системе равна одной минуте. До обновления метрики выдавались примерно за 50 миллисекунд, после обновления это время увеличилось на два порядка и стало равно примерно пяти секундам.

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

Тем не менее решено было разобраться в проблеме. С помощью профайлера обнаружили узкое место: оно было в библиотеке https://github.com/prometheus/client_java. Проблема была в том, что в этой библиотеке время сбора метрик имело квадратичную зависимость от количества метрик. Другими словами если, например, количество метрик увеличивается в 5 раз, то время сбора метрик увеличивается в 25 раз. В нашей системе очень много метрик, поэтому мы получили замедление работы этой функции в 100 раз.

Я сделал PR, который исправляет эту проблему: https://github.com/prometheus/client_java/pull/985. Фикс уже смержен и вошел в релиз 1.3.2.

Утечки коннектов в Undertow

В наших самых нагруженных сервисах, которые принимают трафик по HTTP, мы используем Undertow, т.к. для нас он работает в разы быстрее, чем Tomcat или Jetty. В этом веб сервере есть баг, который проявляется не всегда и не везде, но у нас присутствует. Это утечки коннектов, вызванные гонкой потоков в классе HttpServerExchange. Проблема эта очень старая, на неё заведено пара тикетов:

Почти год назад я делал PR с фиксом этой проблемы: https://github.com/undertow-io/undertow/pull/1560. На нашем проде работает форк с этим фиксом. Однако PR так и не был смёржен на момент обновления Spring Boot. При этом сам Spring обновил у себя Undertow с версии 2.2 до версии 2.3 без поддержки обратной совместимости. Таким образом пришлось форкать уже Undertow 2.3, т.к. утечка коннектов по-прежнему происходила на нашем проде.

К счастью недавно другой PR с аналогичным фиксом таки был смержен здесь: https://github.com/undertow-io/undertow/pull/1661. Есть надежда, что при следующем обновлении Spring Boot уже не придётся сталкиваться с этой проблемой.

Судьба Undertow вызывает у меня некоторое беспокойство: главный контрибьютор Stuart Douglas перестал коммитить в него ещё в 2020 году. Сейчас проект поддерживается другими людьми. Сайт веб сервера судя по всему заброшен: https://undertow.io/. Последний релиз, который там упоминается, это 2.1. Запись о нём была сделана всё в том же 2020 году.

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

Liquibase устраняет технический долг

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

Новая версия liquibase перестала это игнорировать и начала падать с ошибками во время миграций: https://github.com/liquibase/liquibase/pull/2943. На первый взгляд чинится это легко: просто убираем команду afterColumn из changelog«а, которая раньше и так ничего не делала, и всё, тесты начинают работать.

Но здесь не всё так просто. Если вы меняете changelog, то меняется его чексумма. Если liquibase при выполнении миграций увидит, что у одного из старых changelog«ов чексумма отличается от той, что записана в базе, то он упадёт с ошибкой. Тут самое коварство в том, что автотесты у вас работают, т.к. они запускают миграции с нуля. А на проде миграции упадут, т.к. там ранее уже выполнялись эти же миграции.

Решить проблему можно, если указать в старом changelog«е параметр validCheckSum, в котором следует выставить старую чексумму.

Проблема с библиотекой jtwig

На нашем проекте мы иногда отправляем пользователям email сообщения, которые создаём с помощью библиотеки шаблонизатора jtwig. Проблема в том, что эта библиотека умерла. Последний релиз был в 2018 году, а сайт уже недоступен: http://www.jtwig.org/

027e9db03fad94c6c50c6507a43a5e58.png

На Java 11 она ещё работала, однако при обновлении Java до версии 17 работать перестала. Менять её на какую-то другую библиотеку нам довольно трудозатратно, т.к. уже накопилось много немаленьких шаблонов, которые пришлось бы переписывать. К счастью нашёлся workaround: https://github.com/gasparbarancelli/spring-native-query/issues/46. Если указать JVM параметр --add-opens java.base/java.lang=ALL-UNNAMED, то она снова начинает работать.

Заключение

По моему опыту обновление бэкэнда — это всегда долго, сложно и сопряжено с большим количеством проблем, в том числе иногда и для бизнеса (в худшем случае). Java 17 и Spring Boot 3 вышли в релиз уже довольно давно и с тех пор, уверен, было исправлено большое количество других проблем, с которыми я не сталкивался. Поэтому, как правило, лучше выжидать не менее года, а лучше и более перед тем, как обновлять прод.

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

Если долго не обновлять бэкэнд, то уязвимости копятся, их количество начинает исчисляться сотнями. Рано или поздно это может привести к таким проблемам, по сравнению с которыми перегруженный на проде postgres покажется несущественной ерундой.

Автор: Андрей Буров, Максилект.

P.S. Мы публикуем наши статьи на нескольких площадках Рунета. Подписывайтесь на нашу страницу в VK или на Telegram-канал, чтобы узнавать обо всех публикациях и других новостях компании Maxilect.

© Habrahabr.ru