Всем привет, меня зовут Александр Данковцев, я lead engineer команды Antimonolith. В этой статье я расскажу, как построен CI/CD монолита Авито. Речь пойдёт про нашу архитектуру стейджинга, pre-receive хуки, то, что из себя представляет сборка и деплой, как устроен прогон автотестов и какие проверки происходят на merge. А ещё рассмотрим after-merge actions.
Перед началом повествования введу пару понятий, которые будут использоваться дальше:
Релиз-кандидат — версия кода, которому предстоит пройти процесс валидации тестов и деплой в продакшн при успехе.
Срез релиз-кандидата — Git-ветка, созданная от мастера в процессе запуска сборки релиза.
Архитектура стейджинга Авито
Начнём со схемы архитектуры стейджинга. Это то, где вращается сам сайт Авито и компоненты, из которых он состоит:
Авито сайт, как и любой другой микросервис в стейджинг-окружении, деплоится в стейджинговый Kubernetes-кластер. У нас есть отдельный namespace avito-site-tests, в котором расположены ресурсы сайта: базы данных, баунсеры, сервис очередей, Sphinx, прочие репликации, всякие демоны. Это сделано для того, чтобы экономить ресурсы в кластере. Непосредственно каждая ветка сайта деплоится в собственный отдельный namespace, и все бэкенды натравливаются на ресурсы, которые расположены в отдельном неймспейсе. Особняком стоит Frontend Delivery Network (FDN), куда сгружается статика сайта.
Эта инфраструктура очень сложно деплоится: раскатить весь стейджинг одной кнопкой нельзя. Поэтому стейджинг поделен на Helm-релизы.
Отдельным релизом мы катим сами ресурсы, потому что это требуется очень редко. Ресурсы — это база данных, Sphinx, Redis-ы, RabbitMQ. Также отдельно по срезу релиз-кандидатов или по требованию обновляются демоны монолита. На схеме это PGQ daemons, DataBus consumers и QaaS consumers. Мы вынесли их в отдельный деплой, чтобы перераскатка не пересоздавала базу данных, потому что база достаточно большая (около 10 Гб) и её пересоздание — это сброс всех данных, созданных в процессе её работы, т. е. пропадут все созданные объявления и прочие ресурсы. Мы получим неконсистентное состояние с другими сервисами.
Деплой ресурсов стартует в 7 утра, когда все разработчики ещё спят, и к утру у нас есть готовая инфраструктура. Это позволяет нам тестировать в том числе и асинрохронное взаимодействие компонентов сайта. После раскатки ресурсов происходит пересоздание релиза кронов, демонов, консумеров.
Каждый сайт из второй колонки схемы деплоится по отдельности. Грубо говоря, это происходит по пушу в ветку: мы закоммитили, запушили, и ветка собралась в отдельный namespace. Отдельно деплоится статика, об этом расскажу позже.
Каждый бэкенд сайта — Kubernetes Pod — состоит из пяти контейнеров:
Nginx — это точка входа плюс некоторая отдача статики.
Php-fpm — сам бэкенд.
Envoy-core, который отвечает за прокси в сервисы, выполняет роль DNS resolve и keepalive. Например, мы можем стучаться по http://localhost:8888/service-item и попасть в service-item. Это всё нужно для большей производительности стейджинга.
Netramesh добавляет контекст в исходящие запросы: заголовок X-Source. Он также выполняет роль динамической подмены upstream-а сервисов по входящему заголовку X-Route. Формат такой: X-Router: src_host:src_port=dst_host:dst_port.
Navigator — межкластерный маршрутизатор, который резолвит ClusterIP в набор PodIP во всех доступных дата-центрах.
Посмотрим на сетевой стек стейджинга. Когда мы запрашиваем произвольную ветку сайта, запрос проходит через фронденд nginx. В нашем случае это какой-то из подмножества avi-http, который отправляет запросы на Ingress. С Ingress мы попадаем в routing-gateway. Routing-gateway занимается подмешиванием заголовка X-Route и дальше прокидывает запрос в API-gateway. API-gateway балансит, куда отправить запрос: либо это будет Avito Site, либо какой-нибудь API-сomposition. API-сomposition — это десктоп-сайт на Node.js или гошный сервис, либо любой другой сервис на любом другом языке.
Quality gates
Когда мы в общих чертах поняли, как выглядит стейджинг, рассмотрим quality gates, которые применяются для Avito Site.
Основные этапы прохождения коммита таковы:
Гитовые pre-receive хуки.
TeamCity: CI/CD прогоняет тесты, линтеры и так далее.
Проверки на merge, флаги и обязательности.
Действия после merge.
Давайте же покоммитим в монолит. Предположим, мы решили что-то поправить.
Pre-receive хуки
Pre-receive хуки помогают проверить, всё ли в порядке с коммитом до его пуша в репозиторий. Таких хуков у нас не очень много.
Мы проверяем, чтобы каждый коммит содержал имя задачи в Jira. Также есть элементарные проверки синтаксиса. Допустим, если где-то точку с запятой поставили не так, pre-receive хук скажет, что допущена синтаксическая ошибка. Мы проверяем и стиль, например, если пропущен нужный пробел между операторами, линтер сообщит об этом и не даст запушить ветку.
Плюс есть проверка на то, что коммит — наш доменный, он принадлежит Авито, а не какой-то левый.
Что происходит в TeamCity
Вот максимально упрощённый граф зависимостей нашего CI/CD, я собрал основные этапы прохождения:
У нас есть всякие схема-чеки, DI-чеки, юнит-тесты, но лишь вершина айсберга. Из всей схемы я рассмотрю самый основной и сложный процесс — прохождение end-to-end тестов от сборки Docker до прогона тестов.
Сборка
Перед тем, как собирать код сайта, мы подтягиваем мастер. Это нужно, чтобы избежать shadow-коммитов и отловить на ранних стадиях ситуации, когда одновременно разрабатывались две ветки, и кто-то замерджил конфликтующую логику. После подтягивания мастера происходит непосредственно сборка и потом пуш образа.
В сборке образа мы используем паттерн build-образ и push-образ. Build-образ — большой и тяжёлый, с кучей зависимостей, сишных библиотек и прочих утилит, которые нужны, чтобы собирать непосредственно код. Для рантайма же этих зависимостей не должно быть. То есть у нас собирается некоторый артефакт на этапе build code, после этого сбилженная статика загружается в Ceph. А остальная часть собранного кода запекается в Docker-образ и позже пушится.
Вот как происходит build code:
Установка composer-зависимостей.
Кодогенерация, например, генерация client-shop.
Сборка DI, у нас используется Symfony.
Сборка фронденда. Собирается npm, собирается Twig в кэши.
Деплой хранимых процедур. Мы получаем свободного юзера, назначаем ему схему, search pass, и накатываем туда хранимки.
Сборка словарей — файловое представление справочных данных из базы или сервисов.
Если всё прошло успешно, последний этап — сборка артефактов, то есть генерация app.toml, swagger, rev.txt. Rev.txt — это идентификатор сборки, окружение, в котором она собиралась, и прочая отладочная информация.
Деплой
Когда образ собрался и запушился, приходит следующая TeamCity сборка — деплой.
Процесс следующий:
Деплой в Kubernetes-кластер.
Валидация доступности хоста: делается curl с небольшим прогрессирующим шагом, пока хост не станет отдавать код 200. Helm предоставляет информацию о том, что он успешно раскатился, проходят health-чеки и так далее.
Регистрация хоста в routing-gateway.
Автоматическая отбивка в Slack, о том, что хост собрался.
Автотесты
Хост собрался, самое время запускать end-to-end тесты. Если говорить простым языком, у нас сначала отрабатывает сборка Build E2E. В отдельный архив собирается папка с тестами и пушится в Artifactory. От E2E-тестов идёт много связей, я приводил их на первой картинке про TeamCity.
Для простоты рассмотрим одну из них — E2E Test Suite. В этом билде настроено, какой тип тестов запускать, допустим, web или api, или заданы какие-то дополнительные параметры, например, относящиеся к конкретному юниту Авито. Этот билд общается с сервисом параллельного запуска автотестов: посылает ему задачу, сервис её исполняет и получает ответ.
Если копнуть немного глубже, то билд, прогоняющий тесты, представляет собой TeamCity meta runner, который через Docker запускает небольшое приложение, где реализован клишный parallel-client. Parallel-client делает запрос в parallel manager и передаёт ему все метаданные сборки: какие тесты к какому юниту относятся, какой артефакт был запушен в Artifactory. Parallel manager, получив результат, перекладывает всё в очередь и отвечает клиенту, что «всё окей, я принял результат». После этого клиент начинает периодически поллить parallel manager на информацию о том, прошли тесты или нет.
Когда задача поставлена в очередь на исполнение тестов, подключается parallel worker. Воркер вычитывает очередь заданий, которые ему поставили для выполнения тестов, и находит в данных мета-информацию о месте хранения этих тестов. В мета-информации есть ссылка на скачивание архива, который на раннем этапе был загружен в Artifactory. Воркер скачивает себе архив с тестами и запускает command для их прогона.
Тесты ходят в Selenium, который ходит по сайту через Firefox, Chrome или любой другой браузер. Тесты также пользуются файловой системой (fs-qa), куда сохраняют скриншоты, html-странички и прочее. На результат выполнения смотрит воркер: для успеха код ответа — 0, для провала — 1.
После этого parallel worker пушит каждый выполненный кусочек задачи в отдельную очередь «результаты выполнения тестов». Эту очередь слушает parallel manager. Когда менеджер получает результаты, он отгружает их в tests reporters backend, где хранятся отчёты о том, что такой-то тест столько-то выполнялся и прочая информация.
Parallel client через cli-команду запрашивает у test reporter backend финальную информацию о том, что все тесты пройдены. В ней отмечено, сколько тестов прошли, например, 150 из 170. На основе этой информации билд становится зелёным или красным. Также после получения этой информации в TeamCity создаётся артефакт со ссылкой на frontend test reporter. Мы можем зайти в него из TeamCity и визуально посмотреть полную отчётность о том, какие тесты прошли и сколько они выполнялись.
Вот как весь процесс выглядит схематически:
Каждый Е2Е-тест создаёт свою коллекцию тестов, а каждая коллекция тестов создаёт свои репорты, и их довольно много. На прогоне ветки для сайта у нас получается семь Е2Е-сьютов, а для релиза их порядка 50 штук. Когда все отчёты собрались и пришли отбивки в stash, мы можем зайти в stash и замерджить ветку.
Проверки на merge
Помимо стандартной проверки на наличие апрува и свалившиеся или не свалившиеся билды, мы используем свой кастомный плагин, который проверяет, можно ли мерджить ветку в мастер.
Merge-плагин анализирует diff, то, какие файлы были изменены, и выставляет флаги для текущего pull request. Если в pull request были изменения PHP-файлов, он ставит backend changes, если были изменения JS или CSS — frontend changes. Маркируются и случаи, когда произошли изменения в папке с Е2Е-тестами, например, buyer test changes. В итоге работы плагина у pull request появляются флаги, что в нём потрогали: frontend, backend и тесты байеров.
Наименование флага | Значение | Условие |
Backend changes | Yes | *.php |
Frontend changes | No | *.css, *.js |
Buyer E2E changes | No | tests/e2e/BuyerTests/* |
DataBase changes | Yes | *.sql |
Также у нас есть карта всех билдов, которые мы хотим валидировать на pull request Avito Site. Но мы не хотим, чтобы каждое изменение в read.me порождало требование пройти 30 проверок. Мы хотим, чтобы если потрогали какой-нибудь текстовый файлик, было наличие только одного апрува. Это достигается тем, что каждая проверка маркируется списком флажков, на которые она должна срабатывать.
Наименование линтера | Список флажков |
UnitTests | Backend changes |
FrontendCI | Frontend changes |
DockerBuild | Backend changes, Frontend changes, Database changes |
BuyerE2E Suite | Buyer E2E changes |
Например, для "BuyerE2E Suite" проверка прогоняется в том случае, если на pull request был выставлен флаг "Buyer E2E changes". Если мы не трогали папку с байерами, то проверки не будут обязательными, на pull request не будет требоваться, что они должны быть зелёными. Если потрогали SQL-ки, значит, обязательно должны пройти интеграционные тесты на базе, и так далее.
Действия после merge
Когда все тесты прошли, все условия соблюдены, все нужные галочки стоят, мы наконец-то можем слить ветку в мастер. Но здесь всё не останавливается. После этого у нас срабатывает механика after-merge.
Её реализует плагин в нашем stash, который слушает изменения на merge и триггерит соответствующие сборки в TeamCity. Прежде всего, он делает удаление хоста, что экономит нам ресурсы: Kubernetes не резиновый. Ceph тоже не резиновый, поэтому после мерджа происходит очистка статики. И в конце плагин снимает регистрацию с routing-gateway.
Теперь всё, мы в мастере.
А что с релизами?
В статье я не стал рассматривать релизы Авито. Они, в принципе, очень похожи на описанный выше процесс. В релизах просто происходит не pull request в мастер, а срез ветки. В остальном смысл тот же: сборка сайта, прогоны Е2Е-тестов, просто их больше, а вместо проверки в stash на merge-check — валидация релиз-инженерами.