上个月接到一个私有化交付需求,客户要求我们提供一套离线可部署的数据库环境,PostgreSQL 镜像里必须内置 postgis、pg_cron、wal2json 这类常用扩展,最好还能随时按需加料。以前遇到这种需求,我第一反应是手写 Dockerfile,然后被依赖问题折磨到怀疑人生。后来换了思路,用 pig 来做 postgresql 镜像,整个过程才算是真正理顺了。这篇文章就把我这次的实操过程和踩坑记录整理出来,给同样在做数据库镜像交付的同行一点参考。
网上聊 postgresql 安装、postgresql 教程的内容很多,但是真正深入到“怎么把扩展干净利落地打进镜像”这个层面的资料其实不多。我踩过不少坑之后发现,pig 这种构建工具非常适合解决“扩展依赖地狱”和“多版本维护”这两个核心痛点。无论你是 DBA、运维,还是做平台工程的,只要你有过手动编译 PostgreSQL 扩展的经历,下面的内容应该能帮你省下大量时间。
1. 为什么我放弃手写 Dockerfile,改用 pig 做 postgresql 镜像
1.1 手写 Dockerfile 的痛:依赖地狱
先说说我以前的做法。那时候为了交付一个带 postgis 的 PostgreSQL 镜像,我的 Dockerfile 大致长这样:基于官方 postgres:16 镜像,然后 apt 装一堆依赖,再下载 postgis 源码手动编译,最后往镜像里拷 so 文件和 extension 控制文件。第一次构建一切顺利,但等 PostgreSQL 发小版本更新,或者客户要求把 postgis 从 3.3 升到 3.4,噩梦就开始了。
postgis 本身依赖 geos、proj、gdal 这一堆地理空间库,每个库又有自己的版本要求,版本不匹配轻则编译失败,重则编译成功后运行时报错。timescaledb 更麻烦,它跟 PostgreSQL 的小版本号强绑定,扩展版本和内核版本差一个小版本,启动时直接拒绝加载。wal2json 这种逻辑解码插件又需要精确匹配 PostgreSQL 内核头文件,稍微换个版本就得重新编译。
这种“现场编译”的方式在开发环境勉强能用,一旦到了生产交付场景就非常被动。客户现场往往没有外网,也没法保证 apt 源可用,一个依赖下载失败,整个交付流程就卡住了。而且 Dockerfile 里那一大堆 apt-get install 和源码编译命令,本质上很难审计,构建出来的镜像到底装了哪些扩展、哪个扩展是哪个版本,全靠我自己的安装记录,这显然不满足企业级交付的要求。
1.2 pig 是什么,能解决什么问题
后来我接触到 pig,它的全称其实是 Pigsty Builder,是开源 PostgreSQL 发行版 Pigsty 的配套构建工具,现在也作为独立项目维护。pig 做的事情可以一句话概括:把“PostgreSQL 内核 + 常用扩展 + 系统依赖”统一编译打包,产出可直接分发的二进制包和容器镜像。
和手写 Dockerfile 相比,最大的区别是:pig 把扩展当“软件包”来管理,而不是当“源码”来编译。你想做包含任意扩展的 postgresql 镜像,不需要自己去处理每个扩展的依赖关系,只需要在配置文件里声明“我要哪些扩展”,pig 会根据你选择的 PostgreSQL 版本,编译好对应的内核和扩展,再按你的要求组装成不同格式的交付物。
我最初用 pig 的目的是做一套内部统一的 PostgreSQL 交付镜像,后来发现它的价值远不止于此:可以批量产出多个 PostgreSQL 主版本的二进制包,可以同时构建 amd64 和 arm64 架构,还可以把 deb/rpm 包和容器镜像一次全部打出来,甚至能脱离 Pigsty 单独使用。对我这种既要管线上环境、又要做私有化交付的人来说,它已经把数据库“产品化”这件事解决了一大半。
1.3 扩展的“任意”到底是什么意思
先泼一盆冷水:所谓的“任意扩展”,不是说你随便拿一个 GitHub 上的扩展源码,发个命令就能自动编译打进去。PostgreSQL 扩展本质上由三部分构成:控制文件(.control)、SQL 脚本(sql 目录)、动态库(.so)。装进镜像后,最关键的就是让 PostgreSQL 引擎在启动或执行 CREATE EXTENSION 时,能正确找到这些文件。
真正把“任意扩展”变成现实的技术路线,我习惯叫它“二进制扩展法”:提前在构建阶段把扩展编译成与目标 PostgreSQL 版本匹配的二进制,再连同其系统依赖一起打包进镜像。这样一来,镜像运行时不再需要编译器,也不需要 apt 源,扩展开箱即用。像 postgis 这种自带 C 依赖的扩展,底层库全部静态或动态打包进容器,交付后就是一个独立可运行的数据库镜像。
要实现这个目标,挑战在于依赖解析。下面是几个常见扩展在镜像制作时的难点对比:
| 扩展 | 主要依赖 | 常见坑 |
|---|---|---|
| postgis | geos, proj, gdal, libxml2 | 地理库版本连锁反应,升级容易翻车 |
| timescaledb | 与 PG 小版本严格匹配 | 内核版本不一致直接无法加载 |
| wal2json | PG 内核头文件 | GitHub 源码编译环境与镜像环境不一致 |
| pg_cron | libpq, 系统 cron 语义 | 扩展中若有 preload 需求,配置遗漏会导致启动失败 |
| pgvector | 相对简单,仅 PG 头文件 | 与旧版本 PG 的兼容性需要注意 |
| mysql_fdw | mariadb-connector-c | 需要额外数据源依赖,限制更多 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. pig 构建 postgresql 镜像的机制拆解
2.1 从源码到镜像的完整链路
pig 的构建流程可以从宏观上分成四段:编译内核、打包扩展、组装产物、输出镜像。
第一步是编译 PostgreSQL 内核。pig 会根据配置拉取对应版本的 PostgreSQL 官方源码,在指定的平台上完成编译。这一步会生成标准的 PostgreSQL 二进制文件、动态库、头文件,以及 contrib 模块。要注意的是,这一步是整个构建的根基,因为后面所有扩展都要基于这套二进制来编译。
第二步是构建扩展。pig 通过 features 机制管理扩展组件,每个扩展对应一套编译脚本和依赖描述。构建时,pig 会按照 features 的定义,逐个编译并安装扩展到临时目录。这一步的关键在于,扩展编译出来的 .so 文件必须和第一步生成的内核版本完全匹配,所以这两个阶段必须使用同一套 pg_config。
第三步是打包。编译产出的内容会被整理成三种格式的交付物:tar.gz 二进制包、deb/rpm 系统包,以及容器镜像内容目录。打包过程中,系统依赖也会被一并收集,比如 postgis 需要的 libproj、libgeos 等,都会被拷贝到对应的包目录中。
第四步是镜像组装。pig 会根据 target 配置,基于指定的基础镜像,把第三步产出的内容以分层形式写入镜像。这里用的思路和多阶段构建很像:最终镜像只保留运行时需要的文件,编译期产生的临时文件和工具链会被全部丢弃。
这四个阶段全部结束后,你会得到一个完整的交付目录,里面有安装包、镜像 tar 文件和构建日志,拿去做离线交付或者上传到内部镜像仓库都很方便。
2.2 关键要素:内核版本 vs 扩展版本
使用 pig 过程中,我踩过最大的一个坑就是版本匹配问题。PostgreSQL 的扩展,尤其是 C 语言扩展,是为特定版本的内核编译的,不能跨版本使用。原因在于 PG 内核的 Server Programming Interface 会随版本变化,扩展通过它调用内核功能,如果接口结构体定义不一致,轻则加载失败,重则内存错乱、进程崩溃。
举个例子,timescaledb 2.x 需要在编译时关联 PostgreSQL 主版本,官方提供的是按 PG 版本拆分的包。如果你在 PostgreSQL 16.3 上编译了 timescaledb,然后把它丢到 PostgreSQL 16.5 的镜像里,正常情况没问题,因为 PG 在同一个主版本内保持 ABI 兼容;但如果丢到 17 的镜像里,启动阶段就会报动态库找不到符号的错误。
还有一类扩展和内核版本耦合更深,比如 wal2json。它的源码利用 PG 内部宏和结构体做逻辑解码,不同主版本之间即使 API 没变,宏定义变了也可能导致行为不一致。所以我在用 pig 配置多版本镜像时,从来不让一个扩展版本跨多个 PostgreSQL 主版本复用,宁可多编一次,也要保证每个镜像里的扩展是对应当前内核版本编译出来的。
2.3 基础镜像怎么选
很多人会忽略基础镜像的选择,觉得“反正就是把二进制文件拷进去,哪儿都差不多”。实际上,基础镜像里的 glibc 版本、系统库、时区数据、locale 数据都会直接影响 PostgreSQL 的运行稳定性和扩展可用性。
我整理了一下几种常用的基础镜像选型:
| 基础镜像 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| debian:bookworm-slim | 体积较小,glibc 较新,生态好 | 需要科学配置软件源 | 通用生产镜像 |
| ubuntu:22.04/24.04 | 软件包丰富,文档多 | 体积偏大 | 开发环境、演示环境 |
| rockylinux:9 | 企业生态,和 RHEL 系兼容性好 | RPM 系依赖管理稍麻烦 | 传统企业、金融政企交付 |
| distroless 系 | 极小,攻击面小 | 排障困难,依赖缺失排查麻烦 | 对安全要求极高的封闭环境 |
我个人建议优先跟随 pig 官方默认的基础镜像体系,因为构建器在打包时会自动收集对应的系统依赖,如果你自己换一套陌生的基础镜像,很可能因为缺少底层库导致扩展启动失败。等熟悉流程后,再根据自己的安全规范调整基础镜像,会稳妥很多。
3. 实操:用 pig 制作带一组扩展的 postgresql 镜像
3.1 事前准备:环境与工具
我这次实操的环境是一台 8 核 16G 内存的 Debian 12 云主机,磁盘预留了 40G。这个配置对编译 PostgreSQL 和 postgis 这类大扩展来说算够用,但也谈不上舒适。如果你打算编译多个 PG 版本,建议内存至少 16G,磁盘至少 60G,因为每次构建都会产生大量中间文件。
构建机需要安装 Docker、git、make、curl 和 ca-certificates。Docker 用于最终的镜像打包,其他工具用于拉取源码和构建。执行下面的命令可以完成基础准备:
bash复制sudo apt update
sudo apt install -y git make docker.io curl ca-certificates gnupg lsb-release
sudo systemctl enable --now docker
git clone https://github.com/Vonng/pig.git
cd pig
要注意,构建用户必须有 docker 命令的执行权限,否则最后一步镜像组装会失败。最简单的方法是把当前用户加入 docker 组:
bash复制sudo usermod -aG docker $USER
执行完重新登录终端即可生效。另外建议在构建机上配置好 docker 国内可访问的镜像加速,否则拉取基础镜像时可能超时。
3.2 配置核心文件:pig.yaml
进入 pig 仓库目录后,你会发现根目录下有一个 pig.yaml 配置文件,这个是整套构建的核心。不同版本的 pig 配置键名可能略有差异,以下是我这次使用的配置片段,你可以对照自己拉下来的仓库 README 做参考:
yaml复制release: 2.9.1
# 目标 PostgreSQL 主版本
pg_version: 17
# 需要打进镜像的扩展特性
features:
- pgvector
- postgis
- pg_cron
- wal2json
- timescaledb
- mysql_fdw
- pg_repack
# 镜像仓库和标签
target:
registry: docker.io/yourname
tags:
- "17"
这里解释几个关键点。release 是构建版本号,会直接写进包名和镜像标签,建议和数据产品版本对应起来,方便后续排查问题。features 列表就是你要打进去的扩展清单,具体有哪些可选项,直接在仓库的 features 目录里 ls 一下就能看到。
如果你需要做离线交付,可以在配置里多加一个参数,让构建完成后自动把镜像导出为 tar 文件,这样即使客户现场没有镜像仓库也能导入。至于具体参数名,不同版本写法不同,构建前务必看一眼 README。
3.3 构建与产物
配置完成后,执行构建命令即可。不同版本的 pig 命令入口略有差别,通用做法是看 Makefile 或者执行 ./build.sh:
bash复制./build.sh 17
构建过程会输出大段日志,能看到内核源码下载、configure 配置、make 编译、扩展编译、打包镜像等步骤。首次构建耗时比较长,我这次从零开始构建一个带 postgis 和 timescaledb 的 PG 17 镜像,大概花了两个多小时,其中大头是地理库和 TimescaleDB 的编译。第二次构建会利用缓存,速度快很多。
构建完成后,你在仓库的输出目录里会看到类似下面的产物:
bash复制build/
├── prebuilt-17.2-2.9.1-xxx-amd64.tar.gz
├── postgresql-17.2-xxx.amd64.deb
├── postgresql-17.2-xxx.amd64.rpm
└── images/
└── docker.io/
└── yourname/
└── postgres-17-amd64.tar
tar.gz 是最小可用的二进制包,适合手动部署;deb/rpm 适合有标准安装规范的环境;images 目录下的 tar 文件就是可以直接导入到 docker 的镜像。一次构建,三种格式,对交付来说非常方便。
3.4 验证镜像是否真的可用
镜像构建完不能直接交付,必须本地先跑一遍,确认扩展能正常加载和创建。验证步骤分两层:先看扩展是否可见,再实际 CREATE EXTENSION 测试。
先用临时容器起一个实例:
bash复制docker run -d \
--name pg-test \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
docker.io/yourname/postgres:17
进入容器执行 SQL:
bash复制docker exec -it pg-test psql -U postgres
先查看扩展可用列表:
sql复制SELECT name, default_version, installed_version
FROM pg_available_extensions
WHERE name IN ('postgis', 'pgvector', 'pg_cron', 'timescaledb', 'wal2json');
如果这五个扩展都能查到对应版本,说明扩展文件已经正确放入镜像。然后再实际创建验证:
sql复制CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS timescaledb;
如果这几个语句都能成功,说明扩展依赖的动态库也都在,可以放心交付。注意 timescaledb 这类扩展在正式使用前通常需要配置 shared_preload_libraries,测试阶段如果不加载,只创建扩展对象通常也能通过,但要强调一下,等做生产配置时记得按官方文档加上 preload 参数。
4. 定制 postgresql 镜像的进阶玩法
4.1 通过外部包加入不在 features 里的扩展
features 列表覆盖了大部分主流扩展,但一定有你想要的某一个不在里面。这时候有两条路可以走。
第一条路是直接把扩展源码放进 pig 的构建路径,通过外部编译脚本处理。这适合对编译参数有特殊要求的扩展。另一条路相对简单:预先把扩展编译成 deb/rpm 包,然后让 pig 在构建时自动安装这些包并打进镜像。
举个例子,我之前需要内置 mysql_fdw,而镜像环境还需要 mariadb 的客户端库。我的做法是:先在 PGDG 源或官方仓库里下载对应版本 mysql_fdw 的 deb 包和 mariadb-connector-c 的依赖包,放到 pig 配置的 packages 目录下,再在配置里指定需要解析这些本地包。
这样构建出来的镜像运行后,扩展文件都在标准位置,pg_available_extensions 也能正确识别。至于具体的包目录路径,每个版本可能不一样,我自己的习惯是进入仓库先 README,找到 packages 章节再操作。
4.2 调整编译选项与裁剪镜像
默认构建出来的镜像是“标准版”,体积通常会比较大。加了 postgis 之后,镜像超过 1GB 是很正常的,因为地理空间库本身就是重量级依赖。如果你的交付环境对体积敏感,可以从两个方向做裁剪。
一个是调整基础镜像。在确认扩展依赖没问题的情况下,可以把基础镜像换成更精简的 Debian slim 甚至 distroless,体积能降不少。另一个方向是去掉编译产物里的文档、样例、调试符号。通过配置项控制是否生成文档、是否删除 .a 静态库文件,通常能省出 10%-20% 的空间。
不过裁剪要克制。我刚开始裁剪时把 postgis 的栅格驱动给去了,结果客户业务一跑,报缺少格式支持。后来我就学聪明了,裁剪只针对文档和调试信息,功能性的模块一律保留。镜像大一点无所谓,客户现场跑不起来才是大事故。
4.3 多版本与多架构批量出包
pig 最舒服的一点是它天然支持“一条配置,多版本产出”。我这边有一个脚本,循环调用构建器,一次性产出 PG 15、16、17 三个版本的镜像和安装包,配合 CI 流水线,每次发版都能保证三个版本同步更新。
多架构构建稍微麻烦一点。最稳妥的办法是在对应架构的构建机上各跑一次构建,因为有些扩展的依赖库在交叉编译环境下容易出现架构不匹配的问题。如果你有 CI,可以用矩阵构建同时在 amd64 和 arm64 的 runner 上执行,最终把两个镜像合并推送到同一个仓库 tag。
推送私有仓库也很简单,配置好 target 下 registry 地址之后,构建器会在镜像打包完成后自动 push。我们内部 Harbor 就是这么用的,版本号、架构、PG 版本全部体现在 tag 里,比如 postgres-17-amd64-2.9.1,生产环境照着 tag 拉取即可。
5. 常见问题与排查技巧实录
5.1 问题速查表
以下是我在过去一段时间里实际遇到过的典型问题,整理成一张速查表方便你对照排查:
| 现象 | 原因 | 解决方法 |
|---|---|---|
容器启动时报 could not access file "pgvector.so" |
扩展 .so 不在镜像 lib 目录,或 shared_preload_libraries 写错 | 检查镜像内扩展路径,查看 pg_config --pkglibdir |
| CREATE EXTENSION 报无法打开控制文件 | .control 文件缺失,或扩展未安装到 extension 目录 | 检查 extension 目录是否有对应控制文件 |
postgis 创建时报缺 libproj.so |
基础镜像缺少地理依赖 | 通过外部包机制把地理库一并打包进镜像 |
| timescaledb 加载后 PG 启动崩溃 | 扩展与 PG 小版本不匹配 | 用与目标 PG 版本匹配的 timescaledb 重新编译 |
| 构建时网络下载超时 | 源码和依赖包下载受阻 | 配置镜像加速、国内源或离线包缓存 |
| 镜像体积过大 | 包含编译产物和系统依赖包 | 裁剪文档、调试符号,或用更精简基础镜像 |
| arm64 镜像在 amd64 机器上无法启动 | 架构不匹配 | 在原生架构机器上构建,并打对应的 manifest 标签 |
5.2 三个我踩过的坑
第一个坑是扩展文件放错目录。有一次我手动往镜像里拷贝 pgvector 的 so 文件,自认为路径没问题,结果启动时 PostgreSQL 报找不到文件。后来发现这个扩展被安装到了系统全局 lib 目录,而 PostgreSQL 只认自己编译时配置的 pkglibdir 目录。用 pig 构建就不会有这个问题,因为它会自动感知 PostgreSQL 的安装前缀。但如果哪天你手动改了安装路径,务必先 pg_config --pkglibdir 确认一下实际目录。
第二个坑是 TimescaleDB 版本匹配。当时我从官网下载了一个预编译包,没仔细看是对应 PG 15 的,然后直接塞进了 PG 16 的镜像。结果 PostgreSQL 启动阶段直接崩溃,日志里只留下一句“segfault at ...”,排查了很久。后来把包换成 PG 16 对应的版本,问题立刻消失。这个教训让我在构建脚本里加了硬性校验:编译完成后立即检查扩展的版本标识是否和内核版本对应,不一致就 fail fast。
第三个坑是 glibc 版本不一致。我在一台 Ubuntu 22.04 上构建出扩展,放到基于 Debian 11 的基础镜像里,启动时报 GLIBCXX_3.4.30 not found。原因是我构建机上的较新 C++ 运行库在旧 glibc 环境里找不到对应符号。解决办法是统一构建环境和基础镜像的发行版版本,或者用多阶段构建把运行库一并带入。自那之后,我就把“构建环境与生产基础镜像保持同系发行版”写进了团队规范里。
5.3 有效定位扩展加载失败的经验
扩展加载失败是镜像交付里最经典的故障。定位思路我总结成三步:第一步看 PostgreSQL 日志,最常见的是 could not access file、cannot open shared object file、undefined symbol 这几类。第二步进入容器用 ldd 检查 .so 的动态库依赖:
bash复制docker exec -it pg-test bash
ldd /usr/lib/postgresql/17/lib/vector.so
如果有输出 not found,说明基础镜像缺少对应的系统动态库。第三步检查控制文件和 SQL 脚本:
bash复制ls /usr/share/postgresql/17/extension/vector.*
如果这里没有 .control 和 .sql 文件,说明扩展的元数据没有安装完整。把这三步走完,绝大多数扩展加载问题都能定位到具体原因。
写在最后的小建议
这套流程跑顺之后,我最大的感受是:做数据库镜像交付,最值钱的部分不是“把 Dockerfile 写好”,而是“把扩展的依赖关系搞清楚并固化下来”。pig 的意义就在于把这种依赖关系变成了配置化管理,让整个构建过程可复现、可审计、可批量执行。
如果你只是临时起一个开发环境玩一玩,手动写 Dockerfile 加 apt 安装反而更快;但如果你也要做多版本、多架构、离线交付这种“产品化”的事情,pig 带来的收益是立竿见影的。我个人现在每次构建完,都会顺手把扩展清单和版本信息写进镜像 LABEL,后续客户现场排查问题时,一条命令就能看清镜像内容,省去很多来回沟通的成本。
