libvips, CGO 与 purego——如何让 Go 应用跨平台编译运行
This article is also available in English, at libvips, CGO, and purego: How to Compile and Run Go Applications on different platforms.
Go 最令人瞩目的一大特点便是支持非常方便的跨系统、跨架构的交叉编译。想象一下这样的场景:
你有一位朋友,每天都要按照一定规则处理一定数量的文件,他想让你帮忙写个程序,之后他只要一点这个程序,就可以安心摸鱼划水了。
你的这位朋友使用Windows,你使用的是macOS。
实现业务逻辑并不是特别复杂的问题,问题是如何让你的朋友使用你的代码。总不能让人家去安装解释器或编译器吧。并且,如果你使用 NodeJS 那么大概会收获如下战果:
Go 就没有这方面的困扰,理想状况下,我们可以非常简单容易的编译出其他平台、其他架构的二进制文件。我们不需要安装交叉编译工具链,更重要的是,我们的应用程序是静态链接的,意味着不需要任何依赖就可以运行。
初学编程的你、去某网站下载游戏的你一定见过这样的场景
对于Go来说,这种奇奇怪怪的缺少运行库的文件的事情,那就基本不存在了,我们可以直接编译好对应的程序:
# 编译出Windows的exe文件
$ GOOS=windows GOARCH=amd64 go build .
$ file awesomeProject.exe
awesomeProject.exe: PE32+ executable (console) x86-64 (stripped to external PDB), for MS Windows
# 再编译一个Linux的试试, amd64 和arm64都要
$ GOOS=linux GOARCH=amd64 go build .
$ file awesomeProject
awesomeProject: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=tSnBibyCQLPF4pPSyw4d/yFku1u5LTJGphtQfeoc5/EUJDOUquWUDl0G4NOyj2/x8FBO6wejoBCzyF-5sLs, with debug_info, not stripped
$ GOOS=linux GOARCH=arm64 go build .
$ file awesomeProject
awesomeProject: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, Go BuildID=JQJBN7vXIJN-OfAptA1U/6uWHhmAeqKPmmO-Sc7h0/gYsHDj13pvNe2sJukdkO/jUkmn9RgZnyNYBs-UBFj, with debug_info, not stripped
# 我的使用freebsd但是却不会编程的奇葩朋友
$ GOOS=freebsd GOARCH=arm64 go build . 130 ↵
$ file awesomeProject
awesomeProject: ELF 64-bit LSB executable, ARM aarch64, version 1 (FreeBSD), statically linked, for FreeBSD 12.3, FreeBSD-style, Go BuildID=SEQmlkCSluwqrdkxTbI2/ej7yuXNnh9AvTQOLuJ8a/cvumRw18eyZLqbVYYlw4/qHSwivX3bgKMoj_JYuXh, with debug_info, not stripped
然而,使用Go进行交叉编译,真的如此简单吗?
Go 的实现
使用纯 Go 实现的应用程序毫无疑问是可以静态链接的,我们需要显式禁用 CGO 并且加上一些 LDFLAGS。
Go 的标准库,如 net
有一部分是用了 CGO 的,这种情况下默认就会是动态链接了。使用如下神奇代码基本上就可以静态链接了
CGO_ENABLED=0 go build -a -ldflags '-s -w -extldflags "-static"' main.go
CGO
从头去实现一个复杂的系统往往是一件繁琐的事情。
例如,为了实现 WebP 编码的功能,能直接使用已有的 C 语言的 libwebp,要比从头写编码器容易的多。就像胶水语言 Python 调用 C/C++ 的代码一样,通过 Go 调用 C 的这个行为则被称为 CGO: Calling C from Go code。
有人已经写好了计算球体积的代码 calc.c
,使用 stdio.h
来负责标准输出,使用 math.h
来计算次方并提供圆周率pi
#include <stdio.h>
#include <math.h>
float volume(float radius)
{
float volume = (4.0f / 3.0f) * M_PI * pow(radius, 3);
printf("radius: %f, volume: %f\\n", radius, volume);
return volume;
}
想要在 Go 中调用,只需要 import "C"
和 include
。注意 import "C"
一定要写在 include
之后的一行。在这里我顺便调用了 time.h
用于生成运行时的时间戳
package main
import "fmt"
/*
#include "calc.c"
#include <time.h>
*/
import "C"
func main() {
timestamp := C.time(nil)
fmt.Println("timestamp from time.h", timestamp)
res := C.volume(C.float(3.9))
fmt.Println("Result: ", res)
}
此时你再想交叉编译,无论是跨平台还是跨架构,都不可以了。因为我们在 Go 中调用了 C 的代码。
这个提示信息很令人迷惑,main.go
就在那里,却告诉你没有对应的文件
# 尝试编译Linux的二进制
$ GOOS=linux go build main.go
go: no Go source files
# 试试 Darwin amd64
$ GOARCH=amd64 go build main.go
go: no Go source files
# 显示禁用CGO
$ CGO_ENABLED=0 GOOS=linux go build main.go
go: no Go source files
$ CGO_ENABLED=0 go build main.go
go: no Go source files
# 直接构建本平台架构的二进制
$ go build main.go
$ file main
main: Mach-O 64-bit executable arm64
💡 C/C++当然也可以实现静态链接,不过这里不在本文的讨论范围内。
C 标准库与 CGO
大部分操作系统都实现了 C 标准库,比如 GNU/Linux 的通常叫做 glibc,Alpine Linux 的叫做 musl,我们可以通过 ldd
来查看。
# alpine
~ # ldd --version
musl libc (aarch64)
Version 1.2.4
Dynamic Program Loader
Usage: /lib/ld-musl-aarch64.so.1 [options] [--] pathname
# ubuntu
root@b97e444b77d8:~# ldd --version
ldd (Ubuntu GLIBC 2.35-0ubuntu3.1) 2.35
Copyright (C) 2022 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.
我们在使用 CGO 的时候,实际上应用程序的部分功能,动态链接到了对应操作系统的C标准库。
# 先安装下gcc什么的
~ # apk install alpine-sdk
# 编译程序
~ # go build main.go
# 能看到动态链接到了musl
~ # file main
main: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-aarch64.so.1, Go BuildID=EcVShcMJ4BadtKOwCXEA/PmcoroS9BkTvUtAwCGc8/hgBrbqcis0htwacoIGeK/cP45vit8HqiQHniKYo82, with debug_info, not stripped
~ # ldd main
/lib/ld-musl-aarch64.so.1 (0xffffb9bdd000)
libc.musl-aarch64.so.1 => /lib/ld-musl-aarch64.so.1 (0xffffb9bdd000)
# 在debian下,我们要使用 -lm来让gcc链接到 math.h
root@f3a02e3c52f2:~# export CGO_LDFLAGS=-lm
root@f3a02e3c52f2:~# go build main.go
# 这里能看到动态链接到了glibc
root@f3a02e3c52f2:~# file main
main: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, BuildID[sha1]=e6e82e9f418298d8d833beaea799fc13b6e5e9fe, for GNU/Linux 3.7.0, with debug_info, not stripped
# 运行一下试试
root@f3a02e3c52f2:~# ./main
timestamp from time.h 1688153330
radius: 3.900000, volume: 248.474869
Result: 248.47487
# 查看程序的链接信息
root@f3a02e3c52f2:~# ldd main
linux-vdso.so.1 (0x0000ffff98b69000)
libm.so.6 => /lib/aarch64-linux-gnu/libm.so.6 (0x0000ffff98a90000)
libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffff988e0000)
/lib/ld-linux-aarch64.so.1 (0x0000ffff98b2c000)
# 查看glibc版本
root@f3a02e3c52f2:/go# ldd --version
ldd (Debian GLIBC 2.36-9) 2.36
Copyright (C) 2022 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.
将这个 main
拷贝给任意一位 Linux 用户,他们可以直接运行吗?
毫无疑问,你把链接到 musl 的文件给一个用 glibc 的系统去跑,会得到一个很奇怪的报错: ./main: No such file or directory
,而你能看到 main
文件明明就在那里,其实这里的报错是在说系统没有找到 /lib/ld-musl
,而并非那个 main
文件不存在。没错,开源拖拉机就是这样的。
root@4035da59313e:~# cat /etc/issue
Ubuntu 22.04.2 LTS \\n \\l
root@4035da59313e:~# file main
main: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-aarch64.so.1, Go BuildID=EcVShcMJ4BadtKOwCXEA/PmcoroS9BkTvUtAwCGc8/hgBrbqcis0htwacoIGeK/cP45vit8HqiQHniKYo82, with debug_info, not stripped
# main 明明在,但是却告诉你找不到
root@4035da59313e:~# ./main
bash: ./main: No such file or directory
root@4035da59313e:~# ls
calc.c go.mod go.sum main main.go
那如果拷贝给同样的标准库实现的操作系统呢?比如大部分 Linux 都使用的 glibc。
通常这取决于 glibc 的兼容性,一般来说高版本会向下兼容,但是低版本未必会向上兼容。也就说,低版本 glibc 编译出来的文件,高版本 glibc 大概率是可以用的;但是高版本编译出来的文件,低版本的 glibc 有很大概率无法运行。
比如你可以试试在 Debian 11 中跑一下 Debian 12 编译的文件吧,如下,很快我们就会发现失败了!
root@5354fbc3d13b:~# cat /etc/issue
Debian GNU/Linux 11 \\n \\l
root@5354fbc3d13b:~# ./main
./main: /lib/aarch64-linux-gnu/libc.so.6: version `GLIBC_2.32' not found (required by ./main)
./main: /lib/aarch64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by ./main)
# 无法运行,因为 Debian 11的glibc是2.31 而 Debian 12的是更新的版本
root@5354fbc3d13b:~# ldd --version
ldd (Debian GLIBC 2.31-13+deb11u6) 2.31
Copyright (C) 2020 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper
但是如果在 Debian 10 中编译,这个二进制文件可以在 10 11 12 以及更新版本中使用。
因此,如果使用了 CGO,想要让编译出来的程序尽可能在多的地方使用,那么就要在不造成其他负面影响的情况下,尽可能在低的 glibc 下编译,CentOS 7 就是非常好的一个选择。
这里要夸一下 musl,ABI 非常稳定,无论什么版本编译出来就能用,无所谓 Alpine 3.14 还是 3.6,一样都能用!
purego
CGO 是如此的让人又爱又恨!有没有什么办法不用 CGO,但是却依旧可以调用 C 代码?当然有了,使用 ebitengine/purego 这个项目就可以了。
基本使用思路是这样的,我们找到对应的 so 文件,对应 macOS 的 dylib 或 windows 的 dll,在运行时动态引用,然后把函数注册过去,之后就可以调用这个函数啦!
下面是一个调用标准库的 time 函数和我们的 calc.c
的方法。由于这个库似乎不太支持 float32
,所以我们要稍微更新一下 calc.c
把 float
改成 double
,对应 Go 的 float64
#include <stdio.h>
#include <math.h>
double volume(double radius)
{
double volume = (4.0f / 3.0f) * M_PI * pow(radius, 3);
printf("radius: %f, volume: %f\\n", radius, volume);
return volume;
}
然后使用purego
package main
import (
"fmt"
"github.com/ebitengine/purego"
"runtime"
)
func getSystemLibrary() string {
switch runtime.GOOS {
case "darwin":
return "/usr/lib/libSystem.B.dylib"
case "linux":
return "libc.so.6"
default:
panic(fmt.Errorf("GOOS=%s is not supported", runtime.GOOS))
}
}
func getCustomLibrary() string {
switch runtime.GOOS {
case "darwin":
return "calc.dylib"
case "linux":
return "./calc.so" // 注意这里的 ./,否则就要 symbolic link 到 /lib
default:
panic(fmt.Errorf("GOOS=%s is not supported", runtime.GOOS))
}
}
func main() {
libc, err := purego.Dlopen(getSystemLibrary(), purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
panic(err)
}
var timeFunc func() int64
purego.RegisterLibFunc(&timeFunc, libc, "time")
x := timeFunc()
fmt.Println("timeFunc from purego: ", x)
calc, err := purego.Dlopen(getCustomLibrary(), purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
panic(err)
}
var volumeFunc func(float64) float64
purego.RegisterLibFunc(&volumeFunc, calc, "volume")
res := volumeFunc(5.3)
fmt.Println("volumeFunc: ", res)
}
getSystemLibrary
获取系统的 C 标准库的文件名称purego.Dlopen
打开我们的标准库var timeFunc func() int64
定义一个函数timeFunc
,无参,返回 int64purego.RegisterLibFunc(&timeFunc, libc, "time")
把Go的函数注册到libc
中的time
,实际上对应time.h
中的函数
注册完成之后,我们直接调用 timeFunc
即可
对于我们自定义的函数,那么需要先编译为动态链接库
gcc calc.c -shared -o calc.dylib
然后 purego.Dlopen
指定这个库文件的路径就可以了
$ CGO_ENABLED=0 go build main.go
$ file main
main: Mach-O 64-bit executable arm64
$ ./main
timeFunc from purego: 1688157968
radius: 5.300000, volume: 623.614538
volumeFunc: 623.6145379031443
甚至可以交叉编译,跨平台跨架构均可
$ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build main.go && file main
main: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, Go BuildID=fE2oBt7SuA3vQ1l6grl3/TMG44vS0AQgizY8N0dgU/fzsb2LmHEV5MewkNePWZ/gZcBLiazVGwhZnCddgc-, with debug_info, not stripped
$ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build main.go && file main
main: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, Go BuildID=INDTVasUMzUfX4e4TOdd/f0SK_QqErc6rczYl33mA/blCz4OvC5JrgxwccisHF/HKPRj5tDEcSB7MLxefYY, with debug_info, not stripped
$ CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build main.go && file main
main: Mach-O 64-bit executable x86_64
不过要注意的是,你的目标系统也要有 calc.so
,并且 GNU/Linux 要记得 lm
root@50a91fb229b9:~# gcc calc.c -shared -lm -o calc.so
root@50a91fb229b9:~# file main
main: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, Go BuildID=yaDDwT9MZyytBRl8Hgsn/TJAksLImgI7B37_oJor7/RDT3WAei40Odjx7-HbU5/xw7CqO3m_sjSQvcFlX13, with debug_info, not stripped
root@50a91fb229b9:~# ./main
timeFunc from purego: 1688158724
radius: 5.400000, volume: 659.583680
volumeFunc: 659.5836804636092
这与 WebP Server Go 有何关联?
在 WebP Server Team(也是 WebP Cloud Services Team),我们的目标不仅仅是优化图片,另外一个目标是希望能让用户更简单的运行我们的应用——直接下载 GitHub 中的 release 文件即可快速体验。
在之前我们使用了 libaom
和 libwebp
这一切都工作良好,之后我们发现 govips 的运行效率更高,且 libvips
支持多种图片转换格式,所以我们将底层转换库切换到了 libvips
上,不过在切换到 govips
之后,这一切突然变得复杂了。
govips
依赖libvips
8.10以上的版本,这个版本已经比较新了- 我们希望同时支持 amd64 和 arm64 架构,因为大部分用户都是在用 amd64 架构,而我们在生产环境使用 ARM64 架构,可以参考我们之前的博文 Hetzner CAX 系列 ARM64 服务器性能简评以及 WebP Cloud Services 在其上的实践
- 使用
govips
意味着我们使用了 CGO,而使用 CGO 就意味着有 glibc 兼容性问题
使用 purego
purego 是非常优秀的一个项目,支持在禁用 CGO 的情况下交叉编译,所需要的只是引用好对应的 so 文件,然后注册函数。但是从上述实操中我们也能发现,purego 用起来非常麻烦,更何况我们依赖的是 govips
,想要把这个库改成 purego
的模式,恐怕会累死。
使用旧版本 glibc 的 OS 构建
没错,我们在某个比较古老的操作系统上构建出来一个二进制文件,但是用户依旧无法直接使用这个文件,用户需要想办法安装好 libvips 8.10+,然后才能使用
CentOS 7与 libvips
CentOS 7发布于2014年,距今已经接近10年了,依旧处于维护期,是非常适合拿来做构建的。Remi 维护了一个 libvips
的库,但是可惜并不支持 arm64,因此我们要想办法自己构建 libvips
。
我们需要什么?我们只是需要一个能用的、最小化的 libvips
,不支持 png、jpg、webp 不要紧,在这个 CentOS 中,我们只是需要借助 libvips.so
编译我们的二进制。至于编译出来的二进制有何功能,那么全看用户在运行时安装的 libvips
版本。
构建 libvips
的准备工作
需要安装一些常见的依赖,gcc 什么的
yum install -y epel-release wget
yum groupinstall -y 'development tools'
yum install -y glib2-devel expat-devel
下载 libvips
代码并编译
wget <https://github.com/libvips/libvips/releases/download/v8.10.0/vips-8.10.0.tar.gz>
tar xf vips-8.10.0.tar.gz && rm -f vips-8.10.0.tar.gz
cd vips-8.10.0
./configure --prefix=/usr --libdir=/usr/lib64
make && make install
vips --version
prefix` 和 `libdir` 是非常重要的两个参数,CGO默认会去特定的目录里去找对应的so文件(由ldconfig定义的),在CentOS中,这个目录是 `/lib64` 而 `/lib64` 软连接到了 `/usr/lib64
我们编译了一个最小化的 libvips
!这个 libvips
甚至连 jpg 都读不了,但是已经足够了
安装 Go 并编译
从 Go 的官网下载并安装 Go,然后
export CGO_CFLAGS="-std=gnu99"
git clone <https://github.com/webp-sh/webp_server_go>
cd webp_server_go
go build webp-server.go
编译出来的文件就可以分发给用户了,只要他们正确安装了 libvips
,那么就可以使用。
如果你的系统过于古老……
如果你的系统过于古老,没有满足版本需求的 libvips
,也没有第三方 PPA,那么只能自己动手了。
自己编译 libvips
其实并不是很困难,记得 libvips
需要 libwebp
0.6.0 以上版本,而 CentOS 7 源里提供的版本是 0.3.0,非常的老,所以我们需要先需要先编译 libwebp
# 也要先安装jpg png相关的库
yum install -y glib2-devel expat-devel libgif-devel giflib-devel libpng-devel libtiff-devel libjpeg-devel libexif-devel
# 下载解压缩
mkdir libwebp && cd libwebp
wget <https://chromium.googlesource.com/webm/libwebp/+archive/refs/heads/0.6.0.tar.gz>
tar xf 0.6.0.tar.gz && rm -f 0.6.0.tar.gz
# 配置编译
./autogen.sh
./configure --prefix=/usr/ --libdir=/usr/lib64 --enable-libwebpdemux --enable-libwebpmux
# 编译并安装
make && make install
prefix
libdir
同上- 为了让
libvips
支持 webp,需要安装 demux 和 mux
Why the hassle?
这么一波搞下来,我们除了学习了 CGO 的逻辑和构建了一堆程序以外似乎什么也没有收获。purego 基本无法使用;虽然通过低版本的 glibc 编译的二进制文件可以解决在不同系统上运行时遇到的 glibc 的问题,但是为了成功转换图片,用户还是需要手动安装符合版本要求的libvips。
更别提什么 LDFLAGS 去静态链接 libvips
,这根本不是人做的事情。
所以我们可以看出,将 WebP Server Go 的底层转换库从之前的 chai2010/webp
库换到了 libvips
虽然带来的性能的提升,但是也带来了运行时环境的一些问题,鉴于此,我们在最近更新了 https://github.com/webp-sh/webp_server_go 的 README,鼓励用户直接使用我们构建好的 Docker 容器来运行(同时我们保留了直接使用 binary 运行的选项)WebP Server Go。
更进一步,如果你感觉 Docker 用起来比较繁琐,或者你的网站是个静态化的网站,根本就没有运行,为什么不尝试我们 WebP Cloud Services?
最近我们在探索一个新的业务形式,被称为 WebP Cloud Services 下的 WebP Cloud ,类似我们的开源组件 WebP Server Go 的 SaaS 版本,用户只需要用 GitHub 登录,然后填写源站地址,即可获得一个新的带 WebP 转换的,带 CDN 和缓存的新地址,比如 100KB 的图片 https://blog.webp.se/hetzner-arm64/c1-board.png
地址变成 WebP 版本的只有 60KB 的 https://p2k7zwb.webp.ee/hetzner-arm64/c1-board.png
地址(且画质几乎不会衰退)。
https://blog.webp.se/hetzner-arm64/c1-board.jpg | https://p2k7zwb.webp.ee/hetzner-arm64/c1-board.jpg |
107.81 KB | 64.52 KB |
此外,你还可以通过传入 ?width=
参数来直接生成缩略图
https://blog.webp.se/hetzner-arm64/c1-board.jpg | https://p2k7zwb.webp.ee/hetzner-arm64/c1-board.jpg?width=200 |
107.81 KB | 7.27 KB |
如果你觉得这个服务有意思,欢迎登录 WebP Cloud Dashboard 来体验,如果你好奇他有哪些功能,可以来看看我们的文档 WebP Cloud Services Docs。
参考资料
- 包含了libvips的CentOS 7镜像:
webpsh/libvips
- webpsh/libvips 构建代码 https://github.com/webp-sh/libvips
- purego https://github.com/ebitengine/purego
- WebP Cloud Services: https://webp.se/
- Statically Linking Go in 2022: https://mt165.co.uk/blog/static-link-go/
WebP Cloud Services 团队是一个来自上海和赫尔辛堡的三人小团队,由于我们不融资,且没有盈利压力 ,所以我们会坚持做我们认为正确的事情,力求在我们的资源和能力允许范围内尽量把事情做到最好, 同时也会在不影响对外提供的服务的情况下整更多的活,并在我们产品上实践各种新奇的东西。
如果你觉得我们的这个服务有意思或者对我们服务感兴趣,欢迎登录 WebP Cloud Dashboard 来体验,如果你好奇它还有哪些神奇的功能,可以来看看我们的文档 WebP Cloud Services Docs,希望大家玩的开心~
Discuss on Hacker News