Как мы ускорили Golang-тесты на CI
- среда, 17 апреля 2024 г. в 00:00:16
Привет, Хабр! 👋 Меня зовут Александр, я занимаюсь разработкой ПО. В этой статье я расскажу о том, как желание улучшить свой рабочий процесс CI помогло ускорить все golang-пайплайны в PaaS СберМаркета.
В СберМаркете микросервисная архитектура. В качестве CI/CD используется GitLab. На CI гоняются вполне типовые задачи по тестированию, различным проверкам, сборкам образов и т. д. Часть job предоставляется самим сервисом, часть — внедряется в пайплайн сервиса нашей платформой.
Во всех golang-сервисах пишут тесты на свой код. В разных сервисах подход к тестированию немного различается, но сейчас я не буду углубляться в виды тестирования. Общее между всеми сервисами — «в среднем по больнице» тесты гоняются на CI более 5 минут.
Я пришел в СберМаркет в конце августа 2023 года. Глядя на набор Unit-тестов в одном из наших сервисов Odin, я немного расстроился. Каждый раз нужно было ждать на CI больше 6 минут, чтобы узнать, все ли хорошо с тестами. Их в наборе было около 400.
Я подумал, что неплохо было бы разобраться со скоростью. Предположил, что проблема может быть связана с базой данных, неоптимальным сидированием, что можно улучшить очистку и добавить параллелизма. И даже начал накидывать в голове план ускорения...
Разобраться с коннектом к базе данных и DI, чтобы обернуть все приложение тестом и прокидывать коннект сверху из теста в приложение. Это даст возможность изолировать тесты и запускать их параллельно.
💡 Изучите хорошо свои зависимости, прежде чем проецировать это на свой проект. Стоит помнить, что не всегда в сервисе одно хранилище данных, не всегда используются моки, может быть другое шаренное состояние, кроме БД. Не всегда получится просто использовать этот прием без изменений в самих тестах.
Посмотреть, как и где заполняется БД в тестах, чтобы разделить таблицы БД на словарные данные и не словарные. Словарные почти никогда не меняются и могут эффективно переиспользоваться между тестами. К ним можно отнести справочники городов, метро, каких-либо статусов и т. д. Остальные данные должны сидироваться изолировано для каждого теста. Это позволит вынести общие словари в bootstrap-фазу и не накатывать/откатывать их повторно в каждом тесте.
Посмотреть, как очищается БД. Обычно это небыстрые TRUNCATE
вызовы. Вместо них можно использовать дешевый откат (rollback
) транзакции, который возможен благодаря пункту 1.
Но как только я начал локально смотреть тесты, все оказалось куда банальнее :) Локально тесты гонялись в несколько раз быстрее. Проблему нужно было искать со стороны CI и интеграции с CI.
Я обратился к руководителю своего подразделения с просьбой выделить мне один день в спринт (две недели) для RnD. В RnD-дни часть рабочего времени я не решаю задачи спринта, а сам собираю фактуру для потенциальной деятельности и — после одобрения сверху — обрабатываю ее. Потом мы в команде выбираем несколько направлений, которые могут быть полезны и перспективны для компании.
В таком формате я эффективно работал техлидом на одном из прежних мест работы. RnD-дни давали немного отвлекаться от рутины и приносить компании новые решения.
Несмотря на плотный квартальный план команды в СберМаркете, я получил целый спринт, чтобы провести конкретно этот RnD и, если будет фактура, сделать MVP для демонстрации.
Я начал изучать, как устроена текущая CI-job с тестами. На момент старта RnD она занимала 6 минут 35 секунд.
За 5-20 секунд запускается executor и запускает наш image с golang и нужными зависимостями внутри, фетчится Git-репозитория и выставляется нужный комит.
Далее запускается script
часть самой job, и она занимает 6 минут 20 секунд.
Из этих цифр стало понятно, что CI дает минимальный ожидаемый оверхед. Но сам вывод job в лог в scripts
блоке никак не разбивается. Там около 1 500 строчек, которые не особо показывают, куда уходит время и в какой пропорции.
С помощью флага FF_SCRIPT_SECTIONS: 1
можно заставить GitLab GUI в логе выводить время выполнения каждой команды в директивах script
, before_script
, after_script
.
Это дало понимание, что goose
занимает 1-2 секунды, go tool cover
еще пару секунд, а основное время потребляется вызовом go tests ./...
.
Следующим шагом я вынес в отдельную команду перед тестами go mod download
. Она заняла 40-60 секунд. Это время на скачивание go-зависимостей проекта. Около 5 минут по-прежнему занимали сами тесты.
Но куда уходит оставшееся время? Дело в том, что в golang перед запуском тесты тоже компилируются. Так как тесты запускаются на K8s-раннерах в GitLab, на каждый прогон job-тесты компилируются заново. По умолчанию артефакт компиляции создается в файловой системе, в директории go env GOCACHE
.
Понять примерные расходы времени на компиляцию можно локально. Для этого нужно удалить кэш компиляций командой go clean -cache
и запустить локальные тесты. Это позволит добиться воспроизведения кейса с CI и прикинуть разницу между 2 повторными запусками. Это и будет временной штраф на компиляцию тестов.
Представил на иллюстрации примерное распределение времени и работы. В идеале блоков с go mod download
и build tests
не будет совсем (если мы попадаем в кэши на 100%). В реальности там будет небольшая работа, которая достроит диффы кэшей компиляции: они неизбежно будут происходить, так как на каждый коммит исходный код обычно меняется. Дифф кэша модулей может измениться, только если меняется go.mod (go.sum).
Если у вас небольшой дифф между фича-веткой и мастер-веткой (из мастер-ветки мы собираем кэши на прогревах), то работы будет мало. Если большой дифф — работы больше. Но в любом случае это всегда гораздо быстрее, чем работать без кэшей.
Также стоит понимать, что время сильно будет зависеть от числа CPU (GOMAXPROCS). Чем больше логических ядер в системе, тем большее число тестов может компилироваться параллельно. Чтобы ускорить тесты, достаточно результат компиляции переиспользовать между прогонами job.
В моем случае это помогло значительно ускорить job — до 1 минуты 25 секунд. Профит может меняться от проекта к проекту. Забегая вперед, скажу, что на нашем наборе микросервисов он варьируется от 2 до 10 раз.
На этом можно было бы завершить статью, так как вопрос, куда уходит время на CI и что с этим делать, уже раскрыт. Однако надо еще понять, как все это оптимизировать на весь PaaS. Именно об этом дальше.
Имея рабочий прототип на одном проекте, внутри PaaS мы согласились, что 1 минута 25 секунд вместо 6 минут 20 секунд — это отличный результат, поэтому нужно его портировать на все golang-сервисы СберМаркета.
Чтобы измерять результат и видеть профит, пришлось немного позаниматься observability и сделать для себя в Grafana метрики, которые показывают, за какое время выполняются job, с возможностью фильтрации по проекту, конкретной job, статусу выполнения, runner. Без этого невозможно видеть проблемы и держать руку на пульсе. Метрики нам не раз помогли в ходе портирования job, а также помогли показать продуктовым командам, для чего мы вносим изменения в их пайплайны, и убедить их нам содействовать :)
Ранее job с тестами в проекты микросервисов поcтавлялась через «шаблон микросервиса» и определялась в виде yml-конфигурации GitLab CI в репозитории каждого проекта. При этом она наследовала общей тестовой job, которая подключалась к проекту через include:remote
GitLab. Такой подход к организации тестовой job показался мне не лучшим, так как в этом случае любое изменение требует правки конфигураций CI в каждом проекте.
Было решено попутно с ускорением тестов немного изменить подход к конфигурированию тестов в CI и сделать перенос job в PaaS-проект, чтобы можно было централизованно влиять на все проекты, вносить улучшения и исправлять баги из одного места.
За несколько лет часть команд начали самостоятельно менять в своих проектах файл конфигурации CI, который когда-то пришел в качестве шаблона. Это не позволило нам автоматически провести миграцию всех проектов. Кроме того, набор зависимостей в каждом сервисе свой: у кого-то Postgres, у кого-то Redis, S3, ElasticSearch, Kafka, Zookeper и т. д. Кто-то строит себе дополнительно отчеты в формате Cobertura, чтобы выгрузить для GitLab, кто-то гоняет множество job с разными тестами, отделяя их через теги, — и масса прочего кастома от проекта к проекту. Но базовая суть job сводится к запуску go tests ./...
с разными параметрами и к ответу на вопрос, работают тесты на данной ревизии кода или нет.
Частью кастома пришлось пожертвовать во благо единообразия и перехода на общие PaaS-рельсы.
Была создана новая job go:tests
с настройкой GitLab allow_failure: true
для всех существующих проектов, чтобы не аффектить пайплайны на старте и итеративно переходить на нее. В половине проектов из-за их специфики новая job не проходила успешно.
Практически как в TDD, пошел процесс работы с проектами от менее важных к более важным и кристаллизация единой job go:tests
. В CберМаркете более 150 микросервисов на golang. От проекта к проекту меняется специфика запуска тестов (параллельность, дополнительные флаги, зависимости, сбор покрытия, и т. д.). Мы с коллегами постарались сделать типовую job такой, чтобы она покрывала все проекты.
Давали каждому проекту пожить с двумя job несколько недель, чтобы была возможность дать нам обратную связь, собрать новые проблемы и убедиться по статистике CI-прогонов, что после миграции все идет успешно. Потом новая job переводилась в строгий режим для проекта allow_failure: false
и мы еще какое-то время гоняли обе job, но новая уже могла заафектить пайплайн, делая его красным, а не желтым. На последнем этапе мы выпиливали из проекта старую job, так как она больше была не нужна.
На примере одного проекта графики выглядят так:
Такой подход помог безопасно доставить улучшения во все сервисы. Также это дало много побочного профита, кроме ключевого ускорения всех job по тестированию.
Например:
Все проекты теперь одинаково считают свой coverage в рамках PaaS.
Добавили Coberura-отчеты, чтобы просматривать информацию о тестах в GUI GitLab. Не Allure, но лучше, чем ничего.
Добавили составной report coverage, который позволяет объединить репорты из разных тестовых job, сделать пересечение протестированных подмножеств кода и дать более полную оценку о покрытии.
Подсветили в GUI GitLab на странице с code review зеленым или красным цветом информацию, покрыт ли код тестами. Это полезно для ревьюверов:
Создали единое решение и единую поддержку для всех сервисов.
Исправили баги, связанные с CI-конфигурацией в проектах.
Заменили репортер для stdout в тестах на gotestsum. Это сократило лог job примерно в 10 раз, теперь читать его проще.
Стандартизировали работу с ожиданиями сетевых портов зависимых сервисов и накаткой миграций.
Создали отображение процента покрытия в виде числа по каждой job с репортом на экране merge request:
Старые тесты гонялись на K8s runner, который мог предоставить executor с разным числом ресурсов. Там одна задача могла выполняться за разное время, превращая график, по которому смотрим динамику улучшения, в американские горки:
Разброс был от 100 до 400 секунд в частном сервисе. Если выдавался executor с большим числом ресурсов, тесты могли выполниться в 3 раза быстрее, чем обычно. Глядя на такой график, очень сложно итеративно вносить изменения, делать выводы и что-то улучшать.
Мы с DevOps-инженерами сделали два GitLab-тега: 2 CPU + 2 RAM
и 4 CPU + 4 RAM
. Второй тег про запас — если где-то очень потребуется делать быстрее. Пока что не потребовалось. Теги сгладили метрики и позволили любые отклонения на графиках считать результатом вносимых изменений. С кэшами в job удалось на самых минимальных ресурсах добиться такой же картины, как на самых мощных executor до оптимизации. Тем самым мы не только сделали тесты более стабильными и предсказуемыми по времени, но и хорошо сэкономили ресурсы в K8s:
На графике видно, что разброс еще есть, но он сузился (от 100 до 150). Это по большей мере связано с неравномерностью нагрузки на наш K8s-кластер, погрешностью выгрузки кэшей из S3 (от 10 до 30 секунд от времени job). Имея воспроизводимость результата, сильно проще вносить изменения в систему и видеть, как они влияют на результат.
Подкладывание кэшей в CI помогло нам ускориться. Но кэшам свойственно становиться неактуальными. Чтобы этого не происходило, нужно их периодически прогревать. На старте мы взяли очень простую стратегию по обновлению кэшей. Каждую ночь мы запускаем в каждом проекте через schedule pipeline набор job с нужными нам прогревами:
Эти job мы также сделали централизованными в PaaS, с возможностью управлять нюансами через ENV. Например, указывать какие-то теги или выставлять go env, вроде CGO_ENABLED
. Здесь следует отметить, что кэши билдов капризны к изменению не только некоторых go env, но и версии golang. Мы наступили на эти грабли в ряде проектов, но метрики помогли нам понять, что кое-что идет не по плану. Не зря вложились в observability!
Также мы создали автоматику, которая добавляет запись в schedule
для прогона пайплайна по расписанию. При добавлении в PaaS новых проектов не приходится делать это вручную. Ночью никто не гоняет задачи на CI, так что мы никому не мешаем своими прогревами.
В течение дня создаются merge requests, часть файлов изменяется, и компилируется только дифф между кэшами, которые подкладываются с ночного прогона, и текущим кодом. Так же работают изменения в go.mod (go.sum): подкачиваются только изменившиеся инкременты.
Текущая схема очень простая, и она успешно решает свою задачу. В будущем хотим ее немного улучшить и делать прогревы, только если в проекте были изменения, так как есть проекты с низкой активностью. В них мало коммитов и нет нужды собирать кэши каждую ночь.
Стоит помнить про права на файловую систему. Мы используем заготовленный в проекте кэш для разных job: линтеры, тесты, сканеры безопасности. Каждый image может работать из под своего user/group, что может доставить неудобства при интеграции кэша в CI.
Если вы переиспользуете кэши между разными job, следите за версией golang, обновлениями. Изменение даже патч-версии Go приводит к тому, что кэши сборок перестают переиспользоваться. На кэши могут влиять также флаги, например — CGO_ENABLED
.
Если используете cache
директиву в GitLab CI, то отключайте флаг ci_separated_caches
в настройках Git-проекта через API, чтобы ключ кэша одинаково резолвился для защищенных и не защищеных веток. Либо в настройках GUI в разделе CI/CD:
Если будете создавать вторую job для плавного перехода и сбора статистики, как мы, не забудьте перевести все зависимости от старой job в директивах needs
/ artifacts
.
Высокая цена ошибки. Тестируйте изменения CI-конфигураций. Так как мы инжектим job в проекты из PaaS сверху вниз, есть риск одной правкой сломать CI всем проектам разом. В следующей статье я хочу рассказать как раз об изменениях в CI-конфигурациях.
Проводите замеры после любых изменений. У нас несколько дата-центров, и, как оказалось, кэши из S3 не одинаково быстро скачивались из-за специфики инфраструктуры.
Вникайте в суть каждой job. Я смог ускорить сканер gosec
более чем на порядок (с 90 до 5 секунд), упаковав его в golangci-lint. Трейсы по обоим инструментам можно посмотреть в дискуссии на GitHub.
Прежде всего — приятно, когда твои идеи находят поддержку и тебе дают время на их реализацию. Это положительно сказывается на мотивации.
Go-задачи стали быстрее более чем в 2 раза. Получили вот такие графики job с тестами для всех проектов PaaS:
Снизилась нагрузка на K8s-кластер для GitLab, так как наши job стали работать на минимальных ресурсах и выполняться быстрее. Значит, эти ресурсы стали доступны для другой полезной работы. Тут, к сожалению, нет конкретных цифр, так как основная цель была ускорить CI, а не разгрузить K8s. Вопросом мониторинга нагрузки на кластер не занимался.
Оптимизация является частью культуры в командах СберМаркета. Помимо плановой разработки, мы всегда стараемся находить хотя бы немного времени, чтобы улучшать наши процессы, подходы к разработке ПО и повышать Developer Experience.
Буду рад вопросам в комментариях :)
Tech-команда СберМаркета ведет соцсети с новостями и анонсами. Если хочешь узнать, что под капотом высоконагруженного e-commerce, следи за нами в Telegram и на YouTube. А также слушай подкаст «Для tech и этих» от наших it-менеджеров.