不要更新!发布当日叫停:PG也躲不过大翻车

老话说的好,不要在星期五发布代码。前天刚发布的 PostgreSQL 例行小版本虽然特意避开了星期五发布,但却给社区加了一周的活 —— PostgreSQL 社区将于下周四发布一个非常规紧急小版本 PostgreSQL 17.2,16.6, 15.10,14.15,13.20,甚至是刚刚已经 EOL 的 PG 12 也会有 12.22…… 。

在过去十年里这是第一次出现这样的情况:在 PostgreSQL 发布日的当天,新版本就因为社区发现的问题而叫停。紧急发布的原因有两个,第一是修复 CVE-2024-10978 安全漏洞,这个不是大问题,真正的原因是:PostgreSQL 新的小版本修改了 ABI,导致依赖 ABI 的扩展崩溃 —— 比如 TimescaleDB。

关于 PostgreSQL 小版本 ABI 兼容性的问题,在今年六月 PGConf 2024 上,Yuri 在扩展峰会上和《Pushing boundaries with extensions, for extension》的演讲中其实已经抛出过这个问题,但并没有得到过多的关注。结果这次结结实实的爆炸了,我猜 Yuri 看到这个新闻肯定会耸耸肩说:Told you so。

总之,PG 社区强烈建议大家 不要 在最近一周升级 PostgreSQL,Tom Lane 提出的建议是在下周四紧急发布一个非常规小版本集回滚这些变化,然后覆盖老的 17.1,16.5,…… 将这些问题版本视作 “不存在”。所以,原定于这两天的发布,默认使用最新版本的 PostgreSQL 17.1 的 Pigsty 3.1 也会相应延期一周发布。

总体来看,我觉得这件事的影响是正面的。首先这并非内核核心本身质量的问题,其次因为发现的足够早,在发布当天就发现了并及时叫停,没有对用户产生实质影响 —— 不会像其他那些数据库/芯片/操作系统漏洞一发现已经爆炸一大片了。 除了极个别的狂热更新爱好者或者新装机的倒霉蛋,应该不会有多大影响。就好比上次 xz 后门事件,也是 PG 核心开发者 Peter 在PG测试中发现的,从侧面反映出了 PG 生态的活力与洞察力。


发生了什么

11月14号早上,PostgreSQL Hacker 邮件列表中出现了一封邮件,提到新的小版本实际上打破了 ABI 。这对于 PostgreSQL 数据库内核本身并不是什么问题,然而 ABI 的变化打破了 PG 内核与扩展插件的约定,导致像 TimescaleDB 这样的扩展无法在新的 PG 小版本上正确运行。

PostgreSQL 的扩展插件是针对具体操作系统发行版上的大版本提供的。例如,PostGIS ,TimescaleDB,Citus 会针对 PG 12,13,14,15,16,17 这样每年发布的大版本号进行构建。针对 PG 16.0 构建的扩展,大家都默认可以在 PG 16.1,16.2,…… 16.x 上继续使用。 这意味着你可以滚动升级 PG 内核的小版本,而不用担心扩展插件翻车。

然而这并不是一个明确的承诺,而是社区的隐性默契 —— ABI 属于内部实现细节,也不应该有这样的承诺与期待,PG 只是在过去表现的太好了,而大家已经习惯了这一点,将其默认作为了工作假设,并体现在包括 PGDG 仓库包命名,安装脚本等各个方面。

不过这一次,PG 17.1 以及反向移植到 16 - 12 的小版本修改了一个内部结构体的大小,这会导致 —— 针对 PG 17.0 编译的扩展插件在 17.1 上使用时,有概率发生冲突,导致非法写入或程序崩溃。请注意,这个问题对使用 PostgreSQL 内核本身的用户并没有影响,PostgreSQL 在内部有断言来检查这种情况。

然而对于使用 TimscaleDB,这样扩展插件的用户来说,这意味着如果你没有使用针对当前小版本重新编译的扩展插件,将会存在这样的安全隐患。从目前 PGDG 仓库的维护逻辑上来看,扩展插件只会在新的扩展版本出来时,针对当下最新的 PG 小版本进行编译。

关于 PostgreSQL ABI 的问题,来自 CrunchyData 的 Marco Slot 写了一篇详细的推文来解释。供专业读者阅读参考。

https://x.com/marcoslot/status/1857403646134153438


如何规避这样的问题

正如之前我在《PG神功大成,最全PG扩展仓库》中所说,我针对 EL 与 Debian/Ubuntu 维护了一个包含许多 PG 扩展插件的仓库,占了整个 PG 生态近一半的扩展。

PostgreSQL ABI 的问题,其实 Yuri 之前提到过。只要你的扩展插件是针对当前使用小版本的 PostgreSQL 编译的,就不会有问题。所以每当新的小版本发布时,我都会重新编译打包这些扩展插件。


上个月,我刚刚针对 17.0 编译完了所有的扩展插件,这几天正在针对编译 17.1 的版本启动更新,结果看上去不用做了,17.2 回滚 ABI 变化,虽然意味着 17.0 上编译的扩展可以继续用,但我还是会在 17.2 后释出后,重新针对 PG 17.2 与其他主版本重新编译打包。

如果你是习惯于从互联网在线安装 PostgreSQL 与扩展插件,并且没有及时升级小版本的习惯,那么确实会有这样的安全隐患 —— 即你新安装的扩展并非针对老版本的内核编译,遇到 ABI 冲突而翻车。


老实说,我很早就在真实世界见到过这个问题,这也是为什么我在开发 Pigsty 这个开箱即用的 PostgreSQL 发行版时,从 Day 1 就选择了先将所有所需软件包及其依赖下载到本地,构建一个本地软件源,然后给环境中所有需要的节点提供 Yum/Apt 仓库的方式进行安装。这样做能够确保:整个环境中所有的节点安装的都是同样的版本,而且是一个一致性快照 —— 扩展的版本与内核的版本是匹配的。

