
Что такое stamping?
В Bazel есть любопытная фича, позволяющая добавить данные, которые не инвалидируют кэш сборки.
Например, это бывает полезно, чтобы добавить в исполняемый файл информацию о том, когда он был собран и из какой ревизии. Если для времени и номера ревизии использовать stamping, то, когда собранный файл уже есть в кэше, он пересобираться не будет.
То есть мы получаем следующее:
любое значимое изменение соберет файл заново;
внутри файла будет информация, достаточная для того, чтобы заниматься его отладкой (из указанной ревизии можно собрать эквивалентный файл);
при этом не будет происходить лишней пересборки на каждый коммит из-за не влияющих на него изменений, так как номер ревизии не учитывается при поиске в кэше.
В GoLang, к примеру, начиная с версии 1.18, можно получить идентификатор ревизии, от которой был собран файл, через debug.ReadBuildInfo.
Как использовать stamping?
Объявление переменных для stamping-а
Для объявления переменных stamping-а нужно завести исполняемый файл, который запишет в стандартный вывод пары ключ-значение через пробел по одной паре на строку.
Этот файл будет выполняться в корне рабочего пространства.
Например:
#!/bin/sh echo "GIT_COMMIT $(git rev-parse HEAD)" echo "STABLE_GIT_URL $(git remote get-url origin)"
Пользовательские переменные с префиксом STABLE_ будут участвовать в ключе кэширования.
Участвующие в ключе кэширования переменные попадут в файл bazel-out/stable-status.txt , а не участвующие попадут в файл bazel-out/volatile-status.txt.
Для того, чтобы Bazel знал, где находится файл, собирающий пользовательские переменные, файл нужно ему передать через ключ --workspace_status_command= (https://bazel.build/reference/command-line-reference#flag--workspace_status_command).
Любопытно, но при написании этого поста, я обнаружил, что скрипт размещенный в корне рабочего пространства, не работает.
У многих правил stamping работает только при сборке с флагом --stamp.
Пример использования stamping и GoLang
Полный пример доступен на Github.
Минимальное рабочее пространство Bazel для GoLang
Для того, чтобы можно было работать с GoLang в Bazel, создадим три файла.
Пустой файл BUILD.
Файл WORKSPACE (этот фрагмент взят здесь):
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") http_archive( name = "io_bazel_rules_go", sha256 = "dd926a88a564a9246713a9c00b35315f54cbd46b31a26d5d8fb264c07045f05d", urls = [ "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.38.1/rules_go-v0.38.1.zip", "https://github.com/bazelbuild/rules_go/releases/download/v0.38.1/rules_go-v0.38.1.zip", ], ) load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains", "go_rules_dependencies") go_rules_dependencies() go_register_toolchains(version = "1.20.1")
Файл go.mod для того, чтобы можно было сравнить поведение с go build:
module github.com/bozaro/bazel-stamping go 1.19
Скрипт для задания переменных
Создадим простой скрипт, который положит в переменную GIT_COMMIT текущую ревизию кода example/stamping.sh:
#!/bin/sh echo "GIT_COMMIT $(git rev-parse HEAD)"
И, чтобы не передавать имя этого файла при каждом запуске bazel, добавим его в .bazelrc:
build --workspace_status_command=example/stamping.sh
Тестовая программа
Добавил программу для вывода полученных на этапе сборки значений example/main.go:
package main import ( "fmt" "runtime/debug" "strconv" "time" ) var gitCommit string var buildTimestamp string func main() { fmt.Println("Stamping example") if buildInfo, ok := debug.ReadBuildInfo(); ok { fmt.Println("=== Begin build info ===") fmt.Println(buildInfo) fmt.Println("=== End build info ===") for _, setting := range buildInfo.Settings { if setting.Key == "vcs.revision" { fmt.Println("Found go build revision:", setting.Value) } if setting.Key == "vcs.time" { fmt.Println("Found go build timestamp:", setting.Value) } } } if gitCommit != "" { fmt.Println("Found x_defs revision:", gitCommit) } if buildTimestamp != "" { ts, _ := strconv.ParseInt(buildTimestamp, 10, 64) fmt.Println("Found x_defs build timestamp:", time.Unix(ts, 0).UTC().Format(time.RFC3339Nano)) } }
Эта программа делает следующее:
выводит содержимое
debug.ReadBuildInfoкак есть;выводит значение
vcs.revisionиvcs.time, которые передаются средствамиgo build, если он используется;выводит значение переменных
gitCommitиbuildTimestamp, которые в коде нигде не задаются.
Если эту программу запустить через go build . && ./example или, начиная с Go 1.20, через go run -buildvcs=true ., то мы увидим примерно следующее:
Stamping example === Begin build info === go go1.20.1 path github.com/bozaro/bazel-stamping/example mod github.com/bozaro/bazel-stamping (devel) build -buildmode=exe build -compiler=gc build CGO_ENABLED=1 build CGO_CFLAGS= build CGO_CPPFLAGS= build CGO_CXXFLAGS= build CGO_LDFLAGS= build GOARCH=amd64 build GOOS=linux build GOAMD64=v1 build vcs=git build vcs.revision=daa3fb74938a476db8bf4b295b01317226780a75 build vcs.time=2023-02-10T17:03:08Z build vcs.modified=true === End build info === Found go build revision: daa3fb74938a476db8bf4b295b01317226780a75 Found go build timestamp: 2023-02-10T17:03:08Z
То есть, в debug.ReadBuildInfo() появилась информация из текущей рабочей копии Git. gitCommit и buildTimestamp ожидаемо пусты.
Сборка тестовой программы
Добавим правило сборки .go-файла в example/BUILD:
load("@io_bazel_rules_go//go:def.bzl", "go_binary") go_binary( name = "example", srcs = ["main.go"], out = "example", pure = "on", visibility = ["//visibility:public"], x_defs = { "gitCommit": "{GIT_COMMIT}", "buildTimestamp": "{BUILD_TIMESTAMP}", "runtime.modinfo": "\n".join([ " ", "build\tvcs.revision={GIT_COMMIT}", "build\tvcs.time=2023-01-01T00:00:00Z", " ", ]), }, )
В этом правиле примечателен только параметр x_defs:
в переменную
gitCommitзадаётся значение из stamping-переменнойGIT_COMMIT;в переменную
buildTimestampзадаётся значение из stamping-переменнойBUILD_TIMESTAMP.
В данном примере x_defs объявлен непосредственно на go_binary, но его так же можно использовать в go_library и go_test.
Данные для debug.ReadBuildInfo() Bazel сам не заполняет, но, если очень хочется, то их можно задать через runtime.modinfo.
Правда, есть ряд особенностей:
версия Go живёт за пределами
modinfo;в самом значении
runtime.modinfoпо 16 байт с краёв отводятся на различные служебные значения, позволяющие зачитать эти данные снаружи черезbuildinfo.Read(https://pkg.go.dev/debug/buildinfo#Read).
В результате при запуске этой программы мы получим:
bazel run --stamp //example Stamping example === Begin build info === go go1.20.1 X:nocoverageredesign build vcs.revision=f529d5877d4963ef5964363615b48cf066b8f1ef build vcs.time=2023-01-01T00:00:00Z === End build info === Found go build revision: f529d5877d4963ef5964363615b48cf066b8f1ef Found go build timestamp: 2023-01-01T00:00:00Z Found x_defs revision: f529d5877d4963ef5964363615b48cf066b8f1ef Found x_defs build timestamp: 2023-02-27T06:26:16Z
При этом, что важно – если сделать коммит, который не затрагивает данную программу, то пересборки исполняемого файла не произойдёт.
Пример использования stamping и рукописного правила
Полный пример доступен на Github.
Небольшое рабочее пространство
Для примера создадим пустой файл WORKSPACE (в этом случае у нас нет внешних зависимостей).
Добавим генерацию переменных в файл example/stamping.sh:
#!/bin/sh echo "STABLE_GIT_COMMIT $(git rev-parse HEAD)" echo "BUILD_TIME $(date --utc --iso-8601=seconds)"
И добавим правило сборки, которое будет реализовано чуть ниже BUILD:
load("//example:stamping.bzl", "stamping") stamping( name = "hello", src = "hello_template.txt", out = "hello.txt", )
Это правило будет подставлять значения stamping-переменных в шаблон hello_template.txt:
This file was generated from {STABLE_GIT_COMMIT} revision at {BUILD_TIME}.
Реализация правила stamping
Собственно, вся работа будет выполняться довольно простым скриптом на Python example/stamping.py:
#!/usr/bin/env python3 # -*- coding: utf8 -*- import argparse import re def ParseStampFile(filename): with open(filename, 'rb') as f: return ParseStamp(f.read().decode('utf-8')) def ParseStamp(data): vars = dict() for line in data.split("\n"): sep = line.find(' ') if sep >= 0: vars[line[:sep]] = line[sep + 1:] return vars def main(): parser = argparse.ArgumentParser() parser.add_argument("--stamp", action='append', help='The stamp variables file') parser.add_argument("--template", help="Input file", type=argparse.FileType('r')) parser.add_argument("--output", help="Output file", type=argparse.FileType('w')) args = parser.parse_args() stamp = dict() if args.stamp: for stamp_file in args.stamp: stamp.update(ParseStampFile(stamp_file)) template = args.template.read() result = re.sub(r'\{(\w+)\}', lambda m: stamp.get(m.group(1), m.group(0)), template) args.output.write(result) if __name__ == '__main__': main()
Этот скрипт:
получает через аргументы командной строки файл шаблона, файлы со stamping-переменными и имя выходного файла;
зачитывает stamping-переменные в dict;
заменяет в шаблоне переменные через регулярное выражение;
записывает результат в файл.
Никаких python-библиотек за пределами стандартного Python SDK он не использует.
Описание правила stamping
Для реализации правила stamping понадобится объявить дополнительные цели в example/BUILD:
py_binary( name = "stamping", srcs = ["stamping.py"], python_version = "PY3", visibility = ["//visibility:public"], ) config_setting( name = "stamp_detect", values = {"stamp": "1"}, visibility = ["//visibility:public"], )
Они понадобятся внутри реализации правила на Starlark для того, чтобы:
//example:stamping– вызвать ранее созданный скриптstamping.py;//example:stamp_detect– получить значение стандартного bazel-флага--stamp(https://bazel.build/reference/command-line-reference#flag--stamp).
Само правило на Starlark example/stamping.bzl:
def _stamping_impl(ctx): args = ctx.actions.args() args.add("--template", ctx.file.src) args.add("--output", ctx.outputs.out) inputs = [ctx.file.src] if ctx.attr.private_stamp_detect: args.add("--stamp", ctx.version_file) # volatile-status.txt args.add("--stamp", ctx.info_file) # stable-status.txt inputs += [ ctx.version_file, ctx.info_file, ] ctx.actions.run( mnemonic = "Example", inputs = depset(inputs),н outputs = [ctx.outputs.out], executable = ctx.executable._stamping_py, arguments = [args], ) return [ DefaultInfo( files = depset([ctx.outputs.out]), ), ] stamping_impl = rule( implementation = _stamping_impl, doc = "Stamping rule example", attrs = { "src": attr.label(mandatory = True, allow_single_file = True), "out": attr.output(mandatory = True), # Is --stamp set on the command line? "private_stamp_detect": attr.bool(default = False), "_stamping_py": attr.label( default = Label("//example:stamping"), cfg = "exec", executable = True, allow_files = True, ), }, ) def stamping(name, **kwargs): stamping_impl( name = name, private_stamp_detect = select({ "//example:stamp_detect": True, "//conditions:default": False, }), **kwargs )
На что хотелось бы обратить внимание:
все stamping-переменные разворачиваются уже на этапе выполнения правила;
файлы
volatile-status.txtиstable-status.txt, явно фигурируют как выходные данные правила;для обработки флага
--stamp, нужно сделать дополнительные приседания сconfig_setting.
Проверка правила
Для проверки можно выполнить команды:
$ bazel build //:hello && cat bazel-bin/hello.txt This file was generated from {STABLE_GIT_COMMIT} revision at {BUILD_TIME}. $ bazel build --stamp //:hello && cat bazel-bin/hello.txt This file was generated from 7b4e16010330195c58158e59d830ed9cfc789637 revision at 2023-02-27T10:03:58+00:00.
Stamping-переменные по-умолчанию
По-умолчанию stamping всегда предоставляет ряд переменных:
BUILD_EMBED_LABEL(stable) – значение флага--embed_label=...;BUILD_HOST(stable) – имя хоста, на котором инициировали сборку;BUILD_USER(stable) – имя пользователя, который инициировал сборку;BUILD_TIMESTAMP(volatile) – unix time времени начала сборки.
При этом, важно заметить, что на ферме внутри скрипта часто имеет смысл переопределить поля BUILD_HOST и BUILD_USER, иначе смена хоста и пользователя будет провоцировать пересборку шагов, которые использую stamping.
Stamping ломается при использовании внешнего кэша
Важная проблема stamping – он ломается при использовании внешнего кэша.
У Bazel есть несколько кэшей:
кэш графа целей в памяти Bazel-демона;
локальный кэш операций (
$(bazel info output_base)/action_cache);внешний кэш опреаций (
--disk_cache,--remote_cache, сборочная ферма и т.п.).
При этом у локального и внешнего кэша разный ключ кэширования.
В случае с внешним кэшем в ключе кэширования участвуют все входные данные, которые используются для выполнения соответствующего ��ействия, в том числе переменные окружения, командная строка, входные файлы (де-факто ключ кэширования – это хэш от protobuf-описания шага сборки). Файл bazel-out/volatile-status.txt так же является входным файлом и его содержимое начинает влиять на ключ кэширования.
В результате при использования внешнего кэша и stamping-а, мы всегда получаем новый ключ кэширования: каждое действие сборки, которое использует stamping, всегда идёт мимо кэша.
Крайне неприятно то, что при локальных экспериментах можно получать попадание в локальный кэш и создаётся впечатление, что всё работает так, как нужно. А при сборке на ферме поведение резко меняется на постоянную пересборку.
Как проверить, работает ли stamping и remote cache?
Убедиться в наличии или отсутствии проблемы со stamping и remote cache можно достаточно простым способом:
Собрать файл с включенным
--disk_cacheи--stamp. После этого все данные для сборки должны попасть в дисковый кэш.
Например:bazel run --stamp --disk_cache=/tmp/bazel-disk-cache //exampleСобрать файл с включенным
--disk_cacheбез--stamp. Это действие должно инвалидировать локальных кэш Bazel.
Например:bazel run --disk_cache=/tmp/bazel-disk-cache //exampleЕще раз собрать файл с включенным
--disk_cacheи--stamp. Это действие должно вместо сборки взять ранее собранный файл из дискового кэша.
Например:bazel run --stamp --disk_cache=/tmp/bazel-disk-cache //example
Если после первого и третьего шага будет одинаковый результат – то проблемы с remote cache нет. К сожалению, на данный момент (сейчас актуальная версия Bazel 6.0.0) это не так, и третий шаг гарантированно пересобирает исполняемый файл.
Как подружить stamping и remote cache?
На эту тему в Bazel есть несколько репортов:
Но, к сожалению, корректное решение требует внесения правок во всю цепочку сборки:
надо расширить remote execution protocol, добавив туда возможность передавать данные, которые не должны влиять на ключ кэша действия (сейчас ключ кэша – хэш от самого описания задачи для удалённой сборки);
надо добавить поддержку нового протокола в bazel;
надо добавить поддержку нового протокола на ферме.
В частности, как я понимаю, из-за большого количества действующих лиц, эта проблема не решается на протяжении уже двух лет.
Можно вынести stamping во внешний сервис
В качестве обходного варианта можно вынести логику шага, использующего stamping, во внешний сервис.
В таком случае действие должно получить примерно следующий вид:
на вход получаем
volatile-status.txtи входные файлы, которые необходимы и достаточны для следующего шага;считаем хэш от входных файлов для следующего шага и получаем какой-то идентификатор (назовём его
hash_id);отправляем во внешний сервис
volatile-status.txtиhash_id, а этот сервис возвращаетvolatile-status.txt, который был отправлен в первый раз для этогоhash_id, назовём егоfirst-volatile-status.txt;выполняем следующий шаг с
first-volatile-status.txtвместоvolatile-status.txt.
У этого механизма есть очевидная проблема: он требует модификации всех правил, которые используют stamping. Если какое-то из них забыть поправить или ошибиться в реализации, то корректность работы будет нарушена.
Можно подштопать Bazel
Еще один из вариантов обхода этой проблемы: подштопать bazel, чтобы он при подсчете кэша не учитывал volatile-данные для stamping-а.
К сожалению, в таком случае выполнять эти действия на ферме будет нельзя, но ни что не мешает их выполнять локально.
Заплатку с исправлением Bazel можно взять здесь:
Этот подход то же не без недостатка: у bazel-клиента должны быть права заливать данные в кэш сборки.
Тем не менее в нашем случае этот подход работает без особых нареканий.
И это еще не все!
В следующем посте про Bazel мы расскажем о том, как мы приводили stacktrace собранных на CI исполняемых файлов к удобному для работы виду.
