Что такое 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 исполняемых файлов к удобному для работы виду.