golang

Как мы ускорили Golang-тесты на CI

  • среда, 17 апреля 2024 г. в 00:00:16
https://habr.com/ru/companies/sbermarket/articles/806725/

Привет, Хабр! 👋 Меня зовут Александр, я занимаюсь разработкой ПО. В этой статье я расскажу о том, как желание улучшить свой рабочий процесс CI помогло ускорить все golang-пайплайны в PaaS СберМаркета.

В СберМаркете микросервисная архитектура. В качестве CI/CD используется GitLab. На CI гоняются вполне типовые задачи по тестированию, различным проверкам, сборкам образов и т. д. Часть job предоставляется самим сервисом, часть — внедряется в пайплайн сервиса нашей платформой.

Во всех golang-сервисах пишут тесты на свой код. В разных сервисах подход к тестированию немного различается, но сейчас я не буду углубляться в виды тестирования. Общее между всеми сервисами — «в среднем по больнице» тесты гоняются на CI более 5 минут.

Я пришел в СберМаркет в конце августа 2023 года. Глядя на набор Unit-тестов в одном из наших сервисов Odin, я немного расстроился. Каждый раз нужно было ждать на CI больше 6 минут, чтобы узнать, все ли хорошо с тестами. Их в наборе было около 400.

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

Вот этот план:
  1. Разобраться с коннектом к базе данных и DI, чтобы обернуть все приложение тестом и прокидывать коннект сверху из теста в приложение. Это даст возможность изолировать тесты и запускать их параллельно.

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

  1. Посмотреть, как и где заполняется БД в тестах, чтобы разделить таблицы БД на словарные данные и не словарные. Словарные почти никогда не меняются и могут эффективно переиспользоваться между тестами. К ним можно отнести справочники городов, метро, каких-либо статусов и т. д. Остальные данные должны сидироваться изолировано для каждого теста. Это позволит вынести общие словари в bootstrap-фазу и не накатывать/откатывать их повторно в каждом тесте.

  2. Посмотреть, как очищается БД. Обычно это небыстрые TRUNCATE вызовы. Вместо них можно использовать дешевый откат (rollback) транзакции, который возможен благодаря пункту 1.

Но как только я начал локально смотреть тесты, все оказалось куда банальнее :) Локально тесты гонялись в несколько раз быстрее. Проблему нужно было искать со стороны CI и интеграции с CI.

Как освободить себе рабочие часы

Я обратился к руководителю своего подразделения с просьбой выделить мне один день в спринт (две недели) для RnD. В RnD-дни часть рабочего времени я не решаю задачи спринта, а сам собираю фактуру для потенциальной деятельности и — после одобрения сверху — обрабатываю ее. Потом мы в команде выбираем несколько направлений, которые могут быть полезны и перспективны для компании.

В таком формате я эффективно работал техлидом на одном из прежних мест работы. RnD-дни давали немного отвлекаться от рутины и приносить компании новые решения.

Несмотря на плотный квартальный план команды в СберМаркете, я получил целый спринт, чтобы провести конкретно этот RnD и, если будет фактура, сделать MVP для демонстрации.

Куда уходит время на CI? Создание MVP

Я начал изучать, как устроена текущая CI-job с тестами. На момент старта RnD она занимала 6 минут 35 секунд.

  1. За 5-20 секунд запускается executor и запускает наш image с golang и нужными зависимостями внутри, фетчится Git-репозитория и выставляется нужный комит.

  2. Далее запускается scriptчасть самой job, и она занимает 6 минут 20 секунд.

Из этих цифр стало понятно, что CI дает минимальный ожидаемый оверхед. Но сам вывод job в лог в scripts блоке никак не разбивается. Там около 1 500 строчек, которые не особо показывают, куда уходит время и в какой пропорции.

Более 1 500 строчек вывода job в лог
Более 1 500 строчек вывода job в лог

С помощью флага 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 повторными запусками. Это и будет временной штраф на компиляцию тестов.

Схема MVP
Схема MVP

Представил на иллюстрации примерное распределение времени и работы. В идеале блоков с go mod download и build tests не будет совсем (если мы попадаем в кэши на 100%). В реальности там будет небольшая работа, которая достроит диффы кэшей компиляции: они неизбежно будут происходить, так как на каждый коммит исходный код обычно меняется. Дифф кэша модулей может измениться, только если меняется go.mod (go.sum).

