Что такое 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 === gogo1.20.1 pathgithub.com/bozaro/bazel-stamping/example modgithub.com/bozaro/bazel-stamping(devel) build-buildmode=exe build-compiler=gc buildCGO_ENABLED=1 buildCGO_CFLAGS= buildCGO_CPPFLAGS= buildCGO_CXXFLAGS= buildCGO_LDFLAGS= buildGOARCH=amd64 buildGOOS=linux buildGOAMD64=v1 buildvcs=git buildvcs.revision=daa3fb74938a476db8bf4b295b01317226780a75 buildvcs.time=2023-02-10T17:03:08Z buildvcs.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 === gogo1.20.1 X:nocoverageredesign buildvcs.revision=f529d5877d4963ef5964363615b48cf066b8f1ef buildvcs.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 исполняемых файлов к удобному для работы виду.
ссылка на оригинал статьи https://habr.com/ru/articles/720792/
Добавить комментарий