В чем смысл?

Это первый пост из цикла, посвященного миграции с go build на Bazel.

К процессу миграции мы подошли на этапе, когда запуск тестов на CI занимал примерно от 15 минут до часа. При этом мы уже успели реализовать некоторое распараллеливание и кэширование результатов тестов. Без этого тесты на одной машине должны были бы идти примерно часов восемь.

После внедрения Bazel запуск тестов на CI в основном укладывается в интервал от 1,5 до 25 минут (50 перцентиль в районе 12 минут), что гораздо комфортнее исходной ситуации.

Оговоримся, что сравнение этих цифр «в лоб» несколько некорректно: с одной стороны, за время пути кодовая база стала еще больше, а с другой — поменялась топология CI. Но в целом представление о полученном эффекте они дают.

Далее опишем, за счет какого механизма достигнуто ускорение.

Что не так с go build?

Всем известно, что в GoLang очень быстрая компиляция. И это действительно так, но есть ряд особенностей, которые крайне негативно сказываются на общем времени сборки.

Особенности тестов в GoLang

При запуске теста можно выделить следующие стадии:

  • генерация кода (можно выполнить один раз на все тесты);

  • компиляция;

  • линковка;

  • выполнение теста.

Особенностью GoLang является то, что на каждый пакет с тестами создаётся исполняемый файл. В результате, каждый тестовый пакет требует время на линковку. Как результат, сборка исполняемых файлов может занимать намного больше времени, чем сами тесты.

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

Пример компиляции пакета “персонально” для теста

Немного исходного кода:

Файл go.mod:

module github.com/bozaro/example
go 1.19

Файл bar/bar.go:

package bar

import "github.com/bozaro/example/foo"

func Bar() int {
	var f foo.Foo
	return f.Bar()
}

Файл foo/foo.go:

package foo

type Foo struct {
}

Файл foo/foo_test.go:

package foo

func (Foo) Bar() int {
	return 42;
}

Файл foo/foo_test_test.go:

package foo_test

import (
	"github.com/bozaro/example/bar"
	"testing"
)

func TestFoo(t *testing.T) {
	bar.Bar()
}

В проекте два пакета: foo и bar.

Для типа foo.Foo объявлен метод Bar() в файле foo_test.go (пакет foo), который является тестовым. В пакете bar есть явное обращение к методу Bar() структуры foo.Bar.

В итоге получается:

  • в файле foo_test_test.go (пакет foo_test) можно использовать пакет bar, так как он собран с тестовыми файлами пакета foo.

  • отдельно пакет bar даже не скомпилируется, так как метод Bar объявлен в тестовых файлах и доступен только при компиляции тестов этого класса.

go test github.com/bozaro/example/... 
# github.com/bozaro/example/bar
bar/bar.go:7:11: f.Bar undefined (type foo.Foo has no field or method Bar)
ok  	github.com/bozaro/example/foo

Кэш компиляции

Если изучить, от чего зависит результат компиляции отдельного пакета в GoLang, то мы увидим, как минимум:

  • тэги сборки;

  • значение GCO_ENABLED;

  • значение GOOS и GOARCH;

  • то, какой тестовый пакет мы в данный момент собираем.

То есть в рамках сборки проекта один и тот же пакет в худшем случае может пересобираться сотни раз.

В рамках go build кэш компиляции есть, но он не всегда работает (на некоторых пакетах у нас два последовательных запуска никогда не получают cache hit) и даже при полном попадании в кэш запуск может занимать несколько секунд.

У go build нет промежуточных результатов

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

Повторно использовать какие‑то артефакты можно, только если спуститься на уровень ниже go build и реализовать сборку самостоятельно.

Генерация кода и go:generate

Мы использовали генерированный код для:

  • protobuf-а;

  • маршалинг-а;

  • mock-ов.

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

Для генерации использовали стандартный механизм go:generate с некоторым обвесом для распараллеливания и кэширования.

У генераторов в GoLang есть ряд проблем.

Команды, объявленные в go:generate, не имеют никакого описания

А именно:

  • у них не определён порядок вызова между файлами;

  • по самой команде ничего нельзя сказать по поводу её входных и выходных данных.

Из-за этого, если возникают зависимость на порядок генерации файлов между пакетами, то всё сразу становится очень плохо.

Генераторы могут зависеть от компиляции

