Во время поддержки приложений, в особенности если они раскатаны на тысячи машин, в десятках различных версий и конфигураций - важно понимать с чем конкретно мы имеем дело.
Речь именно про вопрос, которым озаглавлена эта статья, который я задаю глядя в терминал, когда вижу нежданную панику или ошибку.
Мы должны мочь узнать какой версии и из какого источника собрано то или иное приложение. И далее речь зайдет о маркировке и версионировании бинарников собранных из go.

По-существу, у нас есть три варианта.

Первый вариант (и самый нереалистичный) - указывать версию вручную, где-то в константах нашего кода.

package main

const (
	version = "1.0.0"
	commit = "abc123"
	date = "2021-01-01"
)

func main(){
  fmt.Printf("version: %s, commit: %s, date: %s\n", version, commit, date)
}

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

А нам важно знать оба этих параметра, потому что без знания коммита с которого собран бинарь мы не можем знать из какого кода он собран и каких версий были при этом зависимости. Время же сборки может подсказать, например, приблизительную версию голанга использовавшегося при сборке, если она не зашита явно (на самом деле зашита, но об этом чуть позже). Ну или, например, явным образом дать понять что у нас в оперирование каким-то образом закралась версия, допустим, трехлетней давности.

Второй вариант - кодогенерация.
Все то же самое, но работает уже не на ручной тяге, а за нас это делает какой-либо билд-скрипт.

package main

import (
  _ "embed"
)

//go:embed .version
var version string

//go:generate echo "$(git describe --tags) $(git rev-parse HEAD) $(date +%s)" > .version
func main(){
  fmt.Printf("version: %s\n", version)
}

В таком варианте нам, нет необходимости что-либо делать вручную, кроме того что один раз добавить дополнительный шаг в последовательность сборки.

go generate ./...
go build . -o /app/my-shiny-binary

Кодогенерация создаст файл .version с версией, коммитом и датой сборки, а сборка бинаря зашьет контент этого файла внутрь с помощью эмбеддинга.
А если наше приложение ставится посредством go install, то и этого делать не нужно - go install сам запустит go generate перед сборкой.

Но на самом деле - ничего этого делать и вовсе не нужно.
Как обычно случается в go - за нас уже подумали на эту тему и сделали почти все что нужно.

Третий вариант - установка строковых переменных при сборке.

После компиляции модулей происходит их линковка, за это отвечает, как ни странно линкер (он же ld). Линкер занимается сборкой всех скомпилированных модулей нашего будущего приложения в, непосредственно, единый бинарь. А еще линкер способен переопределять любые строковые переменные. Вообще он может еще много чего, но сейчас нас интересует его флаг -X.

Зная название пакета и переменной, можно её установить посредством флага -X, даже если она была проинициализирована с каким-то не-нулевым значением. Но сработает это только переменными (var), с константами, например, не канает, потому что константы будут находиться в неизменяемом регионе памяти. А флаг, грубо говоря, лишь добавляет при склейке бинарей инструкций по установке значения этой переменной. Ну а только строковые переменные - просто потому что у нас нет приведения типов (и не нужно?), а CLI он, как бы, текстовый..

Так вот, при сборке бинаря, чтобы передать флаг линкеру используется флаг -ldflags.
Используя код из первого примера, установив значения переменных в "unknown", для явного указания что флаги небыли переданы, собирать приложение надо будет так:

version=$(git describe --tags)
commit=$(git rev-parse HEAD)
date=$(date +%s)
go build -ldflags "-X 'main.version=$version' -X 'main.commit=$commit' -X 'main.date=$date'"

Теперь, на выходе, мы получим бинарь с зашитыми переменными с версией, коммитом и датой сборки. Но помните, я говорил, про явно зашитую версию голанга?
Видите суслика гофера? А он есть!

Дело в том, что линкер, еще и зашивает в бинарь дебажную информацию, которую потом можно получить посредством вызова debug.ReadBuildInfo(). К слову, этот же регион бинаря читается при вызове go version -m, называется он DWARF и, если в целях уменьшения размера бинарника, вы решили его отрезать (флаг -w) - данная информация станет недоступна.
А еще эта информация доступна только для приложений собранных с поддержкой модулей, но на дворе 2025й, так что пойди еще найди приложение которое не использует модули.

Самое интересное для нас в выводе этого метода - версия голанга, целевая платформа и информация о состоянии VCS.
В исходниках го написано немало кода для автоопределения этой информации и даже поддерживается далеко не только гит: mercurial, svn, fossil и bazaar. Единственное требование - наличие программы в системе на которой происходит сборка. Но это врядли проблема, даже в официальном Debian докер-контенере есть git, mercurial и svn, что покрывает, наверное 95% случаев.
Узнать можно ревизию (коммит-хеш), время коммита (бесполезная информация, как по мне) и есть ли изменения относительно последнего коммита (а вот это прям очень полезно).

Единственное, чего там нет, к моему величайшему сожалению - времени сборки. Но это легко правится с помощью -X и -ldflags.
Существует еще, правда, нюанс связанный с версией - версия будет работать лишь для ситуаций, когда на один репозиторий приходится один модуль. В таком случае, если вы следуете конвеншену по теггированию модулей, версия будет автоматически подтягиваться из тегов VCS.
Но у нас в компании, гошка, преимущественно обитает в монорепозитории и конвеншен там "какой-то". И в этом случае так же помогает -X. Уже давно у нас был пакет, который в себе аккумулирует все нужные нам переменные, которые передавались извне, посредством линкера.
А в ходе недавнего рефакторинга и подготовки к выпуску в опенсорс, получился dev.gaijin.team/go/golib/build который использует описанные выше техники - пользуйтесь на здоровье.
Внутри автоматический парсинг полезных данных из дебажной инфы (если она доступна), стрингификация и совместимость со структурированным логгированием.

У нас всё теперь билдится командой go build -ldflags "-X dev.gaijin.team/go/golib/build.version=$(git describe --tags) -X dev.gaijin.team/go/golib/build.buildTime=$(date +%s)". Ну а если нужно докинуть еще что-то оно ложится в мейн и сеттится через линкер.
Более элегантно, на текущий момент, это не решить - требуется перечисление по заранее созданным переменным и ни в какую динамическую структуру данные положить не выйдет.

И на этом, пожалуй, всё. Спасибо что дочитали, надеюсь было полезно.
В комментах можете написать как вы подходите к маркировке и версионированию бинарников.


Кстати, еще у меня есть телега https://t.me/laxcity_lead и ютуб https://youtube.com/@laxcity-lead
Бывает, там даже что-то интересное проскакивает?.

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


  1. manyakRus
    14.05.2025 10:34

    Написано очень правильно и гениально !
    Я сделал почти то же самое скриптами, только не смог уговорить девопсов запускать мой мини скриптик при сборке... теперь скрипт запускаю у себя, он создаёт файл version.txt, всё хорошо только номер версии всегда на 1 меньше настоящего :-(