程序上线后,最常见的排查问题之一是:现在跑的到底是哪一个版本?代码仓库里修了 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 端点或启动日志展示,可以显著降低部署排查成本。
版本信息不是华丽功能,但非常实用。上线后能准确回答“现在跑的是哪份代码”,很多问题就已经解决了一半。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。