WebP Cloud Services Blog

libvips, CGO 与 purego——如何让 Go 应用跨平台编译运行

· Benny Think

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.cfloat 改成 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,无参,返回 int64
  • purego.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 文件即可快速体验。

在之前我们使用了 libaomlibwebp 这一切都工作良好,之后我们发现 govips 的运行效率更高,且 libvips 支持多种图片转换格式,所以我们将底层转换库切换到了 libvips 上,不过在切换到 govips 之后,这一切突然变得复杂了。

使用 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.jpghttps://p2k7zwb.webp.ee/hetzner-arm64/c1-board.jpg
107.81 KB64.52 KB

此外,你还可以通过传入 ?width= 参数来直接生成缩略图

https://blog.webp.se/hetzner-arm64/c1-board.jpghttps://p2k7zwb.webp.ee/hetzner-arm64/c1-board.jpg?width=200
107.81 KB7.27 KB

如果你觉得这个服务有意思,欢迎登录 WebP Cloud Dashboard 来体验,如果你好奇他有哪些功能,可以来看看我们的文档 WebP Cloud Services Docs

参考资料


WebP Cloud Services 团队是一个来自上海和赫尔辛堡的三人小团队,由于我们不融资,且没有盈利压力 ,所以我们会坚持做我们认为正确的事情,力求在我们的资源和能力允许范围内尽量把事情做到最好, 同时也会在不影响对外提供的服务的情况下整更多的活,并在我们产品上实践各种新奇的东西。

如果你觉得这个服务有意思,欢迎登录 WebP Cloud Dashboard 来体验,如果你好奇它还有哪些神奇的功能,可以来看看我们的文档 WebP Cloud Services Docs,希望大家玩的开心~