От ручной сборки приложений к конвейеру: опыт бэк-офиса «Магнита»
Вначале было слово… Нет. Не так…
И земля была безвидна и пуста… Нет. Снова не то…
Вначале было много разнообразной ручной работы… Уже лучше…
Вначале было много разнообразной ручной работы на пути от разработчика до релиза приложения для сети магазинов, и в этой статье я постараюсь рассказать, с чего мы начали, где находимся сейчас и что было на этом пути.
Я — Первушин Дмитрий, разработчик в управлении по развитию бэк-офиса торговых точек сети «Магнит». Основной стек — Python, Firebird, немного html/js и капелька других технологий. Моя команда занимается разработкой приложений для терминалов сбора данных, отчетов, АРМ торговых точек.
Часть первая. Описательная
Предыстория
Итак, начальная диспозиция: приложение состоит из нескольких частей, выполненных на различных технологиях. Большинство частей требует ручного труда при сборке, контроле соблюдения регламентов и условий установки в целевой системе.
Задачи поставлены амбициозные: при сборке постараться свести ручную работу к коммиту в систему контроля версий, а установку приложения в целевой системе — к одной команде. При этом минимизировать возможность внесения ошибок в процессе сборки и установки.
В процессе мы научились хранить наши приложения в git и собирать их «одним кликом», перешли от подхода «один репозиторий — одна технология» к «один репозиторий — одно приложение». Реализовали установку всех частей приложения «одной командой»; собрали грабли, разложенные в самых неожиданных местах, и создали несколько инструментов, помогающих нам на разных этапах сборки и установки приложений.
Если интересно, как и чего мы достигли на сегодняшний день, — добро пожаловать.
Предпосылки
Сборка и поставка
Приложение собиралось и поставлялось отдельными кусками: отдельно SQL, Python, отчеты, Win-приложения. Исходники разной степени актуальности хранились в разных уголках SVN, эталонных баз, GIT, и практически голыми руками собирались в пакеты для Palludis (ebuild+tbz) и SQL-скрипты для корпоративного установщика Manny.
Затем практически голыми руками писался bash-скрипт для установки пакетов Palludis и соединялся с SQL-частью. Получившийся в результате гибрид передавался в службу сопровождения для дальнейшей установки в целевые системы. Естественно, такой процесс временами давал сбои как при создании поставки — что-то не то с чем-то не тем как-то не так соединили, так и при установках — какая-то часть обновилась раньше, какая-то позже или совсем никогда, в результате приложение либо совсем не работает, либо работает как-то странно.
Такой процесс сборки и поставки породил законное желание получить нечто, что собирается роботом и ставится одномоментно.
Дельты SQL
SQL-часть собиралась и ставилась как изменение относительно предыдущей версии. И это работало почти для всех пользователей, у которых реально была установлена предыдущая версия. Однако у некоторого небольшого процента в силу разных причин находились какие-то отклонения от эталонного состояния.
В результате в лучшем случае обновление просто не накатывалось, а в худшем получались странные, трудно воспроизводимые ошибки. Кроме того, откат представлял собой антидельту, которая должна вернуть все метаданные к предыдущей версии.
Соответственно, было желание избавиться от расхождений эталонных и реальных БД и необходимости создания откатов.
Пакетный менеджер
Для установки в целевых системах использовался Palludis с пакетами ebuild, при этом родным для целевой ОС (CentOS) является yum/dnf с форматом RPM. «Раз уж ломать, то ломать всё», — подумали мы. И решили, что переедем на RPM.
Корпоративный переезд
Помимо наших внутренних болей, у нас появилась и внешняя: сверху поступила вводная хранить исходники в GitLab, артефакты — в Nexus, а всякое старое и странное вывести из эксплуатации.
Грабли, костыли, растяжки
Итак, мы с энтузиазмом приступили… Но тут же начали наступать на всякие грабли и биться о подводные камни.
SQL
Никто не любит SQL, а редкий диалект — совсем никто. И если структуру хранения исходников нам удалось подглядеть, то процесс трансляции исходников в скрипты наката, БД разработчика, обеспечение синхронизации приемника с источником, совместимость версий серверов БД пришлось изобретать самим. И начальная идея про «просто склеить файлы из Git — и готово!» со временем превратилась в транслятор SQL c парой фронтендов и тройкой бэкендов.
При создании транслятора познакомились с синтаксическим разбором сначала с помощью sly: сложно в разработке, LR-грамматики, shift-reduce conflicts, много теории. Классика, в общем.
Затем попробовали pyparsing с его подходом Parsing Expression Grammar — документации, скажем так, немного, теории еще меньше. После sly он оказался очень простым — без подробнейшей документации вполне можно работать, легко тестируемым — каждую конструкцию можно тестировать отдельно, затем из мелких собирать крупные; много заготовок, которыми снабдили пакет авторы. За комфорт пришлось заплатить скоростью разбора: она в пять раз меньше, чем у sly. В выборе sly vs pyparsing нас поддержало известие, что разработчики CPython тоже переходят на PEG. Похоже, что сложность классического подхода оказалась слишком большой не только для нас.
И снова SQL
При первых же установках на целевых БД начались странности с блокировками и поведением метаданных. Пришлось подкрутить параметры транзакции БД. После этого начались блокировки обычных данных. Пришлось ещё раз подкрутить. Начал падать сервер. Пришлось подкрутить, помимо транзакций, ещё и установщик.
Плюс очередной разбор ошибки разрушил мечту о накате в одну транзакцию, чтобы можно было вернуть БД в состояние «как было» просто оператором rollback. Некоторые операции, оказалось, требуют обязательного подтверждения транзакции, но их, к счастью, немного.
В общем, сплошной компромисс мечты и реальности.
RPM
Удивление вызвало качество документации для RPM. Толкового, удобного, исчерпывающего руководства по созданию RPM, особенностям работы одноименной утилиты почему-то нет. Похоже, что раз дорос до создания RPM, то разберешься как-нибудь. Вот несколько удивительных вещей:
Нет понятия «обновить». Есть только установить и удалить.
При обновлении сначала выполняется preinstall нового пакета, затем postuninstall старого. В результате можно расконфигурировать что-нибудь только что законфигурированное.
Доступ к файловой части из pre-скриптов отсутствует.
Отличить в RPM-скриптах обновление от установки и удаления можно только по параметру командной строки.
Обращаться к RPM-командам изнутри RPM-скриптов нельзя.
Остановить или хотя бы сообщить о неудаче из post-скриптов нельзя.
Гарантированный интерпретатор скриптов — Lua. Остальные могут и отсутствовать в целевой системе.
rpmbuild временами слишком интеллектуален: то зависимость ненужную добавит, то откомпилирует что-нибудь по собственной инициативе
Миллион rpm-макросов, которые позволяют творить и, что ещё важнее, отключать магию любого цвета, документирован только в вопросах на stackoverflow.
YUM/DNF
В этом приложении тоже немало удивительного. Может быть, это как-то связано с RPM. Итак, удивительно, что:
С документацией на внутреннее устройство — беда-беда. Самый дельный пункт в ней — «смотрите код, может быть, поможет».
YUM и DNF изнутри совсем-совсем разные.
Есть много точек, куда можно подключить свой код для изменения поведения YUM на всех этапах его работы. Но единственная документация — исходники других плагинов и самого YUM.
DNF менее гибок, чем YUM.
Алгоритм выбора версии зависимости не параметризуется: всегда берем максимальную версию.
Нет команды «поставь уже, наконец, именно эту версию». Нужно думать, это первая установка, апгрейд или даунгрейд, и выбирать правильную команду.
Вывод команды RPM буферизуется до ее завершения. Если RPM-скрипт что-то пытается спросить у пользователя, то пользователь получит намертво зависший YUM.
YUM хранит свои плагины в специальном каталоге «плагины yum», DNF хранит свои плагины в пакетах системного Python. Как результат, просто поставить плагин для DNF простым RPM нельзя. Нужно сначала узнать, какой Python нынче системный и где же его пакеты. Природа и правда отдыхает на детях?
Библиотека разрешения зависимостей в DNF написана на С. Потыкать в нее отладчиком из Python не выйдет.
GitLab&Runner
GitLab сам по себе вещь очень гибкая и потому непростая, а в связке с runner легко ставит сложные задачи на почти ровном месте:
Обширная, подробная, но запутанная документация. Возможно, нужно просто привыкнуть, почувствовать логику.
Сложная конфигурация триггеров на запуск конвейеров сборки. Несмотря на документацию с примерами и объяснениями, настроить запуск так, как мы хотели, получилось далеко не сразу.
Оказалось невозможным указать произвольную оболочку командной строки — bash под Windows так и не взлетел.
Shell executor deprecated. Как же так?
runner иногда умеет терять свои служебные файлы с ключами. Не смертельно, но неприятно.
Странно работает сворачивание логов выполнения CI/CD.
Gradle
От Gradle отказались практически в самом начале и ни разу не пожалели. Помимо того, что это инструмент из параллельной вселенной Java, он тоже оказался со странностями:
Виртуальная машина Java использует свою версию доверенных сертификатов — системные сертификаты ей не указ. Соответственно, если вам нужен https на корпоративных ресурсах, то нужны пляски.
Временами Gradle требует пустить его в интернет, что тоже может быть проблемой.
Gradle умудрялся сам себе поставить блокировку на файл и падал, требуя теплых человеческих рук.
Плагин, занимавшийся работой с Python, недавно официально забросили.
Часть вторая. Заглянем под капот
Используемые технологии
Разнообразие каналов взаимодействия и долгая история развития обуславливает разношёрстные подходы и технологии. Вот некоторые из них:
Java, Python, С/С++ — обмен данными между информационными системами как внутри магазина, так и за пределами.
С/С++, Delphi — десктопные приложения, используемые администрацией магазина. Их можно заметить в некоторых магазинах малых форматов.
FastReport, Reporter — создание, просмотр, сохранение, печать отчётов. Ценники на полках — самый заметный для покупателей результат работы этих технологий.
JS/TS фреймворки (Vue, React, ExtJS, …) — web-клиенты для десктопных браузеров.
Java/Kotlin — приложения для терминалов сбора данных (ТСД). Это «прокаченные» Android-смартфоны с аппаратными сканерами штрихкодов, клавиатурами, устойчивые к пыли, воде, ударам. Терминалы — те самые «пикалки», которые можно увидеть в руках работников в торговом зале.
HTML, JS — клиентская часть ТСД, работающая внутри модифицированного браузера. В частности, фронт пункта проверки цен (прайсчекеров) реализован с использованием этой технологии.
Java, Python — серверная часть web-клиентов.
Firebird SQL — база данных. Практически любая активность сотрудников и покупателей находит в ней свое отражение.
На уровне операционных систем тоже разнообразие: CentOS и Rocky различных версий на серверах, Windows 10 на пользовательских ПК, Android различных версий на терминалах.
Сборка SQL
С точки зрения разработчика
Для сборки SQL разработчик должен соблюдать файловую структуру хранения SQL-объектов. Например, определения таблиц должны храниться в sql/tables/, а триггеров —sql/triggers/.
Для корректной установки в целевую БД часто требуется обновлять не только метаданные, определяющие структуру БД, но и производить некоторые действия с пользовательскими данными. Например, до включения ограничения NOT NULL на таблице требуется как-то обработать существующие строки с NULL-ами.
Для решения подобных задач мы создали искусственный тип SQL-объектов — «блок». Блоки выполняются при установке обновления в контексте целевой БД. В файле, помимо непосредственно объявления объекта (например, create table…), также хранится дополнительная информация: права, индексы, ограничения целостности. Хотя с точки зрения SQL-сервера индекс или право являются отдельными объектами, мы приняли решение трактовать SQL-объект чуть шире. В остальном для файловой структуры работает принцип «один файл — один объект».
Кроме непосредственно SQL-объектов, важный аспект — зависимости от других проектов, которые декларируются в файле описания проекта build/project.json. Например, можно задекларировать требуемую версию базового проекта system либо конфликт с другим проектом. Информация о зависимостях позволяет явно связать доработки нескольких проектов, выполненных при реализации одной бизнес-задачи.
Наконец, есть файл с инструкцией по сборке проекта build/sql/build_full.py. Его главная функция — определить порядок выполнения SQL-блоков относительно друг друга и изменений метаданных. Это Python-скрипт с расширенным пространством имён, заточенным под описание шагов сборки. Например, все таблицы проекта можно собрать, написав table_all ().
Помимо удовлетворения нужд автосборки, описанная машинерия может быть применена локально на компьютере разработчика. Одной командой можно скопировать SQL из локальной копии репозитория проекта в БД и IDE разработки или обратно, что значительно снижает риск что-то забыть, перепутать или испортить.
С точки зрения автосборщика
Сборка SQL происходит в два шага. На первом шаге сборщик по инструкции и декларации зависимостей создаёт единый файл наката. Этот файл не является просто склейкой отдельных SQL-файлов проекта. Для каждого файла из инструкции производится синтаксический разбор и преобразование в «установочную» форму. Эта форма служит для сокрытия различий между версиями серверов БД: у нас их два, и они в некоторых деталях несовместимы.
Сборщик также обеспечивает приведение БД к виду «как у разработчика». В частности, добавляет в установочный SQL-скрипт функции удаления объектов, удалённых при разработке. В начало скрипта добавляется блок, контролирующий соблюдение задекларированных зависимостей, в конец — сохранение версии устанавливаемого проекта.
Второй шаг создает RPM-файл для установки через пакетного менеджера. Этот файл умеет проверять соблюдение SQL-зависимостей, соединяться с целевой БД и выполнять установочный SQL-скрипт.
Сборка Python
С точки зрения разработчика
Для сборки Python-модулей файловая структура должна соответствовать требованиям сборщика: исходный код модулей должен находиться в yggdrasil/MODULE_NAME, там же находятся файлы настроек линтеров, декларации зависимостей — requirements.txt, rpm_requrements.txt. Перечень собираемых модулей находится в build/project.json. В остальном структура модуля очень похожа на пакет (package), но им не является.
Две декларации зависимостей нужны для совмещения требований линтеров, которые требуют указывать все зависимости проекта, и требований к установке модулей, которые делят зависимости на два класса. Первый класс гарантированно установлен в целевой системе и изменять его нельзя, так как он активно используется ядром web-сервера, второй класс нужен (почти) исключительно конкретному модулю. Пакеты второго класса нужно ставить при установке модуля.
С точки зрения автосборщика
Сборка Python-модулей значительно проще, чем SQL-скриптов: не требуются какие-либо сложные манипуляции для перевода модуля из состояния «исходники» в состояние «продукт». Автосборщик по перечню модулей из build/project.json производит тестирование модулей, загружает пакеты-зависимости и создает RPM-файл для установки на целевой системе модуля и его пакетов-зависимостей.
Сборка Delphi
С точки зрения разработчика
Исходники и ресурсы Delphi-приложения храним в delphi/имя_приложения, сборку активируем через build/project.json. Также в поставку можно включить дополнительные файлы (внешние ресурсы), записав их в project.json. Вот такие простые правила.
С точки зрения автосборщика
Delphi — компилируемый язык, соответственно, к сборке добавляется начальный шаг — фаза компиляции, которая выполняется на отдельной Windows-машине и выдает бинарные exe/dll.
На втором шаге exe/dll и дополнительные файлы пакуются в RPM.
Сборка отчётов
С точки зрения разработчика
Чтобы файл попал в сборку в качестве отчета, его достаточно положить в reports/. Никаких дополнительных усилий применять не нужно: оба наших движка отчётов хранят все данные в одном файле, который является и исходником, и выполняемым модулем.
С точки зрения автосборщика
Отчёты выполняются на Windows-машинах и частенько имеют русские имена по историческим, административным и другим причинам. К тому же файлы отчётов устанавливаются на linux-машины, которые применяют кодировку имён файлов, отличную от кодировки, которую ожидает Windows и Samba. Поэтому перед упаковкой отчетов в RPM сборщик «портит» имена файлов, чтобы на целевой системе русские буквы в именах выжили и радовали пользователей.
Объединяем всё под одну крышу
С точки зрения разработчика
Для разработчика существует два с половиной места объединения всего собранного в нечто единое, что мы называем поставкой. Все ранее собранные части объединяются «поставкой» и получают бизнес-ценность.
Первое место, которое определяет, что будет собрано, — файл с настройками конвейеров сборки GitLab .gitlab-ci.yml. Мы его максимально упростили за счёт вынесения скучного и объёмного кода во внешний по отношению к проекту файл. В проекте каждый этап сборки занимает ровно три строки: комментарий, что будет собрано этим этапом; имя этапа; ссылка на внешнюю функцию, непосредственно реализующую этап. Неиспользуемые этапы нужно просто закомментировать. Так, сборка Delphi-приложений довольно редкий гость.
Второе место объединения — шаблон установочного bash-скрипта build/installer.tmpl. По большей части это просто последовательное выполнение команд менеджера YUM/DNF, устанавливающих RPM-пакеты на сервера целевой системы. К сожалению, некоторые проекты требуют специфических действий во время установки и полностью унифицировать этот шаблон и вынести его за пределы проекта (в автосборщик) пока не получилось.
Третье место — файл описания проекта build/project.json. Если проект полностью соответствует типовой структуре, то никаких действий не нужно, всё объединятся автоматически. Но если проект с отклонениями — название проекта и SQL-модуля отличаются, несколько веб-модулей в рамках проекта — эти отклонения нужно описать.
С точки зрения автосборщика
Файл .gitlab-ci.yml определяет, какие функции сборки будут использованы и, соответственно, какие пакеты соберутся в результате. Функциональность по обработке .gitlab-ci.yml полностью реализуется через GitLab и GitLabRunner. Мы только создали правила запуска сборочного конвейера, его стадии, задачи, функциональное наполнение сборочных задач.
Непосредственно в задаче, объединяющей пакеты в поставку, производится обработка шаблона установки в целевую систему build/installer.tmpl. По большому счёту всё сводится к подстановке нескольких переменных (версии, названия и подобных) в текстовый шаблон.
Публикация
С точки зрения разработчика
Собрать и опубликовать проект разработчик может с помощью коммита, содержащего в комментарии команду сборки. По завершении процесса разработчик получает уведомление о результате сборки на почту и в мессенджер.
С точки зрения автосборщика
При удачном исходе сборки автосборщик получает перечень артефактов для публикации из build/project.json и публикует их в корпоративных репозиториях. Помимо «настоящих» типов rpm, pypi, raw, есть пара «виртуальных» — gitlab_tag, etalon. Виртуальные репозитории не хранят установочные пакеты в каком-либо виде, а служат, например, для отметки в системе контроля версий точки и параметров конкретного конвейера.
Инструменты, полученные при автоматизации
При автоматизации сборки, помимо бесценного опыта, мы получили несколько инструментов, упростивших нам решение некоторых задач, исправляющих и расширяющих существующие системные утилиты и библиотеки. Часть этого инструментария заточена под наши процессы, софт, окружение и вряд ли может быть применена где-то ещё. Часть инструментов слабо связана либо вообще не связана с нашей инфраструктурой и обычаями и потенциально может заинтересовать кого-либо со стороны.
Пакет сборочных задач rpmpublic
Выполняет высокоуровневую сборку проекта. Очень сильно привязан к нашей структуре проектов. Именно он выполняет обработку build/project.json, build/installer.tmpl, знает что, куда и как положить, чтобы собрать пакет с отчётами или SQL. Представляет собой Python-пакет с интерфейсом командной строки. Практически все команды без параметров, так как подразумевают типовые действия над типовыми данными. Например, публикация осуществляется командой rpmpublic publish repo/root.
Пакет работы с SQL и БД tdb
Основная функция — работа со всеми представлениями SQL: отдельные файлы в репозитории, скрипт установки, метаданные в БД. Именно этот инструмент позволяет конвертировать SQL-часть проекта между различными представлениями, обрабатывает инструкции сборки SQL, устанавливает доработки в БД разработчика и на целевой сервер.
Инструмент работает только с БД Firebird и требует поддержку со стороны БД в виде некоторых хранимых процедур и пользовательских функций. Представляет собой Python-пакет с интерфейсом командной строки. Имеет много команд с кучей ключей и подкоманд. Например, сборка установочного скрипта из репозитория производится командой tdb script path/to/build_full.py repo-to-setup.
Пакет низкоуровневой автоматизации pycicd
Это сервисный пакет, выполняющий задачи по взаимодействию с окружающим миром, — почта, GitLab, Git, Nexus, TeamCity, pip и тому подобное. Слабо зависит от нашей инфраструктуры и вполне может применяться в других окружениях. Заточен на монопольное использование: никаких многопоточных приложений, это позволило упростить интерфейс пакета. Например, публикация RPM-пакета в репозиторий Nexus выполняется вызовом nexus.push («path_to_rpm», «https://nexus_host/nexus_repo/», «rpm»).
Плагин для YUM/DNF minstall
Этот пакет служит заменой команд install/update/downgrade в зависимости от установленной версии пакета и работает по принципу «минимально необходимой версии» — в отличие от оригиналов, которые используют принцип «максимальная версия из доступных». Плюс добавлен вывод на консоль в реальном времени. В оригинале весь вывод буферируется до окончания установки.
Из-за радикальных отличий внутреннего мира YUM и DNF у нас получились фактически два плагина под крышей одного RPM-пакета. Точнее, три. Помимо непосредственно плагинов, в пакет включена библиотека bash-скриптов с полезными (в нашем окружении) функциями.
Это не история успеха, скорее, история о движении к успеху. Инструменты и процессы продолжают дорабатываться по внутренним и внешним запросам. Идёт медленный перевод проектов и людей на новые рельсы. Некоторые проекты пережили уже десяток полноценных внедрений, как и некоторые люди, некоторым это только предстоит.