Если у вас небольшой дифф между фича-веткой и мастер-веткой (из мастер-ветки мы собираем кэши на прогревах), то работы будет мало. Если большой дифф — работы больше. Но в любом случае это всегда гораздо быстрее, чем работать без кэшей.

Также стоит понимать, что время сильно будет зависеть от числа CPU (GOMAXPROCS). Чем больше логических ядер в системе, тем большее число тестов может компилироваться параллельно. Чтобы ускорить тесты, достаточно результат компиляции переиспользовать между прогонами job.

В моем случае это помогло значительно ускорить job — до 1 минуты 25 секунд. Профит может меняться от проекта к проекту. Забегая вперед, скажу, что на нашем наборе микросервисов он варьируется от 2 до 10 раз.

На этом можно было бы завершить статью, так как вопрос, куда уходит время на CI и что с этим делать, уже раскрыт. Однако надо еще понять, как все это оптимизировать на весь PaaS. Именно об этом дальше.

Портирование на весь 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 ./... с разными параметрами и к ответу на вопрос, работают тесты на данной ревизии кода или нет.

Единая job, чтобы править всеми проектами

Частью кастома пришлось пожертвовать во благо единообразия и перехода на общие 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
Статистика по замене job

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

Например:

  • Все проекты теперь одинаково считают свой coverage в рамках PaaS.

  • Добавили Coberura-отчеты, чтобы просматривать информацию о тестах в GUI GitLab. Не Allure, но лучше, чем ничего.

Отчет о тестах в GUI
Отчет о тестах в GUI
  • Добавили составной report coverage, который позволяет объединить репорты из разных тестовых job, сделать пересечение протестированных подмножеств кода и дать более полную оценку о покрытии.

  • Подсветили в GUI GitLab на странице с code review зеленым или красным цветом информацию, покрыт ли код тестами. Это полезно для ревьюверов:

Подсветка coverage на странице code review
Подсветка coverage на странице code review
  • Создали единое решение и единую поддержку для всех сервисов.

  • Исправили баги, связанные с CI-конфигурацией в проектах.

  • Заменили репортер для stdout в тестах на gotestsum. Это сократило лог job примерно в 10 раз, теперь читать его проще.

  • Стандартизировали работу с ожиданиями сетевых портов зависимых сервисов и накаткой миграций.

  • Создали отображение процента покрытия в виде числа по каждой job с репортом на экране merge request:

Отображение процента покрытия на экране MR
Отображение процента покрытия на экране MR

Runner/executor

Старые тесты гонялись на K8s runner, который мог предоставить executor с разным числом ресурсов. Там одна задача могла выполняться за разное время, превращая график, по которому смотрим динамику улучшения, в американские горки:

Американские горки из-за разных ресурсов на executor
Американские горки из-за разных ресурсов на executor

Разброс был от 100 до 400 секунд в частном сервисе. Если выдавался executor с большим числом ресурсов, тесты могли выполниться в 3 раза быстрее, чем обычно. Глядя на такой график, очень сложно итеративно вносить изменения, делать выводы и что-то улучшать.

Мы с DevOps-инженерами сделали два GitLab-тега: 2 CPU + 2 RAM и 4 CPU + 4 RAM. Второй тег про запас — если где-то очень потребуется делать быстрее. Пока что не потребовалось. Теги сгладили метрики и позволили любые отклонения на графиках считать результатом вносимых изменений. С кэшами в job удалось на самых минимальных ресурсах добиться такой же картины, как на самых мощных executor до оптимизации. Тем самым мы не только сделали тесты более стабильными и предсказуемыми по времени, но и хорошо сэкономили ресурсы в K8s:

Стабилизировали время выполнения job
Стабилизировали время выполнения job

На графике видно, что разброс еще есть, но он сузился (от 100 до 150). Это по большей мере связано с неравномерностью нагрузки на наш K8s-кластер, погрешностью выгрузки кэшей из S3 (от 10 до 30 секунд от времени job). Имея воспроизводимость результата, сильно проще вносить изменения в систему и видеть, как они влияют на результат.

Прогревы кэшей