Сами генераторы можно разделить на два вида:

  • генераторы, которые создают код на базе анализа AST-дерева исходного кода – с ними всё хорошо. Они работают очень быстро, но обычно они могут оперировать только ограниченным объемом информации о коде (так, к примеру, работает https://github.com/tinylib/msgp);

  • генераторы, которые компилируют пакет (со всеми зависимостями), для получения информации об исходном коде через reflection (так, к примеру, работает golang.org/x/tools/cmd/stringer). Эти генераторы имеют доступ к более богатой информации о типах, но в этом случае, помимо накладных расходов на компиляцию, мы бонусом получаем зависимость между генераторами разных пакетов.

Проверка кода с помощью go vet

Для статического анализа кода в GoLang существует готовый инструмент: https://pkg.go.dev/cmd/vet

К сожалению, непонятно, как его запускать инкрементально: его можно запустить для одного пакета, но выглядит так, что он пытается этот пакет скомпилировать со всеми зависимостями. Из-за этого не удаётся получить выигрыш в скорости при инкрементальном запуске.

Именно из-за времени выполнения go vet мы получали нижнюю границу времени прогона на CI в 15 минут.

За счет чего Bazel должен быть быстрее?

Bazel является средством сборки проектов общего назначения без привязки к конкретному стеку. Он изначально создавался с расчетом на работу с обширной кодовой базой.

Bazel предоставляет язык для описания и выполнения графа сборки.

Ускорение при этом получается за счет двух основных вещей:

  • агрессивное кэширование;

  • встроенные механизмы для выполнения задач на ферме.

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

Чтобы кэширование было корректным, каждый узел графа выполняется в отдельной песочнице. То есть, если узлу нужно что‑то, что он не объявил как входные данные, то на этапе выполнения этих данных не будет. Справедливо и обратное: если шаг сборки породил данные, которые не были объявлены как выходные, то остальные шаги не увидят этот паразитный вывод.

В терминах Bazel такое выполнение узлов сборки называют «герметичным».

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

Герметичность не является полной: системные утилиты и библиотеки не учитываются. Это может вызывать проблемы, к примеру, после локального обновления C++ компилятора.

Порядок сборки Bazel

При сборке Bazel-у передаётся список собираемых целей. Каждая цель объявлена в своём BUILD-файле.

Bazel идёт по зависимостям из BUILD-файлов и строит граф сборки.

Правила из BUILD-файлов выглядят примерно следующим образом:

load("@io_bazel_rules_go//go:def.bzl", "go_library")

go_library(
    name = "helper",
    srcs = ["helper.go"],
    importpath = "github.com/bozaro/testify-example/example/helper",
    visibility = ["//visibility:public"],
    deps = ["@com_github_stretchr_testify//require"],
)

При этом следует отметить ряд важных моментов:

  • узлом графа сборки является шаг, который получает файлы на вход и порождает файлы;

  • ребрами графа является передача файла из одного шага в другой;

  • одно правило в BUILD-файле может порождать несколько узлов графа или не порождать ни одного вовсе;

  • у правила есть неявный аргумент «конфигурация», и правила с разной конфигурацией обрабатываются отдельно. Например, если есть правило go_library, которое явно или транзитивно используется внутри правил go_binary с разными тэгами, то оно будет обработано несколько раз и породит разные узлы графа.

Построение графа сборки у Bazel выделено в отдельный этап Analyze.

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

Этот подход имеет ряд интересных следствий:

  • на момент построения графа никаких реальных команд не выполняется, как следствие в объявлении правила на Starlark нет конструкции «прочитать файл», хотя «записать файл» можно;

  • для выполнения операции должны быть готовы все входные данные, из-за этого, например, проблематично построить конструкцию вида «не выполняй шаг заливки Docker-образа, если образ уже залит» без загрузки всего образа на сборочный узел из кэша.

Итого

В итоге, мы решили посмотреть в сторону Bazel для того, чтобы:

  • уменьшить время ожидания сборки на CI для разработчиков за счет общего кэша сборки и запуска тестов на сборочной ферме;

  • заменить все костыли, которые мы успели нагородить, для ускорения сборки, на более зрелое решение.

При этом у нас не было никаких иллюзий на тему «простого переезда» . Мы ожидали проблем из-за разного подхода к сборке, как минимум, с:

  • генерацией кода;

  • Go-тэгами;

  • CGO-сервисами;

  • go vet;

  • запуском тестов на ферме (тестам требуется определённое окружение).

Особенно сильно напрягало, что у нас на тот момент не было людей, которые активно работали с Bazel-ом. Понимания того, как это должно выглядеть в итоге, тоже не было, но об этом в следующем посте.

Комментарии (6)


  1. alexac
    00.00.0000 00:00
    +4

    Я в своем проекте в какой-то момент сделал обратную вещь и выбросил bazel в пользу родного тулинга go/docker и обернул это все мэйкфайлами. По факту, мы поняли, что для нашего проекта bazel не решает ни одной задачи, которые решает родной тулинг. Зато добавляет геморой с поддержкой конфигурации сборки и скорее замедляет сборки (у базеля есть заметный оверхед на настройку тулчейна, который на сборке мелких программ в CI очень заметен). В добавок, мы хотели мультиархитектурные образы на выходе (arm64/amd64), а значит было нужно, чтобы у нас работала нормально кросс-компиляция и создание манифеста в конце. А кросс-компиляция в базеле отвратительно работает.


    1. Bozaro Автор
      00.00.0000 00:00

      На сколько я понимаю, Bazel имеет следующие пенальти:

      • время анализа;

      • создание песочницы на каждый шаг;

      • запись и чтение из кэша. В итоге совсем чистая сборка с холодным кэшем действительно идёт чуть дольше. Но цифры сопоставимые.

      Особый плюс Bazel-я проявляется при использовании общего кэша и фермы для сборки.

      С кросс-компиляцией действительно всё плохо, но если не используется CGO, то тут спасает кросс-компиляция в самом Go.

      Ну и интересно, когда именно Вы стокнулись с этими проблемами и какую ферму/общий кэш использовали?


      1. alexac
        00.00.0000 00:00

        Мы не дошли до оптимизации сборки кэшами. Команда решила перенять процессы/инструменты у соседней команды, которая начала работу заметно раньше, и вместе с этим притащили базел. Практически сразу я понял, что все кроме меня воспринимают конфигурацию базеля как магию и не понимают, что там происходит. Вся соседняя команда работала на amd64 и им кросс-компиляция нахер не сдалась. А у нас вся команда работает на arm64 и нам бы сборку под две архитектуры поддерживать. А поддерживать такую сложную штуку просто ради того, чтобы она была (потому что опять же никаких плюсов по сравнению с родным тулчейном go), нафиг не надо. Ну и у нас у каждого отдельно взятого микросервиса сборка/тесты проходят меньше чем за минуту. Нам просто нет нужды пытаться это оптимизировать. С базелем без кэшей оно становится втрое дольше, а настраивать кэши базеля для того чтобы решить проблему которую использование базеля и создало...

        Кросс-компиляция с CGO это ад даже в самом Go, я недавно заставлял это работать с одним из сервисов.


        1. Bozaro Автор
          00.00.0000 00:00

          В нашем случае просто проверка "а код вообще компилируется?" занимала более получаса.
          Мы уже успели навернуть распараллеливание тестов, инкрементальный запуск тестов в зависимости от изменённых тестов, распил самих тестов и время запуска на CI всё еще было очень печальным.

          Вопрос в этой ситуации был в том, развивать собственный инструментарий или заменить его на что-то готовое. После некоторого исследования пришли к выводу, что затащить Bazel будет более разумным.


  1. GooG2e
    00.00.0000 00:00

    А это может помочь в случае, если в проекте есть один большой package?
    Резать на отдельные его нельзя, к сожалению т.к. получается всё устройство кодгеном
    Интересует как раз тоже ускорение тестов и сборки, особенно на локальных машинах, потому что сейчас запустить тесты этого проекта на локальных машинах не всегда возможно


    1. Bozaro Автор
      00.00.0000 00:00

      Гранулярность Bazel-а при сборке Go-проектов - пакет.

      То есть, если в пакете поменялся один файл, то пересобрать придётся весь пакет и, скорее всего, всё от чего он зависит.

      Здесь, как мне кажется, Bazel может дать ускорение только за счет следующих моментов:

      • не нужно будет каждый раз пересобирать зависимости данного пакета;

      • если пакет не менялся, то его тесты могут быть закэшированны;

      • можно распилить тесты на https://bazel.build/reference/be/common-definitions#test.shard_count (грубо говоря, будет параллельно вызываться один и тот же исполняемый файл с разными аргументами, каждый раз выполняя часть тестов пакета);

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

      Ну и переход на Bazel довольно трудоёмок. Самые вкусные плюшки в виде общего кэша и сборочной фермы, мы так и не смогли получить на машинках разработчиков: для этого нужен очень толстый канал до фермы.

      В результате, к примеру, зачастую для проверки компилируемости проще и быстрее закинуть ветку на CI.