Путь миграции с Go Build на Bazel

Картинка для привлечения внимания :)

При поиске решений для сборки больших проектов на Go с завидной регулярностью попадались отсылки на статьи про Bazel, где общий алгоритм действий сводился к следующему:

  1. С помощью Gazelle создаём BUILD-файлы и файлы зависимостей;
  2. 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_repositorys вызывается 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-библиотеки, то это будет гарантированным источником проблем.

comments powered by Disqus