本文先介绍 Go CGO 的概念和应用场景,以 mattn/go-sqlite3
项目为例,介绍 CGO 程序的静态链接实现方案,其中涉及到动态链接的问题分析、ubunut/centos 系统的静态编译环境搭建,CGO 静态编译遇到的问题和解决方案,最终生成最小化系统环境依赖的静态链接二进制。
Go CGO 程序的编译过程 #
参考:https://www.rectcircle.cn/posts/go-static-compile-and-cgo/
当项目使用 CGO 时,go build
编译过程如下:
-
预处理:首先调用
pkg/tool/linux_amd64/cgo
对 go 源码进行预处理,生成中间源码文件,在上例中,位于/tmp/go-build3000103738/b001
,其中 main.cgo2.c 是 C 语言的源码部分。 -
编译 C 源码:调用系统默认的 C 语言编译器(可通过 CC 环境变量指定),在本例中为 gcc。
- 将 c 源码文件编译成 .o 文件,如 main.cgo2.c 被编译成了 _x002.o。
- 将所有 .o 生成 cgo.o。
-
生成动态链接库信息:调用
pkg/tool/linux_amd64/cgo
根据 cgo.o 生成包含动态链接信息的代码文件,位于 _cgo_import.go。 -
编译 Go 源码:
pkg/tool/linux_amd64/compile
编译 go 源码生成 pkg.a。 -
打包 C 源码的 .o:
pkg/tool/linux_amd64/pack
将 C 源码生成的 .o 打包到 pkg.a 中。 -
写入 buildid:
pkg/tool/linux_amd64/buildid
将 buildid 写入 pkg.a。 -
生成可执行文件(链接阶段):
pkg/tool/linux_amd64/link
为 pkg.a 链接上 Go 源码引入的其他 go package,并生成可执行文件 a.out -
复制产物:将 a.out 复制到目标位置。
通过 ldd 查看生成的二进制,CGO 引入如下动态链接库:
linux-vdso.so.1 (0x00007ffe875d2000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f058bbf5000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f058ba20000)
/lib64/ld-linux-x86-64.so.2 (0x00007f058bc1f000)
CGO 程序的静态链接 #
通过给 go build
命令设置 -ldflags="-linkmode external -extldflags '-static'"
参数来对 CGO 程序进行静态编译:
-
-ldflags
: 传递给 go tool link 链接器的参数; -
-linkmode external
: 使用外部链接器(如 gcc 的 ld)来对 Go 程序进行链接,CGO 程序需要链接 C 程序库,故需要使用该模式;
- 其它的模式包括 internal(使用 go tool link 内置链接器);
- auto(默认):如果是未启用 CGO 或者只使用了标准库的 CGO,则使用 internal ,否则为 external;
-extldflags
: 传递给外部链接器如 gcc 的 ld 的参数,这里的-static
为 gcc ld 的静态链接参数;
- 更复杂的为 ld 传递参数的例子:
GOOS=linux GOARCH=amd64 GO111MODULE=on go build -o exec -ldflags "-linkmode external -extld gcc -extldflags '-static -v -Xlinker -T./module_info.ld'"
通过 go clean --cache && rm -rf main
清理缓存后,然后执行 go build -work -x -ldflags "-linkmode external -extldflags -static" main.go
命令即可观察到上述包含 CGO 的代码的编译过程,和上面的默认编译相比,输出唯一的变化如下:
/usr/local/lib/go/pkg/tool/linux_amd64/link -o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -buildmode=exe -buildid=O0JDvIMHA7Jvz8sJWN7S/vq722mILkfSlVJkLngsl/sc8_MwUXPVexy5egmON-/O0JDvIMHA7Jvz8sJWN7S -linkmode external -extldflags -static -extld=gcc $WORK/b001/_pkg_.a
通过 ldd 查看生成的二进制,没有使用动态链接库。
Go CGO 动态链接和静态链接 #
编译 Go CGO 项目时,生成的二进制是动态链接的,它链接到系统的 glibc 库和依赖的三方库:
# 编译
$ GOOS=linux GOARCH=arm64 CGO_ENABLED=1 go build -o myagent ./cmd/
# 动态链接 ELF
$ file myagent
myagent: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, BuildID[sha1]=f531e4906b378654bb2faa188153cdc56459c96c, for GNU/Linux 3.7.0, with debug_info, not stripped
# 链接系统 libc 库
$ ldd myagent
linux-vdso.so.1 (0x0000ffff8ffdc000)
libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffff8fdd0000)
/lib/ld-linux-aarch64.so.1 (0x0000ffff8ff90000)
动态链接 glibc 库带来常见问题是版本兼容性:各 Linux 发行版一般只能保证大版本的 glibc 兼容,如 CentOS 7.9 和 7.2 的 glibc 是兼容的,但是 CentOS 8.1 和 CentOS 7.9 的 glibc 不一定兼容,也就是在较新 glibc 版本的机器上构建出的二进制可能不能在低版本 glibc 机器上运行(升级内核不会有这个问题),报错:
#ldd /tmp/myagent
/tmp/myagent: /lib64/libc.so.6: version `GLIBC_2.33' not found (required by /tmp/myagent)
/tmp/myagent: /lib64/libc.so.6: version `GLIBC_2.34' not found (required by /tmp/myagent)
/tmp/myagent: /lib64/libc.so.6: version `GLIBC_2.32' not found (required by /tmp/myagent)
/tmp/myagent: /lib64/libc.so.6: version `GLIBC_2.28' not found (required by /tmp/myagent)
linux-vdso.so.1 => (0x00007fff931d2000)
libc.so.6 => /lib64/libc.so.6 (0x00007f6ee76e9000)
/lib64/ld-linux-x86-64.so.2 (0x00007f6ee7ac4000)
解决 glibc 版本兼容性问题的常用方案:
- 静态链接:将 glibc 等依赖库静态链接到可执行程序中,运行时不依赖系统 glibc 库;
- 手动打包:通过 tar 包等机制,将依赖的动态库和程序二进制一起打包,运行时通过
LD_LIBRARY_PATH
环境变量来注入包中动态库;
- 参考:glibc
- 容器镜像:和手动打包类似,但是打包到一个 Docker 容器镜像中;
方案 2 和 3 都有一些局限性和运行环境要求,本文主要讨论静态链接的方案 1。
Go CGO 程序的静态链接 #
Go CGO 程序能否静态链接,取决于使用的 C 部分代码(包括 C 项目本身及其依赖的库,如 glibc、libm 等)是否支持静态链接。
以 x86_64 架构的 fedora:40 编译环境为例(CentOS/RHEL 类似),需要额外安装静态 glibc 库:
# docker run -it -v fedora:40 bash
[root@d8e784ed1d22 tmp]# uname -a
Linux d8e784ed1d22 6.12.5-orbstack-00287-gf8da5d508983 #19 SMP Tue Dec 17 08:07:20 UTC 2024 x86_64 GNU/Linux
# 安装 go 编译工具链和 CGO 编译所需的 gcc
[root@d8e784ed1d22 myagent]# yum install -y go gcc
# 启用 CGO 的情况下,动态链接成功
[root@d8e784ed1d22 myagent]# GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o myagent ./cmd/
[root@d8e784ed1d22 myagent]# ldd myagent
libresolv.so.2 => /lib64/libresolv.so.2 (0x00007ffffffb4000)
libc.so.6 => /lib64/libc.so.6 (0x00007fffffdc0000)
/lib64/ld-linux-x86-64.so.2 (0x00007ffffffc9000)
# 启用 CGO 的情况下,静态链接失败,原因是系统缺少静态 glibc 库
[root@d8e784ed1d22 myagent]# GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o myagent -ldflags="-linkmode external -extldflags '-static'" ./cmd/
# gitlab.mysite.com/paas/myagent/cmd
/usr/lib/golang/pkg/tool/linux_amd64/link: running gcc failed: exit status 1
/usr/bin/ld: cannot find -lresolv: No such file or directory
/usr/bin/ld: cannot find -lc: No such file or directory
collect2: error: ld returned 1 exit status
# 安装 glibc 静态库
[root@d8e784ed1d22 myagent]# yum install glibc-static
# 再次静态编译,可以生成二进制,但是有警告
[root@d8e784ed1d22 myagent]# GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o myagent -ldflags="-linkmode external -extldflags '-static'" ./cmd/
# gitlab.mysite.com/paas/myagent/cmd
/usr/bin/ld: /tmp/go-link-16410204/000034.o: in function `unixDlOpen':
/root/go/pkg/mod/github.com/mattn/[email protected]/sqlite3-binding.c:44707:(.text+0x7719): warning: Using 'dlopen' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
/usr/bin/ld: /tmp/go-link-16410204/000001.o: in function `mygetgrgid_r':
/_/GOROOT/src/os/user/cgo_lookup_cgo.go:45:(.text+0x40): warning: Using 'getgrgid_r' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
/usr/bin/ld: /tmp/go-link-16410204/000001.o: in function `mygetgrnam_r':
/_/GOROOT/src/os/user/cgo_lookup_cgo.go:54:(.text+0xe1): warning: Using 'getgrnam_r' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
/usr/bin/ld: /tmp/go-link-16410204/000002.o: in function `mygetgrouplist':
/_/GOROOT/src/os/user/getgrouplist_unix.go:15:(.text+0x1e): warning: Using 'getgrouplist' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
/usr/bin/ld: /tmp/go-link-16410204/000007.o: in function `_cgo_97ab22c4dc7b_C2func_getaddrinfo':
/tmp/go-build/cgo_unix_cgo.cgo2.c:60:(.text+0x33): warning: Using 'getaddrinfo' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
/usr/bin/ld: /tmp/go-link-16410204/000001.o: in function `mygetpwnam_r':
/_/GOROOT/src/os/user/cgo_lookup_cgo.go:36:(.text+0x186): warning: Using 'getpwnam_r' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
/usr/bin/ld: /tmp/go-link-16410204/000001.o: in function `mygetpwuid_r':
/_/GOROOT/src/os/user/cgo_lookup_cgo.go:27:(.text+0x235): warning: Using 'getpwuid_r' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
上面静态编译打印的警告中,以 /_/GOROOT/src/os/user/
开头的部分来源于 Go 标准库的 os/user
package,这是由于 Go 为这个 package(还有 net package)同时提供了 Go 实现和 CGO 实现, 在启用 CGO 编译参数的情况下会使用 CGO 实现 ,CGO 实现会动态链接系统 glibc 库来进行域名解析和获取用户信息(如上面警告中的 getaddrinfo 是域名解析, getgrname_r 是获得用户 group 名称)。
对于这两个 package,可以通过 go build tags 机制来切换到 纯 Go 实现 :为 go build 添加 -tags 'osusergo netgo'
。
再次静态编译,只剩下一条 sqlite3 相关警告,这是由于 sqlite3 使用了 dlopen 函数,它必须使用动态 glibc 库:
[root@d8e784ed1d22 myagent]# GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -tags 'osusergo netgo' -o myagent -ldflags="-linkmode external -extldflags '-static'" ./cmd/
# gitlab.mysite.com/paas/myagent/cmd
/usr/bin/ld: /tmp/go-link-3263404225/000011.o: in function `unixDlOpen':
/root/go/pkg/mod/github.com/mattn/[email protected]/sqlite3-binding.c:44707:(.text+0x7719): warning: Using 'dlopen' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
查看 go-sqlite3 项目的 sqlite3-binding.c 源码:
// https://raw.githubusercontent.com/mattn/go-sqlite3/refs/heads/master/sqlite3-binding.c
#ifndef SQLITE_OMIT_LOAD_EXTENSION
/*
** Open the dynamic library located at zPath and return a handle.
*/
static void *rbuVfsDlOpen(sqlite3_vfs *pVfs, const char *zPath){
sqlite3_vfs *pRealVfs = ((rbu_vfs*)pVfs)->pRealVfs;
return pRealVfs->xDlOpen(pRealVfs, zPath);
}
可见:在没有定义 SQLITE_OMIT_LOAD_EXTENSION 宏时,会使用 dlopen 动态链接特性。
查看 go-sqlite3 项目源码 sqlite3_load_extension_omit.go,当为 go build 指定了 sqlite_omit_load_extension tag 时会定义这个宏:
// https://github.com/mattn/go-sqlite3/blob/master/sqlite3_load_extension_omit.go
//
//go:build sqlite_omit_load_extension
// +build sqlite_omit_load_extension
package sqlite3
/*
#cgo CFLAGS: -DSQLITE_OMIT_LOAD_EXTENSION
*/
import "C"
所以,解决办法是在 go build 时添加 sqlite_omit_load_extension tag,这样编译 sqlite3 C 源码时将不再启用它依赖的动态链接特性。
再次编译,只剩下 go plugin 相关的 dlopen 警告:
[root@d8e784ed1d22 myagent]# GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -tags 'osusergo netgo sqlite_omit_load_extension' -o myagent -ldflags="-linkmode external -extldflags '-static'" ./cmd/
# gitlab.mysite.com/cmd
/usr/bin/ld: /tmp/go-link-3794635646/000028.o: in function `pluginOpen':
/_/GOROOT/src/plugin/plugin_dlopen.go:19:(.text+0x82): warning: Using 'dlopen' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
go plugin 是 Go 提供的插件机制,查看源码 plugin/plugin_dlopen.go 文件可知,在构建 Linux 系统二进制和启用 CGO 的情况下,会进行动态链接( LDFALGS 中添加了 -dl),所以编译静态二进制时有警告:
// https://cs.opensource.google/go/go/+/refs/tags/go1.22.9:src/plugin/plugin_dlopen.go;l=5
//
//go:build (linux && cgo) || (darwin && cgo) || (freebsd && cgo)
package plugin
/*
#cgo linux LDFLAGS: -ldl
#include <dlfcn.h>
#include <limits.h>
#include <stdlib.h>
#include <stdint.h>
使用 go mod why plugin
命令来查看项目为何会依赖 plugin package:
[root@d8e784ed1d22 myagent]# go mod why plugin
# plugin
gitlab.mysite.com/paas/myagent/pkg/containers/cri
github.com/containerd/containerd
github.com/containerd/containerd/plugin
plugin
可见,项目使用的 containerd/plugin
package 使用了 go 的 plugin 库,所以问题聚焦在关闭 containerd 使用 Go plugin 的特性。
搜索 github.com/containerd/containerd 项目 issue 和源码,最终发现较新的 1.7.x 版本提供了 no_dynamic_plugins tag 来关闭 Go plugin 支持:https://github.com/containerd/containerd/pull/11203
所以解决方案是:升级 continerd 到最新版本(v1.7.25),go build 时添加 no_dynamic_plugins tag。
再次编译,静态编译链接成功:
[root@d8e784ed1d22 myagent]# go get github.com/conptainerd/containerd@latest
go: downloading github.com/containerd/containerd v1.7.25
[root@d8e784ed1d22 myagent]# GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -tags 'osusergo netgo sqlite_omit_load_extension no_dynamic_plugins' -buildmode=exe -o myagent -ldflags=" -extldflags '-static'" ./cmd/
[root@d8e784ed1d22 myagent]# ldd /tmp/myagent
not a dynamic executable
总结: Go CGO 静态链接需要看情况 #
在启用 CGO 的情况下(默认启用,即 CGO_ENABLED=1
),如果 go 程序使用了 os/user
或 net
包,则 go 默认使用 CGO 的实现,即 go build 生成的二进制会 动态链接 glibc 库,使用 glibc 的 getaddrinfo() 等函数来解析域名等。如果编译环境的 glibc 版本较高,后续将该二进制在低版本 glibc 库的机器上运行时可能会出现 glibc 版本兼容性问题导致启动失败。
可以为 go build 添加 -tags 'osusergo netgo'
参数来使用 os/user
和 net
包的纯 Go 实现(默认是 CGO 实现),这样 Go 的标准库可以实现完全静态链接。
但是当 Go 程序还使用了其它 CGO 三方库,如上面的 go-sqlite3,则需要先确保该库本身能实现静态编译链接,而这个过程,可能需要查看库的源码、issue 和文档来解决。
- go 标准库为 os/user 或 net 包同时提供了 go 实现和 CGO 实现,而且默认使用 CGO 实现(CGO_ENABLE 默认为 1);
- 如果使用 CGO 实现,则编译机器需要安装有 gcc,且生成的二进制是动态链接 glibc;
- 使用 CGO_EBABLE=0 来关闭 CGO,这时 go 使用 go 实现的 os/user 和 net 包,这时可以生成静态链接的二进制;
- 其它第三方库如果是 CGO,则编译时必须开启 CGO_ENABLE=1,系统必须安装 gcc,生成的二进制是动态链接 glibc;
- 可以使用 musl 代替 glibc,实现在使用 CGO 的情况下,生成静态链接的二进制;
go build 的 -ldflags 和 -linkmode 参数解释 #
GOOS=linux GOARCH=amd64 GO111MODULE=on go build -o exec -ldflags “-linkmode external -extld gcc -extldflags ‘-static -v -Xlinker -T./module_info.ld’”
-linkmode
参数值含义:
-
默认值 auto(参考:cmd/cgo/doc.go Implementation details):
-
internal: 使用 go 实现的原生链接器(二进制:
pkg/tool/linux_amd64/link
, 命令是:go tool link
)进行链接,因为上述的 cgo 过程已经进行过链接了,因此动态库的信息已经知晓了,因此在此阶段不需要再进行动态库查找了,直接生成 a.out 即可。 -
external: 使用外部的链接器进行链接,一般是 gcc。
- 先将 .a 转换为链接器可识别的 .o 文件。
- 使用 gcc 进行链接生成 a.out。