go build 时注入编译信息

场景

该公司线上运行的Go服务存在多个版本
时间:某天凌晨
事情:线上Go服务突然间 crash
紧急处理:重启Go服务
故障排查:查询日志,找出可能出现的堆栈信息以及追溯源码

问题:线上同时存在多个版本,如何知道当前 crash 的程序属于哪个版本?

添加版本信息的两种方案

方案1,手动添加版本信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"flag"
"fmt"
)
// 下面三个变量,每次发版都要修改
var version = "v0.0.1" // 程序版本号
var gitTag = "v0.0.1" // git tag 号
var dateTime = "2021-08-14 10:00:00" // 编译生成时间

func main() {
debugVerInfo := flag.Bool("ver", false, "show app version info")
flag.Parse()

if *debugVerInfo {
fmt.Println("version is:", version)
fmt.Println("dateTime is:", dateTime)
fmt.Println("gitTag is:", gitTag)
return
}

fmt.Println("do other thing")
}

由于手动在代码中添加版本信息,所以在排查时可以查看到对应信息。

1
2
3
4
➜  code ✗ ./client -ver  
version is: v0.0.1
dateTime is: 2021-08-14 10:00:00
gitTag is: v0.0.1

分析:
在很多公司甚至开源项目都会采用该方式,在代码中显式的添加版本等信息。

  1. 假设不经常发版或者发版周期比较长,则完全没问题
  2. 假设发版频繁,很大概率会出现版本信息的遗漏、错误
  3. 假设版本信息忘记更改,则查询出来的信息就是错的
    针对以上情况,提出一个问题:Go是编译型语言,版本等信息是否可以在编译时,自动的打包到二进制文件中?

方案2,自动打包版本信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"flag"
"fmt"
)

var version = "v0.0.0"// 此处暂时只填写大的版本号
var gitTag string
var dateTime string

func main() {
debugVerInfo := flag.Bool("ver", false, "show app version info")
flag.Parse()

if *debugVerInfo {
fmt.Println("version is:", version)
fmt.Println("dateTime is:", dateTime)
fmt.Println("gitTag is:", gitTag)
return
}

fmt.Println("do other thing")
}

在编译时,打包版本等信息到Go的二进制文件中:

1
go build -ldflags "-X 'main.version=v0.0.1' -X 'main.dateTime=`date +%Y-%m-%d %T`' -X 'main.gitTag=`git tag`' " -o client

build 通过 -ldflags 的 -X 参数可以在编译时将值写入变量

变量格式:'包名称.变量名称=值'

最终查看版本信息

1
2
3
4
➜  code ✗ ./client -ver  
version is: v0.0.1
dateTime is: 2021-08-14 10:00:00
gitTag is: v0.0.1

优点:

  1. 无需代码中显式添加版本等信息
  2. 避免手动添加版本信息时,遗漏或者错误等情况发生
  3. 可使用持续集成工具自动把版本等信息打包到二进制文件中

原理

二进制文件在加载到内存中之后,整个内存空间会被划分为若干段。除了代码区、数据区、堆、栈,还有有一个段为符号表。

在编译时,把版本等信息打包到符号表中,供程序运行时使用。

1
2
3
4
5
6
7
8
9
[root@localhost demo]# readelf -s client | grep main
......
1686: 00000000005608b0 16 OBJECT GLOBAL DEFAULT 10 main.version
1687: 00000000005608a0 16 OBJECT GLOBAL DEFAULT 10 main.gitTag
1688: 0000000000560890 16 OBJECT GLOBAL DEFAULT 10 main.dateTime
......
2320: 00000000004eb2e8 7 OBJECT GLOBAL DEFAULT 2 main.version.str
2321: 00000000004ebba0 20 OBJECT GLOBAL DEFAULT 2 main.dateTime.str
2322: 00000000004eb2e0 7 OBJECT GLOBAL DEFAULT 2 main.gitTag.str

使用 readelf -s命令查看编译好的Go二进制文件符号表信息,可以明显看到在编译时写入的三个变量。

其中,main.versionmain.gitTagmain.dateTime 大小都为16,是指 在Go中的string类型结构体大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(gdb) ptype version
type = struct string {
uint8 *str;
int len;
}
(gdb) ptype dateTime
type = struct string {
uint8 *str;
int len;
}
(gdb) ptype gitTag
type = struct string {
uint8 *str;
int len;
}

不知细心的你是否发现,在符号表显示的变量具体值 main.version.strmain.dateTime.strmain.gitTag.str长度都比实际多一个字节。

虽然目前Go实现了自举,但是编译Go编译器的编译器还是用C语言写的
C语言字符串(字节数组)是非安全类型,使用尾零来标识字符串结束。其中,尾零也占用一个字节。
尾零是 ASCII 的第一个元素0, 即:NUL

1
2
3
4
5
6
(gdb) p version
$1 = "v0.0.1"
(gdb) p dateTime
$2 = "2021-08-13 23:26:44"
(gdb) p gitTag
$3 = "v0.0.1"

注意事项

包名称.变量名称=值 一定要使用引号(一般是单引号)包括起来,否则如果值存在空格,就会导致一些问题。

不加引号,执行编译脚本,可能输出报错信息提示:
问题见 https://stackoverflow.com/questions/55964947/ci-cd-build-failed-with-go-ldflags

1
2
3
4
5
6
7
8
9
# command-line-arguments
usage: link [options] main.o
... //skipped
-extldflags flags
pass flags to external linker
... //skipped
-s disable symbol table
... //skipped
-w disable DWARF generation