Bazel, stamping, remote cache

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

В 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_library")

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 для того, чтобы:

Само правило на 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-клиента должны быть права заливать данные в кэш сборки.

Тем не менее в нашем случае этот подход работает без особых нареканий.

comments powered by Disqus