Меня зовут Денис, я тимлид команды R&D в Naumen Service Managment Platform.
Наша платформа — зрелое решение для автоматизации бизнес-процессов, а ещё сложная IT-система, которая строится с использованием готовых опенсорсных решений. Чем сложнее и старше продукт, тем больше подобных кубиков — внешних зависимостей, появляется в проекте. И в конце концов, нам пришло понимание, что всем этим «зоопарком зависимостей» надо управлять, желательно автоматически.
В этой статье я поделюсь опытом своей команды. Расскажу, как, используя maven плагины, чуточку Groovy и очень много Jenkins, наш сервис выжил в многомодульном монолите с тысячей внешних зависимостей.
Статья будет полезна разработчикам и девопсам, которые сталкиваются с поддержкой систем.
Откуда берутся внешние зависимости
Naumen Service Management Platform — большой проект, где есть более двадцати maven-модулей и более полутора миллиона строк кода. Система позволяет работать с разными механизмами, объединенными в одну кодовую базу: от многочисленных способов аутентификации до низкоуровневых базовых решений. Например, библиотеки ORM, библиотеки внутрикластерного взаимодействия, интеграции с внешними системами через сервисы Kafka, IBM, Apache Cassandra и т.д. Для реализации этой функциональности мы используем готовые опенсорсные решения и встраиваем их в продукт в виде внешних зависимостей. Функциональность продукта растёт, а, значит, и количество зависимостей тоже.
Чем выше сложность продукта, тем больше проблем приходится решать. В том числе, с обеспечением совместимости и поддержкой актуальных версий всех внешних зависимостей.
Тут может возникнуть вопрос: в чём проблема? Ведь современные системы сборки могут решать конфликты из разряда JAR hell! Да, действительно, системы сборки maven или gradle могут справляться с конфликтами зависимостей, но не всегда надёжными способами :)
Maven решает проблему, когда у вас есть несколько одинаковых зависимостей с разными версиями, путём «nearest definition» — выигрывает зависимость, которая находится ближе к корню проекта. А если зависимости находятся на одном уровне вложенности, то побеждает первая.
Пример такой проблемы: зависимость D с версией 1 имеет более короткий путь к руту, чем зависимость с более высокой версией 2.
Вы можете исправить проблему — сделать так, чтобы зависимость D с версией 2 стала ближе всех к руту. Я изобразил это на рисунке:
Однако, если вы добавили ближе к руту новую зависимость, а она тянет с собой уже имеющуюся, но с версией меньшей, то в финальной сборке вы можете получить, как раз, её. Предугадать, как поведет себя в этом случае ваша система, не всегда возможно.
Внешние зависимости накладывают на систему ограничения: они напрямую влияют на продукт и его развитие, а, значит, влияют на клиентов и бизнес. Здесь советую действовать под таким девизом: «Либо вы управляете ограничениями, либо они управляют вами», иначе возникнут сложности.
Какие проблемы будут, если не обновлять зависимости
Если не управлять ограничениями, могут возникнуть суровые последствия: уязвимости, сложность внесения изменений, сложности обновлений и растущий техдолг.
Во внешней зависимости злоумышленники могут найти уязвимость, с помощью которой взломают клиента, а вы понесёте материальные и репутационные убытки. Известный и недавно нашумевший пример — это легкоэксплуатируемая уязвимость в библиотеке логирования log4j.
Или такой пример: от бизнеса поступит задача внести правки в логику системы, но вы не сможете этого сделать. Так как именно это изменение требует новой версии внешней зависимости или, наоборот, текущая версия не может дать возможностей, которые от неё ожидают.
А ещё само обновление, как последствие долгого необновления, может быть болезненным последствием. Ведь это дорого и долго.
Чтобы избежать таких проблем, важно систематически обновлять внешние зависимости, то есть, поддерживать продукт в актуальном состоянии. Для этого важно: постоянно мониторить все зависимости, искать подходящие версии и проверять, чтобы в них не было уязвимостей, а также следить, что после обновления ничего не сломалось. Страшно звучит? Да, не очень весело и рутинно :) Поэтому спойлер: мы эту работу автоматизировали.
Сначала мы составили чек-лист, что нужно сделать для автоматизации процесса. Делюсь им с вами:
Найти что обновить.
Убедиться, что мы можем обновить.
Убедиться, что нет конфликтов с текущими версиями зависимостей.
Убедиться, что обновление безопасно.
Убедиться, что обновление не ломает нашу систему.
Закоммитить обновление в систему.
Потом думали, как это реализовать — мы же не первопроходцы в этой теме, поэтому сперва изучили, какие решения уже существуют.
Какие способы обновления уже существуют
Например, существует механизм от github — «dependabot», который обещает интересные возможности. Заявлена поддержка обновлений зависимостей для многих языков: Ruby, Python, Go, Java, Rust, PHP. Приведу пример его работы:
Обновился maven плагин в 3.2.2, бот это заметил и предложил заапрувить изменения. То есть, нам автоматически нашли обновление и предложили внести изменения в систему — красота. Но вернёмся к нашему к чек-листу: важно, чтобы эти изменения были внедрены в систему безопасно и органично. Поэтому давайте посмотрим, что на самом деле умеет делать бот. Этот механизм может:
Если ваш код в GitHub, подключение без проблем — из коробки.
Если вы хоститесь в GitLab, то чуть сложнее — требуется доступ из вашего Gitlab вовне, чтобы проверять зависимости.
Делать pull request на каждое обновление c возможностью объединения в группы с помощью регулярок.
Игнорировать зависимости и настраивать время выполнения.
Замержить изменение без вашего участия.
Выглядит привлекательно, однако, увы и ах, возможностей нам не хватило :) Мы хостимся на Gitlab, а вся автоматика по части Continuous Integration сделана в Jenkins, где у нас есть хорошая экспертиза и много готовых инструментов. Также нам нужен полный контроль и возможность глубокой кастомизации, что, на наш взгляд, достаточно просто было сделать в Jenkins. А ещё нам нужно больше гибкости — базовые настройки нам не подошли.
Поэтому мы, вдохновившись «dependabot», создали свой способ обновления тысячей зависимостей: взяли наш чек-лист, положили его на пайплайн Jenkins и написали логику на Groovy.
Наш способ обновления зависимостей
Получилась сборка из цепочки этапов.
Рассмотрим их подробнее.
Первый этап, «Collector», — почти тоже самое, что делает «dependabot», но с более гибкими возможностями. Мы используем плагин Maven Version, с помощью которого чекаем обновления зависимостей, а после формируем набор ссылок на новые версии библиотек в maven-репозитории. На этом этапе мы умеем гибче игнорировать либы, точечно управлять приоритетом обновления, ну и, конечно же, настраивать время и частоту обновления. Причем под каждую конкретную библиотеку, если это требуется.
Однако, в отличие от бота, мы умеем решать конфликты версий в связанных зависимостях. Иногда одну либу невозможно корректно обновить без другой — у обновляемой есть несколько собратьев из той же groupId и с той же версией, которые нужно обновлять, только если обновляется чужая связанная.
Также мы готовим к обновлению сразу все зависимости, у которых есть обновления, а не по одной.
На втором этапе, «Updater», обновляем версии в наших помниках. Исключаем плагины, для которых есть конфликты версий и убеждаемся, что новая версия зависимости не вызовет jar hell. То есть, мы строим полное дерево зависимостей и смотрим, нет ли в нём разных версий одной и той же зависимости.
На третьем этапе, «Scanner», убеждаемся, что новая версия зависимости безопасна для внедрения в продукт — проходим проверку в отделе безопасности. Список ссылок из первого этапа мы отправляем во внутреннюю систему безопасности. Каждая библиотека проходит проверку через AppScriner. Пока не придёт положительный ответ, наша система терпеливо ждёт. Здесь мы получаем гарантию, что в проект не проскользнет уязвимость или вредоносный код.
Так как этап с проверкой безопасности занимает продолжительное время, параллельно выполняем четвертый этап, «Testing». Это прогон тестов на ветке с обновленными зависимостями. Мы должны убедиться, что обновленная зависимость не сломала наш продукт. Если тесты падают на ошибке компиляции, когда, например, в новой версии изменился класс или пекедж, то отправляем сообщения ответственной команде. Они правят ошибку, чтобы автоматика продолжила работу дальше.
После того, как все тесты пробежали, сборка зеленая, а от системы безопасности пришёл положительный ответ, наступает время заключительного этапа. На пятом этапе, «Integrate», ветка с обновленными зависимостями интегрируется в мастер. Каждый из предыдущих этапов оставляет после себя мини-отчёт, который суммируется и прикладывается как коммит-месседж к новой ветке, а хэш коммита передается в наш task tracker. Там его подхватывает автоматика по процессу интеграции всех прочих задач от разработки. Наша интеграция получает приоритетный статус, а проверка осуществляется только на отсутствие ошибок компиляции.
И вуаля, всё готово.
Мы автоматизировали весь процесс систематического обновления зависимостей, при этом не ухудшили его качество и не использовали внешние решения. Тщательно проверили конфликты, особое внимание уделили вопросам безопасности и, что самое главное, сделали почти все автоматически.
Сейчас система находится в прод-режиме, с помощью нее было обновлено более 500 библиотек — это сэкономило много времени и сил. И мы не собираемся останавливаться, в планах есть идеи на развитие нашего механизма. Например, мы хотим уменьшить количество внешних зависимостей в проекте, так как, несмотря на всю автоматику, держать их под контролем трудно. Для этого хотим добавить ещё один этап на раннюю стадию пайплайна, где будем строить граф зависимостей из java-классов с использованием библиотеки JQAssistant и графовой базы neo4j. Это поможет получить инструмент анализа связей и поиска неиспользуемых или редко используемых зависимостей.
novoselov
Есть планы выложить в open source?