Go 构建版本信息入门:把 commit、版本号和构建时间写进程序

讲 Go 程序如何通过 ldflags 注入版本号、Git commit 和构建时间,并在 CLI 或 HTTP 健康检查中展示。

程序上线后,最常见的排查问题之一是:现在跑的到底是哪一个版本?代码仓库里修了 bug,不代表服务器已经部署了。镜像构建成功,不代表线上实例都更新了。把版本号、Git commit 和构建时间写进程序,是一个很小但很有用的习惯。

Go 可以用 -ldflags -X 在构建时注入字符串变量。本文用一个小 CLI 和 HTTP 服务示例讲清楚基本做法。

定义 version 包

先建一个包:

package version

var (
	Version   = "dev"
	Commit    = "unknown"
	BuiltAt   = "unknown"
)

type Info struct {
	Version string `json:"version"`
	Commit  string `json:"commit"`
	BuiltAt string `json:"built_at"`
}

func Get() Info {
	return Info{
		Version: Version,
		Commit:  Commit,
		BuiltAt: BuiltAt,
	}
}

默认值用于本地开发。没有注入时也能运行,但一眼能看出这是开发构建。

用 ldflags 注入

假设模块路径是 example.com/app,构建命令:

go build -ldflags "\
  -X 'example.com/app/version.Version=1.2.3' \
  -X 'example.com/app/version.Commit=abc1234' \
  -X 'example.com/app/version.BuiltAt=2024-07-09T14:00:00Z'" \
  ./cmd/app

-X 后面是完整包路径、变量名和值。变量必须是字符串变量,不能是常量。很多新手把 const Version = "dev" 写成常量,然后发现注入不生效。

在 shell 里可以自动取 Git commit:

COMMIT=$(git rev-parse --short HEAD)
BUILT_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
go build -ldflags "-X 'example.com/app/version.Commit=$COMMIT' -X 'example.com/app/version.BuiltAt=$BUILT_AT'" ./cmd/app

实际项目可以放进 Makefile、CI 脚本或发布脚本里。

CLI 里打印版本

命令行工具可以支持 version 子命令:

func run(args []string, stdout io.Writer) error {
	if len(args) == 0 {
		return errors.New("missing command")
	}
	switch args[0] {
	case "version":
		info := version.Get()
		fmt.Fprintf(stdout, "version=%s commit=%s built_at=%s\n",
			info.Version, info.Commit, info.BuiltAt)
		return nil
	default:
		return fmt.Errorf("unknown command %q", args[0])
	}
}

用户执行:

app version

就能看到当前二进制来自哪个 commit。排查线上问题时,这比猜测部署状态可靠得多。

HTTP 服务里暴露版本

服务可以提供内部端点:

func VersionHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	json.NewEncoder(w).Encode(version.Get())
}

挂载:

mux.HandleFunc("/internal/version", VersionHandler)

这个端点是否公开,要看你的安全策略。版本号和 commit 可能暴露部署节奏,通常更适合放在内网、需要认证的内部路由,或者只写进启动日志。

启动日志也有价值

服务启动时打印:

info := version.Get()
log.Printf("starting app version=%s commit=%s built_at=%s",
	info.Version, info.Commit, info.BuiltAt)

当你翻日志时,可以确认某个时间点启动的是哪个版本。如果多实例滚动发布,日志还能帮助你判断是否所有实例都更新完成。

和 Go build info 的关系

Go 还可以通过 debug.ReadBuildInfo 读取模块构建信息:

func PrintBuildInfo() {
	info, ok := debug.ReadBuildInfo()
	if !ok {
		return
	}
	fmt.Println("go version:", info.GoVersion)
	for _, setting := range info.Settings {
		if setting.Key == "vcs.revision" {
			fmt.Println("revision:", setting.Value)
		}
	}
}

这能读到 Go 版本、模块依赖和部分 VCS 信息。ldflags 的好处是你可以定义自己需要的发布版本、构建时间、镜像标签等。两者可以配合使用。

测试默认值

版本包通常不需要复杂测试,但可以测结构:

func TestVersionInfo(t *testing.T) {
	info := version.Get()
	if info.Version == "" {
		t.Fatal("version should not be empty")
	}
}

不要在单元测试里依赖真实 Git commit。测试应该能在没有 .git、没有 CI 环境变量的地方运行。构建注入属于发布脚本的验证范围。

常见问题

第一,包路径必须写完整。-X version.Version=1.2.3 通常不对,除非你的包路径真叫这个。

第二,变量不能被编译器优化成常量。用 var,不要用 const

第三,值里有空格时要注意 shell 引号。构建时间建议用 RFC3339,不要用包含空格的格式。

第四,本地开发不一定每次都注入。默认值应该能让程序启动,同时提醒你这是 dev 构建。

在 CI 里固定格式

版本注入最好由 CI 统一完成,而不是每个开发者手工敲命令。比如发布脚本可以规定:

VERSION="${VERSION:-dev}"
COMMIT="$(git rev-parse --short HEAD)"
BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

go build -ldflags "\
  -X 'example.com/app/version.Version=${VERSION}' \
  -X 'example.com/app/version.Commit=${COMMIT}' \
  -X 'example.com/app/version.BuiltAt=${BUILT_AT}'" \
  -o app ./cmd/app

格式固定后,日志、监控和内部接口都能稳定解析。不要今天写 build_time,明天写 builtAt,后天又改成自然语言时间。机器读的信息要尽量稳定。

如果构建产物是容器镜像,也可以让镜像标签、应用版本和 commit 对齐。出了问题时,你可以从 HTTP /internal/version 查到 commit,再对应到镜像和仓库提交。这个链路一旦打通,回滚和审计都会轻松很多。

不要把版本信息当权限边界

版本端点可以帮助排查,但它不应该暴露敏感配置。不要顺手把数据库地址、环境变量、访问密钥一起返回。健康检查和版本检查应保持克制,只给运维需要的信息。

公开服务里,如果担心 commit 暴露内部节奏,可以只在认证后的内部端点展示完整信息,公开端点只展示语义化版本号。版本信息是观测能力,不是越详细越好。

小结

Go 程序可以通过 -ldflags -X 在构建时注入版本号、commit 和构建时间。把这些信息放进 version 包,再通过 CLI、内部 HTTP 端点或启动日志展示,可以显著降低部署排查成本。

版本信息不是华丽功能,但非常实用。上线后能准确回答“现在跑的是哪份代码”,很多问题就已经解决了一半。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页