glibc vs musl 实测:镜像从 74.8MB 到 7.8MB,代价是什么

🔑 关键词:glibc, musl, Alpine, 基础镜像, 动态链接

📖 摘要:同一个 C 服务换到 Alpine 后崩了三次的经历,聊聊 glibc 和 musl 在 DNS、线程栈、iconv、NSS 上的真实差异,以及基础软件选型里那些没人写进评估表的成本。

先给数字,免得有人说我在讲故事

图片

同一台 2C2G 的机器,docker images 打出来,debian:bookworm-slim 是 74.8MB,alpine:3.19 是 7.8MB,中间差 67MB。我做的东西不大,一个不到 3000 行的 C 服务,用 cgo 挂了 libcurl 和 libpq,说白了就是个转发加落库的小玩意。为了省这 67MB 换到 Alpine,我在这上面花掉的时间,够把这个服务重写一遍。所以这篇不是“Alpine 好不好用”的站队帖,我想聊的是基础软件选型这件事本身,它跟你以为的评估方式不太一样。大多数人评估基础软件用的是功能清单,但真正决定你半夜要不要爬起来的是边界条件——locale、DNS、时间、线程栈、文件锁。

第一个坑在运行期,而且是间歇性的

换过去之后,服务大概跑四五个小时会卡住一次,日志里什么都没有,请求就挂在那儿。我先怀疑连接池,去 pg 那边看,连接是活的。strace 上去才发现是 DNS。musl 的解析器是自带的一套实现,它不读 /etc/nsswitch.conf,也不支持 NSS 模块,直接看 /etc/hosts 和 /etc/resolv.conf。我这台机器配了两个 nameserver,上游偶尔会吐一个我根本没配 v6 的 AAAA 记录出来。glibc 会按 RFC 3484 那套规则做地址排序,挑可用的先用;musl 不做这件事,按返回顺序来。第一个地址是个走不通的 v6,connect 就在那儿等超时。这问题在 Debian 上永远不会出现,因为 glibc 悄悄帮你兜了。我把这个叫基础设施的隐性服务——你以为你在调 getaddrinfo,其实你在调 glibc 顺手帮你做的一大堆判断。

图片

第二个坑是栈,128KB 对 8MB

我服务里有个解析 JSON 的递归函数,写得不太讲究。musl 编译出来的程序,pthread 默认栈大小是 128KB,glibc 是 8MB,差 64 倍。同一份代码在 Debian 上跑了一年没事,在 Alpine 上直接段错误。查了半个晚上才反应过来不是逻辑 bug 是栈不够。当然你可以 pthread_attr_setstacksize 去改,但问题是你得先知道有这回事。这类差异最麻烦的地方在于它不是报错,它是行为不同,你没有任何报错信息可以搜,只能靠经验或者靠读源码。musl 的源码确实好读,一万多行出头,比起 glibc 那种百万行级别的体量,一个周末真能翻完。顺便还有第三个坑:musl 的 iconv 支持极窄,基本就是 UTF-8 和 Latin-1 之间转,手上但凡有 GBK 历史数据要迁,直接就挂。

图片

glibc 也不是没问题,它的问题是大

镜像大是一方面,安全面大是另一方面,你去看 CVE 列表,glibc 一年几十个是常态。更值得说的是它的 ABI 兼容承诺——2021 年 8 月发的 glibc 2.34,把 libpthread、libdl、libutil、libanl 全合并进 libc.so.6 了,理由是这几个库本来就只是 libc 里的几个符号。对绝大多数人没影响,但如果你构建脚本里硬写了 -lpthread 还做静态链接,或者用 dlsym 去 libdl 里翻符号,就会碰上找不到符号或者链接器报警。老代码在 glibc 上跑得越久,这种历史包袱越厚。所以 glibc 的兼容不是白给的,它是靠不停往里塞兼容层维持住的。你用 glibc,本质上是在用别人几十年攒下来的包袱,换你自己的省心。

所以真正该问的问题不是哪个更快

图片

是你想把 debug 账单付给谁。选 musl,你把账单付给边界情况——DNS、locale、线程栈、iconv、NSS,这些平时不出事,一出就是深夜。选 glibc,你把账单付给体积和攻击面,换来的是“我遇到的坑百度上都能搜到”。这两个选择都不算错,错的是你以为自己在做一个纯技术决策。还有一层我想多说一句:ABI 稳定性经常被当成上游的恩赐,其实它更像一份把复杂度从上游转移到下游的合同。Linux 内核那句“我们绝不破坏用户空间”听着很硬气,代价是内核里堆着一大把没人敢删的旧接口。你去想想,你愿不愿意为这些接口付镜像体积。

基础软件这行的另一个真相是人

图片

很多关键组件的维护者就一两个人。2024 年 3 月那件事是最好的例子:xz(准确说是 liblzma)被塞后门,编号 CVE-2024-3094,影响 5.6.0 和 5.6.1。发现它的是微软的 Andres Freund,他当时在做 PostgreSQL 的性能测试,注意到 SSH 登录变慢、sshd 异常占 CPU,顺着查才挖出来。而投毒那边,Jia Tan 花了两年多时间在社区里做贡献、拿提交权限。xz 的原始维护者 Lasse Collin 一个人扛了很多年。我对这事的解读跟大多数人不太一样,大家在讨论供应链安全,我看到的是维护预算这个更土的问题——一个下载量以十亿计的压缩库,维护者可能连个全职都没有。这笔钱没人出,最后就变成别的人替你“出”。

我自己的判断清单

那到底怎么选,我把我踩完之后的判断标准写下来,不一定对,仅供参考。第一,如果你的镜像里已经塞了 Python、Java、Node 这种运行时,那基础镜像这几十 MB 差异可以忽略,别折腾,Debian slim 或者 Ubuntu 就行。第二,如果是 Go 纯静态编译、cgo 关着,那去哪都行,Alpine 没问题,scratch 也行。第三,如果你开了 cgo,或者用 C/C++/Rust 的 glibc target,先问自己三个问题:代码碰不碰 LDAP/AD?碰不碰非 UTF-8 编码?有没有深递归或者大栈帧?任何一个是“有”,老老实实用 glibc 系的镜像。第四,真要用 Alpine,在测试环境跑满 48 小时再上,别构建成功就上线,并且记得显式调用 pthread_attr_setstacksize 把栈顶上去,别指望默认值。

图片

最后

我把服务挪回了 debian:bookworm-slim。67MB 磁盘对我这台机器真的无所谓,多出来的那点内存占用也无所谓。我算了算,那一周我花在这上面的时间大概 11 个小时,按我接外包的报价,够买这台 VPS 跑三年。当然这个账不能这么算,因为我是顺便学了点东西——但老实说,这种“顺便”的成本,在选型评估表里几乎没有人会写进去。下次再有人问我 Alpine 好不好,我的回答是:先看看你的 getaddrinfo 后面藏着什么东西。

🏷️ 标签: