原文:Porting Our Software to ARM64,作者 Alexander Huynh,Cloudflare Blog。
ARM64 服务器这几年越来越普遍。AWS 的 Graviton、Ampere 的 Altra,包括苹果 M 系列芯片的 Mac,都在把 ARM 架构从移动端推向数据中心和开发者桌面。对于只跑过 x86 的团队来说,迁移这件事听起来简单,做起来往往不是那么回事。
这篇文章是 Cloudflare 工程师 Alexander Huynh 在 2018 年写下的迁移记录,记录了他们把整个边缘软件栈迁移到 ARM64 的过程——包括用了哪些方法,碰了哪些壁,最后怎么解决的。虽然时间稍早,但里面的问题和思路到今天都有参考价值。
规模有多大
要理解这件事的难度,先看看 Cloudflare 的软件栈有多厚。
底层是 Linux 内核,发行版选的是 Debian,往上是他们自己维护的数百个软件包,其中一部分是基于开源项目做了定制,另一部分是完全内部开发的。编程语言横跨 C、C++、Go、Lua、Python、Rust 六种。
好消息是,ARM64 的生态支持已经相当成熟。Linux 内核很早就支持 ARM64,Debian 从 Stretch 版本(2017 年)起把 ARM64 列为一类发行架构,这意味着操作系统本身能比较顺滑地跑起来。真正的工作量在于让那数百个内部包也在 ARM64 上正确构建和运行。
两条主路:Go 和 Rust 的交叉编译
对于 Go 和 Rust,情况相对乐观,两个语言都有成熟的跨平台交叉编译支持。
Go 的迁移方式
Go 官方把 ARM64 列为一类支持架构,交叉编译只需要在 Debian 上额外安装 crossbuild-essential-arm64,然后把原来的 go build 替换为:
GOARCH=arm64 CGO_ENABLED=1 go build
注意 CGO_ENABLED=1 是必须显式指定的,因为交叉编译时 Go 默认会关掉 cgo。他们的做法是把原来的单次构建改成一个循环,同时为 amd64 和 arm64 各跑一遍,产出的二进制再跑测试框架验证。
Rust 的迁移方式
Rust 的交叉编译支持同样完善。同样先安装 crossbuild-essential-arm64,然后在 cargo build 或 rustc 里指定 --target aarch64-unknown-linux-gnu 即可。
唯一需要留意的地方:如果你的包依赖了很多第三方 crate,每一个 crate 都需要能正确交叉编译。依赖树越深,碰到问题 crate 的概率就越高。
第三条路:QEMU 用户态仿真
C、C++ 以及其他一些语言在交叉编译时就没那么顺了——调 CC、LD 各种环境变量往往费时费力,还不一定能覆盖所有情况。
他们选择的方案是 QEMU 用户态仿真(user-space emulation),核心思路是:不去改动构建工具链,而是提供一个仿真层,让 x86 机器(包括开发者的 MacBook)能直接"运行" ARM64 程序。
具体实现借助了 Docker。目标是让开发者能直接 docker run 进入一个 ARM64 环境,就像这样:
host$ uname -m
x86_64
host$ docker run --rm -it stretch-arm64/master:latest
guest# uname -m
aarch64
在 x86 的宿主机上,uname -m 输出 aarch64,仿真层对开发者完全透明。
实现方式的关键在于一个打了补丁的 qemu-user。这个补丁让 QEMU 在每次 execve 系统调用时自动把仿真器前置进去,效果类似于 Linux 内核的 binfmt_misc 机制——每个新进程都会被自动带入仿真环境,整个容器因此形成一个自洽的 ARM64 沙盒。
Dockerfile 的核心结构是这样的:
# 内部构建的带补丁 QEMU
FROM qemu-aarch64/master:latest as qemu
# ARM64 版 Debian 基础镜像
FROM arm64v8/debian@sha256:841bbe...
# 把仿真器复制进来,设为 ENTRYPOINT
COPY --from=qemu /qemu-aarch64 /qemu-aarch64
ENTRYPOINT ["/qemu-aarch64", "--execve", "/qemu-aarch64"]
有了这个镜像,99% 以上的内部代码库都能在仿真环境里正常构建和测试。
真正让人头疼的:四个技术坑
方案看起来很美,但落地时碰到了几个有意思的问题。
坑一:环境变量失效
开发者最早反映的问题之一是 LD_LIBRARY_PATH 不生效。排查之后发现问题不只是这一个变量——所有通过命令行或 export 设置的环境变量,都无法传递进 qemu-user 进程。
根源是 Dockerfile 里的一行 setcap:
RUN setcap cap_setuid,cap_setgid+ep /qemu-aarch64
这行命令是为了让容器内的 sudo 能正常工作,但它同时阻断了环境变量的透传。两个需求直接冲突,没有两全的解法,最终只能告知开发者:在容器内,sudo 和环境变量传递只能选其一。
坑二:Go 程序不定时崩溃
CI 系统里跑了大量 Go 代码,很快发现一个规律:Go 程序会以不可预测的间隔发生 segfault。
定位到原因是 Go 运行时与 QEMU 的多线程兼容性问题。Go 的 goroutine 调度器会自由地把 goroutine 分配到不同系统线程上,而 QEMU 的用户态仿真在处理多线程时存在已知问题,上游也明确表示短期内不会修复。
他们的解决方案很实用:在 Go 二进制的 .deb 安装后脚本里,检测当前是否处于 ARM64 仿真环境,如果是,就通过 taskset 把进程限制为只使用单个 CPU:
# 检测是否在 ARM64 仿真下运行
if [ "$(uname -m)" = "aarch64" ] && [ "$(uname -r | grep -c qemu)" -gt 0 ]; then
taskset -c 0 "$@"
fi
单核运行性能有损耗,但在仿真环境里本来就慢,慢点跑总比随机崩强。加了这个限制之后,随机崩溃降为零。
坑三:动态库加载顺序不一致
Cloudflare 有一个习惯:不覆盖系统目录 /usr/lib 下的库,而是把自己的最新版本装在 /usr/local/lib,保持系统库的稳定性。
这套方案在 x86 上工作了很久,到 ARM64 就出了问题:有团队反映 ARM64 版本的程序无法加载正确的动态库符号。
排查下来,发现根因藏在 Debian 的一个细节里。动态链接器通过 /etc/ld.so.conf.d/ 目录下的文件来确定库的搜索顺序,而这个目录是按文件名字母序遍历的。
在 x86_64 机器上,目录内容是:
libc.conf ← 含 /usr/local/lib,字母序靠前,先搜索
x86_64-linux-gnu.conf ← 含 /usr/lib/x86_64-linux-gnu,后搜索
在 ARM64 机器上,目录内容变成了:
aarch64-linux-gnu.conf ← 含 /usr/lib/aarch64-linux-gnu,字母序靠前,先搜索
libc.conf ← 含 /usr/local/lib,后搜索
a 排在 l 前面——这个字母序的差异,导致 ARM64 上系统库的优先级高于 /usr/local/lib,而 x86 上恰好相反。同样的代码,行为截然不同。
解决方案是不动系统配置,改为在链接器参数里显式加 --rpath /usr/local/lib,强制运行时先从这里搜索。
值得一提的是,这个问题在仿真环境和物理 ARM64 机器上都存在,说明仿真层在这一点上是忠实地复现了真实环境的行为。
坑四:少数包仍然需要原生编译
99% 的包通过交叉编译或仿真解决了,剩下的 1% 是真正的硬骨头。
以 llvm 为例,它的构建高度并行化,在原生 x86 机器上跑得很快,但一旦套上仿真层,并行度反而成了负担,构建时间超过 6 小时。还有一些包调用了 QEMU 尚未实现的系统调用,直接失败。
对这部分包,他们的选择是给开发者分配少量真实的 ARM64 机器,以及一台专用的原生 ARM64 CI 节点。这样做的代价是从可用机器池里划走了一些资源,但对于长尾问题来说,这是性价比最高的处理方式。
如何推动整个工程团队跟进
技术方案定了之后,还有一个不小的工程管理问题:数百个包分散在几十个团队手里,怎么让大家都动起来。
初期由移植团队承包了所有包的 ARM64 构建工作,同时与各包的维护者密切协作,在代码变动时保持同步。等 ARM64 平台被评估为生产就绪之后,他们整理了一套自助操作文档,向全工程部门发出了"把 ARM64 作为一等公民支持"的要求,之后各个团队自行负责自己包的 ARM64 兼容性。
这件事给出的几个判断
回顾整个迁移过程,有几个判断值得记下来。
交叉编译能解决大多数问题,但不是万能的。 Go 和 Rust 的交叉编译体验已经很好,但仍然有依赖、crate 兼容性等边界情况。对于 C/C++ 这类语言,交叉编译工具链的配置复杂度明显更高。
仿真层是务实的折中。 QEMU 用户态仿真让团队不需要为每个开发者配一台 ARM64 机器,大幅降低了迁移成本。代价是引入了一些仿真特有的问题(环境变量透传、多线程不稳定),需要有人专门去踩和处理。
架构差异往往藏在细节里。 动态库搜索顺序这个问题,在 x86 上完全感知不到,到 ARM64 就直接导致运行失败。这类问题不是逻辑 bug,而是对平台差异不够了解的结果,只有在真实的 ARM64 环境下跑才能暴露。
原生编译始终是最可靠的。 仿真和交叉编译都是务实的过渡手段。长期来看,随着 ARM64 开发机和 CI 机器越来越普及,直接在原生环境里编译和测试,才是最终的稳态。