Подкладывание кэшей в CI помогло нам ускориться. Но кэшам свойственно становиться неактуальными. Чтобы этого не происходило, нужно их периодически прогревать. На старте мы взяли очень простую стратегию по обновлению кэшей. Каждую ночь мы запускаем в каждом проекте через schedule pipeline набор job с нужными нам прогревами:

Конфигурация schedule pipeline
Конфигурация schedule pipeline

Эти job мы также сделали централизованными в PaaS, с возможностью управлять нюансами через ENV. Например, указывать какие-то теги или выставлять go env, вроде CGO_ENABLED. Здесь следует отметить, что кэши билдов капризны к изменению не только некоторых go env, но и версии golang. Мы наступили на эти грабли в ряде проектов, но метрики помогли нам понять, что кое-что идет не по плану. Не зря вложились в observability!

Также мы создали автоматику, которая добавляет запись в schedule для прогона пайплайна по расписанию. При добавлении в PaaS новых проектов не приходится делать это вручную. Ночью никто не гоняет задачи на CI, так что мы никому не мешаем своими прогревами.

В течение дня создаются merge requests, часть файлов изменяется, и компилируется только дифф между кэшами, которые подкладываются с ночного прогона, и текущим кодом. Так же работают изменения в go.mod (go.sum): подкачиваются только изменившиеся инкременты.

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

Подводные камни

  1. Стоит помнить про права на файловую систему. Мы используем заготовленный в проекте кэш для разных job: линтеры, тесты, сканеры безопасности. Каждый image может работать из под своего user/group, что может доставить неудобства при интеграции кэша в CI.

  2. Если вы переиспользуете кэши между разными job, следите за версией golang, обновлениями. Изменение даже патч-версии Go приводит к тому, что кэши сборок перестают переиспользоваться. На кэши могут влиять также флаги, например — CGO_ENABLED .

  3. Если используете cache директиву в GitLab CI, то отключайте флаг ci_separated_caches в настройках Git-проекта через API, чтобы ключ кэша одинаково резолвился для защищенных и не защищеных веток. Либо в настройках GUI в разделе CI/CD:

Настройки в GitLab GUI
Настройки в GitLab GUI
  1. Если будете создавать вторую job для плавного перехода и сбора статистики, как мы, не забудьте перевести все зависимости от старой job в директивах needs/ artifacts.

  2. Высокая цена ошибки. Тестируйте изменения CI-конфигураций. Так как мы инжектим job в проекты из PaaS сверху вниз, есть риск одной правкой сломать CI всем проектам разом. В следующей статье я хочу рассказать как раз об изменениях в CI-конфигурациях.

  3. Проводите замеры после любых изменений. У нас несколько дата-центров, и, как оказалось, кэши из S3 не одинаково быстро скачивались из-за специфики инфраструктуры.

  4. Вникайте в суть каждой job. Я смог ускорить сканер gosec более чем на порядок (с 90 до 5 секунд), упаковав его в golangci-lint. Трейсы по обоим инструментам можно посмотреть в дискуссии на GitHub.

Выводы

Прежде всего — приятно, когда твои идеи находят поддержку и тебе дают время на их реализацию. Это положительно сказывается на мотивации.

Go-задачи стали быстрее более чем в 2 раза. Получили вот такие графики job с тестами для всех проектов PaaS:

Максимальное время выполнения тестов
Максимальное время выполнения тестов
95p выполнения тестов
95p выполнения тестов

Снизилась нагрузка на K8s-кластер для GitLab, так как наши job стали работать на минимальных ресурсах и выполняться быстрее. Значит, эти ресурсы стали доступны для другой полезной работы. Тут, к сожалению, нет конкретных цифр, так как основная цель была ускорить CI, а не разгрузить K8s. Вопросом мониторинга нагрузки на кластер не занимался.

Оптимизация является частью культуры в командах СберМаркета. Помимо плановой разработки, мы всегда стараемся находить хотя бы немного времени, чтобы улучшать наши процессы, подходы к разработке ПО и повышать Developer Experience.

Буду рад вопросам в комментариях :)

Tech-команда СберМаркета ведет соцсети с новостями и анонсами. Если хочешь узнать, что под капотом высоконагруженного e-commerce, следи за нами в Telegram и на  YouTube. А также слушай подкаст «Для tech и этих» от наших it-менеджеров.