跳过正文

Go CGO 程序静态编译链接

·
Cgo Go Compile Gcc
目录
gnu-toolchain - 这篇文章属于一个选集。
§ 6: 本文

本文先介绍 Go CGO 的概念和应用场景,以 mattn/go-sqlite3 项目为例,介绍 CGO 程序的静态链接实现方案,其中涉及到动态链接的问题分析、ubunut/centos 系统的静态编译环境搭建,CGO 静态编译遇到的问题和解决方案,最终生成最小化系统环境依赖的静态链接二进制。

Go CGO 程序的编译过程
#

参考:https://www.rectcircle.cn/posts/go-static-compile-and-cgo/

当项目使用 CGO 时,go build 编译过程如下:

  1. 预处理:首先调用 pkg/tool/linux_amd64/cgo 对 go 源码进行预处理,生成中间源码文件,在上例中,位于 /tmp/go-build3000103738/b001,其中 main.cgo2.c 是 C 语言的源码部分。

  2. 编译 C 源码:调用系统默认的 C 语言编译器(可通过 CC 环境变量指定),在本例中为 gcc。

  • 将 c 源码文件编译成 .o 文件,如 main.cgo2.c 被编译成了 _x002.o。
  • 将所有 .o 生成 cgo.o。
  1. 生成动态链接库信息:调用 pkg/tool/linux_amd64/cgo 根据 cgo.o 生成包含动态链接信息的代码文件,位于 _cgo_import.go。

  2. 编译 Go 源码:pkg/tool/linux_amd64/compile 编译 go 源码生成 pkg.a。

  3. 打包 C 源码的 .o:pkg/tool/linux_amd64/pack 将 C 源码生成的 .o 打包到 pkg.a 中。

  4. 写入 buildid:pkg/tool/linux_amd64/buildid 将 buildid 写入 pkg.a。

  5. 生成可执行文件(链接阶段):pkg/tool/linux_amd64/linkpkg.a 链接上 Go 源码引入的其他 go package,并生成可执行文件 a.out

  6. 复制产物:将 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 程序进行静态编译:

  1. -ldflags : 传递给 go tool link 链接器的参数;

  2. -linkmode external: 使用外部链接器(如 gcc 的 ld)来对 Go 程序进行链接,CGO 程序需要链接 C 程序库,故需要使用该模式;

  • 其它的模式包括 internal(使用 go tool link 内置链接器);
  • auto(默认):如果是未启用 CGO 或者只使用了标准库的 CGO,则使用 internal ,否则为 external;
  1. -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 版本兼容性问题的常用方案:

  1. 静态链接:将 glibc 等依赖库静态链接到可执行程序中,运行时不依赖系统 glibc 库;
  2. 手动打包:通过 tar 包等机制,将依赖的动态库和程序二进制一起打包,运行时通过 LD_LIBRARY_PATH 环境变量来注入包中动态库;
  1. 容器镜像:和手动打包类似,但是打包到一个 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/usernet 包,则 go 默认使用 CGO 的实现,即 go build 生成的二进制会 动态链接 glibc 库,使用 glibc 的 getaddrinfo() 等函数来解析域名等。如果编译环境的 glibc 版本较高,后续将该二进制在低版本 glibc 库的机器上运行时可能会出现 glibc 版本兼容性问题导致启动失败。

可以为 go build 添加 -tags 'osusergo netgo' 参数来使用 os/usernet 包的纯 Go 实现(默认是 CGO 实现),这样 Go 的标准库可以实现完全静态链接。

但是当 Go 程序还使用了其它 CGO 三方库,如上面的 go-sqlite3,则需要先确保该库本身能实现静态编译链接,而这个过程,可能需要查看库的源码、issue 和文档来解决。

  1. go 标准库为 os/user 或 net 包同时提供了 go 实现和 CGO 实现,而且默认使用 CGO 实现(CGO_ENABLE 默认为 1);
  2. 如果使用 CGO 实现,则编译机器需要安装有 gcc,且生成的二进制是动态链接 glibc;
  3. 使用 CGO_EBABLE=0 来关闭 CGO,这时 go 使用 go 实现的 os/user 和 net 包,这时可以生成静态链接的二进制;
  4. 其它第三方库如果是 CGO,则编译时必须开启 CGO_ENABLE=1,系统必须安装 gcc,生成的二进制是动态链接 glibc;
  5. 可以使用 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。

参考
#

  1. https://www.rectcircle.cn/posts/go-static-compile-and-cgo/
gnu-toolchain - 这篇文章属于一个选集。
§ 6: 本文

相关文章

C 预处理器-个人参考手册
·
Gnu Cpp Manual
这是我个人的 C 预处理器参考手册文档。
链接器 ld
·
Gnu Ld Manual
使用 linux perf 进行内核和应用性能分析
Perf Kernel Performance Tool
介绍 perf 工具的使用方式、局限性和问题。
使用 linux bpftrace 进行内核和应用性能分析
Bpftrace Kernel Ebpf Performance Tool
介绍 bpftrace 工具的使用方式、局限性和问题。