而且,这样做还可以实现“自主可控”的需求,这意味着当你的部署上线之后,你不会遇到这种SB事情 —— 原本的软件源关停或者挪窝了,或者仅仅是上游仓库发布了一个不兼容的新版本或者新依赖,就会导致你新装机器/实例的时候遇到大翻车卡在这里。这意味着你有进行复制/扩容的完整软件副本,有能力让你的服务运行到地老天荒,而不用担心被人 “真·卡了脖子”。


比如最近 17.1 发布的时候,RedHat 赶在两天前更新了 LLVM 默认的版本,从 17 到 18,而且好死不死的只更新了 EL8 没有更新 EL9,如果用户选择在这个时候从互联网上游安装,就会直接失败。我给 Devrim 提了这个问题后,他花了两个小时修复,把 LLVM-18 加入到 EL9 专用补丁Fix仓库。

PS:如果你不知道这个独立的仓库,那你大概在修复后也会继续遇到翻车,直到 RetHat 自己修复这个问题,但 Pigsty 就会替你处理好所有这些肮脏的细节。


有人说我用 Docker 也能解决这样的版本问题,确实没错。只不过 用 Docker 跑数据库还会有其他的问题,而且,这些 Docker 镜像容器里其实本质上也是在 Dockerfile 里用操作系统的包管理器,从官方软件源给你下载 RPM/DEB 包来安装的。说到底,这些活总是要有人来做的 ……。

当然,适配不同操作系统意味着很大的维护工作量。例如,我维护了 143 个 EL 和 144 个 Debian 中的 PG 扩展插件,每个扩展插件都要针对 10 个操作系统大版本(el 8/9,Ubuntu 22/24,Debian 12,五个大系统,amd64 与 arm64),与 6 个数据库大版本(PG 17-12)进行编译,这些要素的排列组合意味着总共将近有一万个软件包需要构建/测试/分发,其中还有二十个一编译就半小时过去的 Rust 扩展……。不过老实说,反正都是半自动化流水线,从一年跑一次变成3个月跑一次,也不是不能接受。


附:关于 ABI 的问题的解释

关于最新补丁版本(17.1、16.5 等)中的 PostgreSQL 扩展 ABI 问题

PostgreSQL 扩展的 C 代码会包含来自 PostgreSQL 本身的头文件。当扩展被编译时,头文件中的函数在二进制文件中表示为抽象符号。这些符号在扩展加载时根据函数名链接到实际的函数实现。这样,一个针对 PostgreSQL 17.0 编译的扩展通常仍然可以加载到 PostgreSQL 17.1 中,只要头文件中的函数名和签名没有改变(即应用程序二进制接口或 “ABI” 是稳定的)。

头文件还声明了传递给函数的结构体(以指针形式)。严格来说,结构体的定义也是 ABI 的一部分,但其中有更多的细微之处。编译后,结构体主要由其大小和字段的偏移量定义,因此例如名称的改变不会影响 ABI(虽然会影响 API)。大小的改变会稍微影响 ABI。大多数情况下,PostgreSQL 使用一个宏(“makeNode”)在堆上分配结构体,它会查看结构体的编译时大小,并将字节初始化为 0。

在 17.1 中出现的差异是,向 ResultRelInfo 结构体中添加了一个新的布尔值,这增加了其大小。接下来发生的事情取决于谁调用了 makeNode。如果是 PostgreSQL 17.1 的代码,那么它会使用新的大小。如果是一个针对 17.0 编译的扩展,那么它会使用旧的大小。当它使用旧大小分配的指针调用 PostgreSQL 函数时,PostgreSQL 函数仍然假定新的大小,并可能写入超出已分配块的区域。一般来说,这是相当有问题的。它可能导致字节被写入不相关的内存区域,或者程序崩溃。

在运行测试时,PostgreSQL 有内部检查(断言)来检测这种情况并抛出警告。然而,PostgreSQL 使用自己的分配器,总是将分配的字节数向上取整到 2 的幂次方。ResultRelInfo 结构体是 376 字节(在我的笔记本电脑上),因此它会向上取整到 512 字节,变更后也是如此(384 字节在我的笔记本电脑上)。因此,通常这个特定的结构体改变实际上并不影响分配大小。可能存在未初始化的字节,但通常通过调用 InitResultRelInfo 来解决。

这个问题主要在扩展中分配 ResultRelInfo 的测试或启用断言的构建中引发警告,特别是在使用针对旧 PostgreSQL 版本编译的扩展二进制文件运行这些测试时。不幸的是,故事并未就此结束。TimescaleDB 是 ResultRelInfo 的重度用户,并且确实遇到了大小变化带来的问题。例如,在其某个代码路径中,它需要在一个 ResultRelInfo 指针数组中找到索引,为此它进行了指针运算。这个数组是由 PostgreSQL 分配的(384 字节),但 Timescale 二进制文件假定为 376 字节,结果是一个无意义的数字,进而触发断言失败或段错误。 https://github.com/timescale/timescaledb/blob/2.17.2/src/nodes/hypertable_modify.c#L1245…

这里的代码实际上并没有错误,但与 PostgreSQL 的契约并非如预期的那样。这对我们所有人都是一个有趣的教训。其他扩展中也可能存在类似的问题,尽管没有多少扩展像 Timescale 这样高级。另一个高级扩展是 Citus,但我进行了验证,发现 Citus 是安全的。它确实会显示断言警告。建议大家保持谨慎。最安全的做法是确保扩展使用您正在运行的 PostgreSQL 版本的头文件进行编译。

Last modified 2024-11-22: update minio/etcd docs (345b8442)