При поиске решений для сборки больших проектов на Go с завидной регулярностью попадались отсылки на статьи про Bazel, где общий алгоритм действий сводился к следующему:
С помощью Gazelle создаём BUILD-файлы и файлы зависимостей;
PROFIT.
К сожалению, эти статьи не дали ответа на два вопроса:
Как, с точки зрения разработчика, должна выглядеть работа с репозиторием после миграции на Bazel?
Как мигрировать на Bazel за несколько шагов, а не одним прыжком?
Как должно выглядеть решение в пределе?
После игр с нано-проектом на пять файлов, пришло осознание, что BUILD-файлы руками писать никто не будет. Помимо этого было совершенно непонятно, как в Bazel управлять зависимостями на внешние библиотеки.
Через какое-то время пазл в голове сложился следующим образом:
Разработчики по-прежнему используют для локальной разработки Go Build и для них работа непосредственно с Bazel не обязательна;
Управление зависимостями остаётся в зоне ответственности Go Build;
BUILD
и.bzl
-файлы, которые формируются на базе исходного кода не хранятся в репозитории и каждый раз генерируются с нуля;Генерируемые
.go
-файлы создаются средствами Bazel;На CI сборка и запуск тестов осуществляется средствами Bazel.
Таким образом для разработчика общий алгоритм выглядит примерно следующим образом:
git checkout
для получения нужной ветки;генерация
BUILD
и.bzl
-файлов;генерация средствами Bazel
.go
-файлов;работа так же, как и до внедрения Bazel.
При этом генерация BUILD
, .bzl
и .go
-файлов выполняется одной командой.
У этого подхода есть очевидный плюс: не нужно переучивать разработчиков и ломать через колено устоявшиеся процессы.
Но есть и очевидные минусы:
на машинах разработчиков нужно установить Bazel (благо при использовании Bazelisk это не выглядит большой проблемой);
генерация
BUILD
и.bzl
-файлов, очевидно, требует времени (в нашем случае, менее 10 секунд);генерация средствами Bazel
.go
-файлов также требует времени (но это время сопоставимо с ранее используемой генерацией файлов);Bazel и Go Build могут порождать исполняемые файлы с разным поведением. Вероятность этого мала, но сбрасывать со счетов её нельзя. В этом случае ничто не мешает разработчику локально запустить сборку и тесты через Bazel.
В крайнем случае, если генерация BUILD
и .bzl
-файлов будет занимать неприлично много времени, можно будет коммитить данные файлы в репозиторий.
Как вскипятить океан?
Даже путь в тысячу ли начинается с первого шага.
Лао-цзы, книга «Дао дэ цзин»
Bazel очень скверно стыкуется с другими системами сборки и изначально выглядит так, что миграция на Bazel бинарна: либо Bazel не используется, либо всё собирается через Bazel. Такой подход плохо ложится на реальность и требуется какая-то этапность.
После некоторых раздумий сформировался примерно следующий план:
делается генерация
BUILD
и.bzl
-файлов для небольшого подмножества исходного кода и запускается параллельно с существующей сборкой. При этом важно, что генерация файлов происходит на базе существующего механизма до Bazel-сборки;реализуется запуск тестов для этого подмножества исходного кода;
к сборке прикручивается ферма;
ищется и реализуется замена
go vet
;генерация
BUILD
,.bzl
-файлов и запуск тестов расширяется до всей кодовой базы репозитория. Старый запуск тестов убирается;постепенный перенос ответственности за генерацию
.go
-файлов внутрь Bazel-сборки;постепенный перенос сборки артефактов для боевых серверов внутрь Bazel-сборки.
Некоторые шаги можно дробить еще мельче или менять местами, но в данном случае важно, что разбивка на этапы позволяет использовать Bazel уже в начале пути, а не увязнуть в попытке объять необъятное.
Краткий список набитых шишек
Не нужно ставить пакеты с Bazel
Достаточно забавно, но устанавливать Bazel через apt
или brew
- плохая идея.
Для установки Bazel лучше всего использовать bazelisk
.
Главное отличие в том, что bazelisk
смотрит на содержимое файла .bazelversion
и запускает ровно ту версию Bazel, которая там указана. Это избавляет от лишней головной боли.
Нужно ли писать свой генератор или стоит использовать Gazelle?
Мы попытались натравить Gazelle на наш репозиторий для генерации BUILD
-файлов. Потом подождали 10 минут. Потом подождали еще 20 минут. Через час после запуска Gazelle никаких видимых изменений не произошло, но ждать надоело: никакого прогресса не было и чем занималась Gazelle было решительно не понятно.
В итоге вместо того, чтобы разбираться с Gazelle, мы решили писать свой генератор.
В нашем случае, помимо проблем с Gazelle были еще аргументы в пользу написания своего генератора:
мы активно используем Go-тэги, а Gazelle на несколько вариантов тэгов не заточен;
мы активно используем генерацию
.go
-кода и дополнительные файлы с манифестами сервисов, а разбираться, как прикрутить этот функционал к Gazelle особого желания не было;у нас уже был опыт написания кода, работающего с
.go
-файлами на базе AST-дерева;написание
BUILD
-файлов не выглядело чем-то сложным;в начале пути Bazel был не единственным кандидатом и нужно было генерировать файлы для нескольких систем сборки.
Тем не менее, Gazelle активно использовался как образец, чтобы понять, какое содержимое должно получиться внутри BUILD
-файлов.
Некоторые проекты используют Bazel и это проблема
Часть внешних Go-библиотек, как выяснилось, уже использует Bazel. Внутри правил go_repository
вызывается Gazelle, который обновляет уже имеющиеся там BUILD.bazel
-файлы.
Итоговая конструкция может быть не совместима с текущим проектом.
Обойти это можно конструкцией вида:
go_repository(
importpath = "github.com/google/tink/go",
patch_cmds = ["find . -name BUILD.bazel -delete"],
...
)
Долгое построение зависимостей из проектов с BUILD-файлами
В Go-проектах BUILD
-файлы часто используются для генерации каких-либо производных .go
-файлов на основе первичных данных. Для go.mod
-зависимостей эта генерация уже не нужна, так как опубликованная версия содержит все нужные .go
-файлы.
Но, тем не менее, эти BUILD
-файлы подхватываются и при сборке выполняется совершенно ненужная работа.
Например, таким образом при использовании пакета github.com/bazelbuild/buildtools проект «заезжает» ненужная сборка goyacc
.
Очень долгая стадия Analyze
Для некоторых пакетов, стадия Analyze занимала очень много времени.
На этой стадии происходит два существенно отличающихся процесса:
выполняется загрузка
BUILD
-файлов для запрошенных целей сборки;выполняется загрузка внешних зависимостей, на которые ссылаются правила для запрошенных целей сборки.
В нашем случае мы получали проблему в следующих местах:
долгое построение
BUILD
-файлов через Gazelle;долгий анализ
go_test
-правил.
Для поиска проблем с Analyze мы при каждой сборке сохраняли профиль через флаг --profile
. Смотреть эти профили можно, к примеру, в Google Chrome через URL chrome://tracing/
.
Долгое построение BUILD-файлов через Gazelle
Мы используем свой генератор BUILD
-файлов, но Gazelle всё равно вызывается внутри правила go_repository
.
Надо отметить, что Gazelle работает достаточно быстро.
Мне известен ровно один сценарий, когда Gazelle зависает на неопределённое время: в процессе определения, какому модулю принадлежит пакет.
К примеру, если в проекте есть ссылка на генерируемый пакет, для которого в текущий момент нет ни одного файла, то Gazelle может попытаться пойти за ним в Internet. Выкачать для него текущий же репозиторий и убедиться, что там ничего нет. Эта активность суммарно может занимать много времени.
Начиная с версии 0.25.0 эта проблема более не актуальна для go_repository
, но при вызове gazelle для текущего WORKSPACE всё еще можно залипнуть на долгом поиске какого-либо пакета.
Об этом можно почитать здесь: https://github.com/bazelbuild/bazel-gazelle#dependency-resolution
Долгий анализ go_test-правил
В один прекрасный момент мы заметили, что локально Bazel требует подозрительно много оперативной памяти.
С помощью команд вида:
export BAZEL=~/github/bazel
export STARTUP_FLAGS="--host_jvm_args=-javaagent:${BAZEL}/third_party/allocation_instrumenter/java-allocation-instrumenter-3.3.0.jar --host_jvm_args=-DRULE_MEMORY_TRACKER=1"
bazel ${STARTUP_FLAGS} shutdown
bazel ${STARTUP_FLAGS} build --nobuild //...
bazel ${STARTUP_FLAGS} dump --rules
Выяснилось, что доминатором с большим отрывом являются go_test
-правила.
При исследовании go_test
-правила был найден следующий комментарий: https://github.com/bazelbuild/rules_go/blob/v0.38.1/go/private/rules/test.bzl#L476-L508
Проблема в том, что если в пакете foo
есть тестовые файлы, как в пакете foo
, так и в пакете foo_test
, то Bazel создаёт ноды графа для пересборки всех пактов, которые нужны для foo_test
и зависят от foo
. Таких пакетов могут быть сотни.
В нашем случае оказалось, что эту фичу довольно часто использовали.
Мы запретили её на уровне генератора и поправили код, убрав её использования. Общее время на стадии анализа сократилось где-то в 1.5 раза. При этом потребление памяти сократилось где-то в 2.4 раза.
Для этого начали генерировать BUILD
-файлы для тестов немного по-другому:
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
# Tested package
go_library(
name = "foo",
srcs = ["foo.go"],
importpath = "github.com/bozaro/foo",
visibility = ["//visibility:public"],
)
# Original tests
# Long build time, but `foo_test_test.go` can use symbols from `foo_test.go`
#go_test(
# name = "foo_test",
# srcs = [
# "foo_test.go", # package foo
# "foo_test_test.go", # package foo_test
# ],
# embed = [":foo"],
# importpath = "github.com/bozaro/foo",
# deps = ["//bar"],
#)
# Internal tests (`foo_test`) package
go_test(
name = "foo_interal_test",
srcs = ["foo_test.go"], # package foo
embed = [":foo"],
importpath = "github.com/bozaro/foo",
)
# External tests (`foo_test`) package
# Fast build time, but `foo_test_test.go` can't use symbols from `foo_test.go`
go_test(
name = "foo_external_test",
srcs = ["foo_test_test.go"], # package foo_test
importpath = "github.com/bozaro/foo_test",
deps = [
":foo",
"//bar",
],
)
CGO
Если у вас есть зависимость на C-библиотеки, то это будет гарантированным источником проблем.
Это не конец!
В следующем посте про Bazel мы расскажем о неочевидных особенностях stamping-а. Пишите в комментариях о своей работе с Bazel – интересно обменяться опытом!
Комментарии (14)
akurilov
00.00.0000 00:00+5А просто распилить монолит нельзя?
Bozaro Автор
00.00.0000 00:00"Монолит" это что?
akurilov
00.00.0000 00:00+1Видимо то, что билдится 40 минут
Bozaro Автор
00.00.0000 00:00Распилить проект и уменьшить связанность кода это благая цель.
Но в данном случае:уменьшение связанности никак не конфликтует с оптимизацией инструментария сборки;
уменьшение связанности кода никак нельзя назвать "простым" процессом.
akurilov
00.00.0000 00:00Достаточно маленькие модули компилятся за секунду или меньше. И не нужно пересобирать все целиком. Распилить монолит - да, непросто, но это - инвестирование в правильном направлении. А тут какое то сокрытие симптомов нездорового процесса
Bozaro Автор
00.00.0000 00:00Вы не представляете масштаб проблемы: распил проекта на маленькие модули требует годы.
Даже если предположить, что проект волшебным образом распадётся на сотни маленьких слабо связанных модулей, то их всё равно нужно будет чем-то собирать.
К тому же сокращение времени на итерацию в CI делает рефакторинг заметно комфортнее.
igor_alt
Простите, а чем этот Bazel так хорош, что нужно так срадать?
Bozaro Автор
Об этом я писал здесь: https://habr.com/ru/company/joom/blog/718340/
Основная цель: сокращение времени сборки в несколько раз (до миграции среднее время на сборку было порядка 40 минут, после 12 минут).
При этом удалось оторвать кучу самопальных костылей и, в целом, сборка стала гораздо надёжнее.
elderos
это ж что там должно происходить чтобы оно собиралось 40 минут О_О
Bozaro Автор
40 минут это был средний результат инкрементальной сборки на 7-ми машинках в параллель. По нашим прикидкам, на одной машинке с холодным кэшем должно было быть часов 15.
В основном это время уходило на линковку тестовых бинарей: каждый пакет с тестами собирается в отдельный исполняемый файл и на это уходит чудовищное количество времени и дискового пространства.
На втором месте собственно исполнение тестов.
miga
Базель как демократия по Черчиллю - плохой, но все остальное еще хуже :)
Помимо всякого кэширования, базелем можно делать всякую мелкую встраиваемую автоматизацию, типа генерации моков (причем на лету). Можно отслеживать прямые и обратные зависимости, включая транзитивные. Плюс оно гвоздями не прибито к конкретному языку, можно одной и той же системой собирать гошечку, жяву и жаваскрипт.
В общем, много чего можно, но и ломает он тоже очень много чего. В контексте голанга, например, совсем ломает gopls и всю идею модулей, так что либо надо городить огороды с поддельным GOROOT, либо использовать
интеллижопуIDE c поддержкой базеля, что гм, не всегда удобно.Bozaro Автор
Я так и не смог понять, как подружить IDE и Bazel...
Собственно из-за этого и пошли по пути, когда BUILD-файлы генерируются на базе исходного кода и go.mod-файлов, максимально повторяя
go build
-сборку.На машинках разработчиков Bazel используется только для создания генерируемых .go-файлов, которые потом раскладываются в рабочей копии. В результате для большинства, кроме этого вызова генератора, Bazel никак не используется.
Эта схема оказалась на удивление удачной:
разработчикам не нужно воевать с Bazel;
IDE работает как работало;
генераторы получают возможность генерировать зависящие от компиляции данные (в нашем случае mock-и);
если нужно, разработчик может использовать Bazel как для сборки, так и для запроса зависимостей через
bazel query
.А вот тезис с поддельным GOROOT я не понял...
miga
Поддельный GOROOT - это когда специально обученный костыль собирает все зависимости указанного пакета (пакетов) и в отдельной директории выстраивает окружения для голанга - создает go.mod, вендорит внешние зависимости, генерит все то, что генерируя базелем итд. В итоге с этим можно работать в любой иде, потому что gopls.
У интеллиж в принципе нормальный рабочий плагин для работы с базелем, умеет все что надо - и тесты запускать, и навигацию. Единственно что оооочень долго синкается проект. А у VSCode когда я в последний раз смотрел, все было достаточно печально с базелем