Linux二进制无法运行:glibc与musl兼容性
遇到“GLIBC_2.34 not found”并非文件损坏,而是构建主机设定了glibc最低版本。了解二进制的真实依赖,并从四种方案中选择可移植发布方式。
较旧服务器无法运行 Linux 二进制文件的原因
难以构建可移植的 Linux 二进制文件,因为 glibc(GNU C 库)只承诺单向兼容。旧二进制文件可以在新版 glibc 上运行。新二进制文件无法在旧版 glibc 上运行。编译二进制文件的机器决定了所有部署目标机器的最低要求。
错误信息会准确指出它需要的版本:
./mytool: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.38' not found (required by ./mytool)文件没有损坏,通常也不存在配置错误。唯一的例外是 LD_LIBRARY_PATH 设置将加载器指向了较旧的 libc;错误信息中的路径会显示它实际读取的是哪个 libc。动态加载器读取二进制文件内部记录的版本标签,在该 libc 中查找此标签;找不到后,就会拒绝启动程序。重新下载文件或运行 chmod +x 都不会改变结果,因为此要求已经写入文件本身。您必须改变二进制文件的构建方式,或改变发布的内容。
glibc 符号版本控制的实际作用
glibc 导出的每个函数都有一个版本标签。printf 在 libc.so.6 中实际是 printf@@GLIBC_2.2.5。当 glibc 更改函数的行为或 ABI(应用程序二进制接口)时,不会替换旧函数。它会保留带有旧标签的旧代码,并在新标签下添加新代码,因此单个 libc.so.6 可以同时保存同一符号的多个版本。2009 年构建的二进制文件仍能找到 2009 年的标签,这正是向前兼容性如此出色的原因。
链接步骤会记录它使用的内容。二进制文件会获得一个 .gnu.version_r 节,其中实际表示:“我需要 GLIBC_2.38,来自 libc.so.6。”较旧的 libc 从未包含该标签,因此加载器会在 main 运行前停止。这里没有回退机制,因为回退意味着向程序静默提供一个不同于其编译时所针对的函数。
自 2021 年以来,最常见的触发因素是 glibc 2.34。该版本将 libpthread 和 libdl 合并到 libc 中,并将 __libc_start_main(每个 C 程序的启动函数)移到了 GLIBC_2.34 标签。因而,在 glibc 2.34 或更高版本的任何发行版上编译的 hello-world 都会要求 GLIBC_2.34,即使源代码没有调用任何现代功能。这就是为什么代码多年未变的用户会突然遇到此问题。
我的发行版提供哪个 glibc 版本?
此图表背后的数据
[
{
"distro": "CentOS 7",
"glibc": 2.17
},
{
"distro": "RHEL 8",
"glibc": 2.28
},
{
"distro": "Ubuntu 20.04",
"glibc": 2.31
},
{
"distro": "Debian 11",
"glibc": 2.31
},
{
"distro": "RHEL 9",
"glibc": 2.34
},
{
"distro": "Ubuntu 22.04",
"glibc": 2.35
},
{
"distro": "Debian 12",
"glibc": 2.36
},
{
"distro": "Ubuntu 24.04",
"glibc": 2.39
},
{
"distro": "Debian 13",
"glibc": 2.41
}
]这些是各发行版在其软件仓库中提供的版本,由发行版发布,并截至 2026 年 8 月有效。CentOS 7 早已结束生命周期,但由于仍有继承而来的服务器运行它,因此仍保留在列表中。在任意一台服务器上运行 ldd --version,它会在第一行输出 glibc 版本;也可以运行 getconf GNU_LIBC_VERSION。
将这 9 行视为一个梯子。在 glibc 2.41 的 Debian 13 上构建,结果只能运行在 Debian 13 或更新的系统上。在 glibc 2.31 的 Ubuntu 20.04 上构建,同一份源代码可以运行在该行以上的每个系统上,包括 Debian 13 服务器。必须支持的最旧服务器是唯一需要考虑的构建主机,因此,为其编译的所有程序,其 ABI 下限由你统一采用的发行版决定。应在服务器集群建立之前做出这个决定,并与 选择 VPS 运行的操作系统 一起权衡其他因素。
如何查找二进制文件所需的最低 glibc 版本?
直接从文件中读取。这适用于任何文件,包括供应商交付但未提供构建说明的二进制文件。
objdump -T ./mytool | grep -o 'GLIBC_[0-9.]*' | sort -Vu | tail -5最后一行就是最低要求。如果缺少 objdump,请安装 binutils 软件包。如果无法在该主机上安装任何软件,strings -a ./mytool | grep -o 'GLIBC_[0-9.]*' | sort -Vu | tail -5 也能得到接近的结果,因为这些标签以纯字符串形式存储在文件中。
readelf -V ./mytool 会以结构化形式显示相同的要求。查找以 Version needs section '.gnu.version_r' 为标题的代码块。该代码块每个标签列出一行 Name: GLIBC_x.y,并按必须提供该标签的库进行分组。
要确定哪个函数集设定了最低要求,请搜索该标签:objdump -T ./mytool | grep GLIBC_2.38。它通常对应单个符号,有时可以通过其他方式避开。如果 objdump -T 完全没有输出,说明该二进制文件是静态链接的,因此没有动态符号表,也没有可供输出的版本要求。
从 file 和 readelf -d 可以了解他人交给你的二进制文件什么信息
file 用一行同时显示架构和链接类型。
file ./mytool普通的 glibc 构建通常如下:
ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=..., for GNU/Linux 3.2.0, not stripped静态构建会显示 statically linked,并且不指定解释器。musl 构建会指定另一个解释器:
ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-x86_64.so.1, ...解释器是关键字段。它是内核在运行程序前启动的动态加载器。目标机器上必须存在这个准确路径。除非有人在 Debian 或 Ubuntu 服务器上安装了 musl,否则不会有 /lib/ld-musl-x86_64.so.1。
readelf -d ./mytool 按名称列出二进制文件所需的共享库:
Dynamic section at offset 0x2d58 contains 27 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libssl.so.3]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN/../lib]每个 NEEDED 条目都是硬性依赖,并按 soname 匹配。libssl.so.3 表示 OpenSSL 3。因此,只安装 libssl.so.1.1 的服务器无法加载该二进制文件。原因在于 soname:不同的 soname 表示 ABI 有意不兼容。RUNPATH 会添加加载器搜索二进制文件直接依赖项的目录。这些目录位于 LD_LIBRARY_PATH 设置指定的目录之后,但位于系统库缓存和默认路径之前。$ORIGIN 会展开为存放该二进制文件的目录。自包含软件包正是通过它查找自身库文件的。
不要对不信任的二进制文件运行 ldd。在 glibc 中,ldd 可能会通过让加载器运行程序来解析依赖项,因此恶意文件可以执行代码。readelf 和 objdump 只读取字节。在执行上述任何操作前,请确认下载的文件确实是项目发布的文件,因为根据项目发布的校验和验证下载文件是唯一能确认你持有的字节来自谁的步骤。
实际会看到的错误及其含义
加载器指出了找不到的 GLIBC 版本。该二进制文件基于比目标系统更新的 glibc 编译。直接在目标系统上安装软件包无法安全解决此问题。请从以下 4 个选项中选择一个。
对于一个明显存在的文件,shell 报告 No such file or directory。缺少的是解释器,而不是二进制文件。加载器路径在 ELF 头中缺失时,execve 会返回 ENOENT,shell 只能输出它收到的这条信息。运行 file 并读取解释器路径。在仅支持 glibc 的服务器上运行使用 musl 链接的二进制文件时,通常会出现完全相同的现象。
cannot execute binary file: Exec format error通常表示架构不匹配:例如在 arm64 服务器上运行 x86-64 二进制文件,或反过来。它也可能表示文件根本不是有效的可执行文件,例如下载内容被截断或文件已损坏。file 输出的第一行会告诉您文件使用的架构,或表明它不是可用的 ELF 可执行文件。
error while loading shared libraries: libssl.so.3: cannot open shared object file表示缺少 NEEDED 库,或者该库以不同的 soname 存在。请安装匹配的发行版软件包。不同发行版系列的软件包名称不同,因此从 README 复制 apt 行之前,请先完成转换;dnf 和 apt 命令对照涵盖了这种映射。
在 glibc 下运行正常的 musl 构建仅输出 Segmentation fault。通常是线程栈大小问题,选项 2 会介绍这一点。
发布便携式 Linux 二进制文件的 4 种方式
每种方案都会带来实际成本。应根据程序在运行时的行为选择方案,而不是根据哪种方案听起来最简洁来选择。
选项 1:基于您支持的最旧发行版构建
这是最无趣的答案,但通常是正确答案。在您承诺支持的最旧发行版容器镜像中进行编译。链接器只能记录该版本 glibc 实际具有的标签,因此最低版本会降到该发行版,同时二进制文件仍是普通的动态链接二进制文件,并保留 glibc 的全部行为。
docker run --rm -v "$PWD:/src" -w /src ubuntu:20.04 sh -c 'apt-get update && apt-get install -y build-essential && make'该输出可在 glibc 2.31 及更高版本上运行。请通过验证而不是假设来确认:对结果运行前文的 objdump -T 单行命令,并检查最高标签是否为您预期的值。
代价在于工具链。较旧的基础镜像也包含较旧的编译器;当代码需要较新的 C++ 标准时,这会造成问题。Go 和 Rust 基本可以避开这一点,因为它们的工具链会独立安装到容器中,不依赖发行版软件包。对于 C 和 C++,您可以从发行版自己的工具链渠道安装较新的编译器,也可以使用 manylinux 镜像。这些镜像正是为了将旧版 glibc 与当前 GCC 结合而存在的。Zig 捆绑的 C 编译器也可以直接面向指定的 glibc,例如 zig cc -target x86_64-linux-gnu.2.28;这样无需长期保留旧镜像,也能得到相同的最低版本。
方案 2:静态链接 musl
musl 是一个面向静态链接设计的小型 C 库。静态 musl 二进制文件包含自己的 libc,不指定解释器,并且可在架构匹配的任意 Linux 内核上运行。大多数单文件工具下载包都采用这种方式构建。
sudo apt install -y musl-tools
musl-gcc -static -O2 hello.c -o hello
file ./hello现在,file 应显示为 statically linked,且不包含解释器字段。对于 Rust,添加目标平台并针对该目标构建:
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-muslGo 不需要这些设置。使用 CGO_ENABLED=0 go build 时,生成结果已经是静态的,完全不链接 libc。
NSS 查询方式会发生变化。 glibc 通过 NSS(名称服务切换)解析用户和主机名。程序运行时,NSS 会使用 dlopen 加载 libnss_* 模块。静态链接的 glibc 无法执行此操作,链接器会输出以下警告:
warning: Using 'getaddrinfo' in statically linked applications requires at runtime the shared libraries from the glibc version used for linkingmusl 完全不实现 NSS,因此不会出现此警告。它使用自己的解析器,并直接读取 /etc/resolv.conf 和 /etc/hosts。在普通 VPS 上,这种方式没有问题,而且更简单。但如果主机上的账户或名称来自 LDAP 或 SSSD,其他程序都能看到这些信息,而你的二进制文件看不到。musl 的解析器也比 glibc 更新较晚:对于 UDP 响应无法容纳的过大 DNS 响应,musl 于 2023 年在 1.2.4 中加入了 TCP 回退支持。使用较旧 musl 构建的程序无法查询大型 DNS 记录;如果递归名称服务器在截断的 UDP 响应中不返回部分结果,这些查询还可能失败。
dlopen 无法工作。 在静态 musl 二进制文件中,dlopen 是一个始终失败的存根。任何在运行时加载代码的功能都无法使用,包括插件系统、PAM 模块、GPU 驱动程序,以及 glibc 自带的 iconv 字符集模块。如果程序需要 dlopen,就不能使用静态链接,必须采用其他 3 种方案之一。
安全更新由你负责。 动态链接的二进制文件会在服务器执行软件包升级后立即获得 libc 修复。静态二进制文件不会自动获得修复。当某个 CVE(常见漏洞和暴露)条目涉及你的 libc,或涉及你打包进程序的静态 OpenSSL 时,你必须重新构建并重新分发程序。已经部署的每个副本都会持续存在漏洞,直到有人替换该文件。请记录链接进程序的组件,因为目标系统无法告知管理员这个单文件二进制文件包含 2 年前的库。
许可证也会发生变化。 glibc 使用 LGPL,静态链接会触发 LGPL 的重新链接义务:你必须向接收者提供所需材料,使其能够将程序重新链接到其他 glibc。musl 使用 MIT 许可证,不附带此类条件。项目发布单文件二进制文件时选择 musl 而不是静态 glibc,主要原因就在于此。
两个运行时差异会导致难以诊断的崩溃。 musl 的默认线程栈大小为 128 KiB。glibc 根据程序启动时的栈限制设置默认值,通常为 2 到 10 MB;当该限制为 unlimited 时,大多数架构会回退到 2 MB。因此,在分配大量线程栈缓冲区的代码中,使用 musl 可能导致段错误,且没有错误消息或日志记录。使用 pthread_attr_setstacksize 显式设置大小,或将缓冲区移到堆上。musl 的分配器也更注重小型分配和可预测行为,而不是支持大量线程同时分配,因此分配密集型多线程程序的运行速度可能明显更慢。请针对自己的工作负载进行基准测试,不要依赖任何一个库的既有评价。
选项 3:将加载器和库一起打包
将库与二进制文件放在同一目录,并附带匹配的加载器,然后通过该加载器启动程序。
./ld-linux-x86-64.so.2 --library-path ./lib ./mytool要使设置永久生效,请使用 patchelf 将这些路径写入文件:
patchelf --set-interpreter "$PWD/lib/ld-linux-x86-64.so.2" --set-rpath '$ORIGIN/lib' ./mytool只有一条规则决定这是否可行:加载器和 libc.so.6 必须来自同一个 glibc 构建版本。它们是匹配的一对。将主机的加载器与打包的 libc 混用,会导致程序在启动时崩溃,而不是显示可读的错误信息。要么同时打包两者,要么都不打包。
AppImage 将这种模式打包为一种格式:有效负载位于 squashfs 映像中,并由一个小型运行时负责挂载。很多人会忽略一个特性:大多数 AppImage 使用主机提供的 glibc,而不是将其打包在内。因此,在当前发行版上按这种方式构建的 AppImage,在旧服务器上仍会因相同的版本错误而失败。AppImage 官方最佳实践建议,在不高于所支持最旧系统的基础环境上进行构建。因此,这类 AppImage 是叠加在选项 1 之上的交付格式,而不是选项 1 的替代方案。也可以将 glibc 一起打包:appimage-builder 和 go-appimage 都支持将 glibc 放入 AppImage 中。按这种方式构建的 AppImage 可以在比构建系统更旧或更新的发行版上运行。代价是文件更大,而且 libssl 等库会固定在打包内容中。
在无头服务器上,AppImage 需要 FUSE(用户空间文件系统)来挂载其有效负载,而精简的 VPS 映像通常不包含 FUSE。使用较旧运行时构建的 AppImage 需要主机上的 FUSE 2 库。在 Debian 和 Ubuntu 中,该库的软件包名为 libfuse2;在 Ubuntu 24.04 中则改名为 libfuse2t64。缺少该库时,程序会以 AppImages require FUSE to run. 退出。较新的 AppImage 内置了 FUSE 3 支持,因此不再需要在主机上安装 libfuse。使用 ./App.AppImage --appimage-extract-and-run 可以完全跳过挂载过程:该选项会将内容解包到临时目录,然后从该目录执行。
选项 4:发布容器镜像
将整个用户空间与程序一起交付。镜像自带 libc,因此主机的 glibc 不再重要,只需内核和架构匹配即可。这是最直接的选项,也消除了整类问题,因此许多服务器软件都采用这种分发方式。在 VPS 上运行 Docker 是使用它的常见方式。
主机内核仍会设置限制。较新的 glibc 会使用较新的系统调用,旧版容器主机可能会阻止这些调用:glibc 2.34 及更高版本会使用 clone3,而旧版默认 seccomp 配置文件会拒绝该调用。其表现是立即失败,并显示 Operation not permitted,但完全不提 glibc。升级主机上的容器运行时即可解决,因为阻止规则位于运行时的系统调用过滤器中,而不是内核中。
这些成本都很常见。目标环境需要容器运行时,并且当前用户必须有权使用它。下载内容会从一个文件增加到几十或几百 MB。现在还需要负责基础镜像及其补丁计划,因此选项 2 中规避的安全维护工作会以重建镜像的形式重新出现。对于长期运行的服务,这种取舍通常合理。对于只运行一次的命令行工具,则不值得。
应选择哪个选项?
- 在由您控制、且全部运行同一发行版的服务器上使用的内部工具:在该发行版上动态构建,然后停止在这一步。
- 供陌生用户下载并运行的单个文件:如果程序不需要
dlopen,也不需要基于 NSS 的查找,则使用静态 musl。 - 需要插件或 GPU 访问的程序:保持动态链接,在较旧的基础环境上构建,并使用选项 3 交付。
- 在已具备运行时的计算机上长期运行的服务:交付镜像。
无论选择哪种方式,都应记录下来并进行检查。构建主机上的 glibc 现在已成为发布流程的一部分。将构建机从一个 LTS 版本升级到下一个版本,可能会在不知不觉中提高最低兼容版本,导致上个月还能正常运行的用户现在无法使用。按标签固定构建镜像,并在构建步骤中断言输出中的最高 GLIBC_ 标签。这样检查会在您的流水线中失败,而不是在其他人的终端中失败。
FAQ
为什么服务器上会出现“找不到 version GLIBC_2.38”?
该二进制文件是在 glibc 版本高于服务器的计算机上编译的。glibc 的符号版本机制只提供向前兼容:旧二进制文件可以在新版 glibc 上运行,但新二进制文件不能在旧版 glibc 上运行。这是因为加载器需要读取文件中记录的确切版本标记,而旧版 libc 从未包含该标记。直接在服务器上安装其他组件无法安全解决此问题。请使用较旧的基础镜像重新构建,发布静态 musl 构建版本,将库文件及其匹配的加载器一起打包,或发布容器镜像。
如何确定二进制文件需要哪个 glibc 版本?
使用 objdump -T ./mytool | grep -o 'GLIBC_[0-9.]*' | sort -Vu | tail -5 读取文件中的版本标记。最后一行表示能够加载该文件的最低 glibc 版本。readelf -V ./mytool 会在 .gnu.version_r 节中显示相同的依赖要求,并按必须提供这些要求的库进行分组。如果 objdump -T 没有输出,说明该二进制文件是静态链接的,完全不需要 glibc。将结果与服务器通过 ldd --version 获取的 glibc 版本进行比较。
musl 二进制文件是否比 glibc 二进制文件慢?
这取决于工作负载,最可靠的方法是实际测量。静态 musl 二进制文件启动更快,因为执行时不需要运行加载器,也不需要进行重定位。另一方面,musl 的内存分配器侧重于体积小和行为可预测,而不是支持大量线程同时分配内存;此外,musl 手工优化的字符串和内存例程少于 glibc。因此,对于分配密集型或字符串处理密集型的多线程程序,速度可能明显更慢。在接受任何结论前,请在自己的服务器上测试自己的程序。
为什么文件明明存在,bash 却提示“No such file or directory”?
缺少的是动态加载器,而不是二进制文件本身。内核会从 ELF 头中读取解释器路径。如果该路径不存在,内核就会返回 ENOENT;shell 只能将其报告为这一条消息。运行 file ./mytool,然后读取 interpreter 字段。如果在 Debian 或 Ubuntu 服务器上显示 /lib/ld-musl-x86_64.so.1,说明你使用的是在 glibc 系统上运行的 musl 链接二进制文件。请安装发行版提供的 musl 运行时(Debian 和 Ubuntu 上的 musl 软件包),或者改用该程序的 glibc 构建版本或静态构建版本。
可以从更新的服务器复制 libc.so.6 来解决这个问题吗?
不可以。libc.so.6 和 ld-linux-x86-64.so.2 是同一个 glibc 构建中的匹配组件,机器上的每个动态链接 glibc 程序都会使用它们。因此,覆盖系统副本可能导致服务器几乎无法运行任何程序,包括用于恢复更改的工具。如果必须在旧主机上运行一个较新的二进制文件,请将新版 glibc 解包到专用目录,并通过其自身的加载器使用 --library-path 启动该程序。这样只会影响该进程。针对旧版 glibc 重新构建,仍然是不会在 6 个月后带来意外问题的方案。