做PostgreSQL镜像最烦的一步,不是装数据库,而是装扩展。官方 postgres 镜像只带 contrib,想加个 pgvector 或者 PostGIS,要么改一堆 Dockerfile,要么在容器里重新装编译依赖,最后镜像体积动不动就蹭到几个 GB。我最近试着用 pig 这个无守护进程的镜像构建工具,把“先准备好目录,再打包镜像”的思路落到 PostgreSQL 上,实测下来非常顺:先在一台机器上装好 PG 和任意扩展,再把整个目录结构导出成 OCI 镜像,一条命令就能生成一个自带全部扩展的 postgresql 镜像。这篇文章就把整套流程完整拆给你看,从环境准备、扩展编译、pig 打包,到 docker-compose 部署和常见坑位,一次性讲清楚。
1. 项目概述:为什么需要“自带任意扩展”的 PG 镜像
1.1 常规构建方式的痛点
很多团队在建 PostgreSQL 镜像时,第一反应是写 Dockerfile,然后 RUN apt-get install 或者现场编译。这个方式看起来简单,用起来槽点很多:
- 编译过程全留在镜像层里。Dockerfile 里一旦出现
RUN make && make install,哪怕后面你在同一层里rm -rf 源码目录,历史层里依然留着源码和编译中间文件,整个镜像体积被撑得巨大。更常见的是很多人根本没清理,直接把 git clone 和 build 目录都打进层里。 - 扩展版本没办法灵活切换。apt 仓库里的扩展版本往往比较保守,比方说 pgvector 推出新版本支持 HNSW 索引时,apt 源可能还停在老的 IVFFlat 实现。想用新特性只能源码编译,而源码编译要用到
postgresql-server-dev、gcc、make这些运行期根本用不到的东西,造成了巨大的层污染。 - 构建过程依赖 daemon 和网络,CI 里容易翻车。Docker build 需要 dockerd 正常工作,在 Kubernetes 里或者安全扫描严格的流水线中,经常没有这个条件;网络不稳定时 apt 装包失败还会导致整条流水线中断。
- 官方镜像缺少扩展管理思路。官方 PostgreSQL 镜像就是“数据库本体 + contrib”,而你真正需要的是
postgis、vector、timescaledb、citus这些扩展,也往往需要同时支持好几个,靠官方 image 完全做不到。
所以我们的核心诉求是:把“编译扩展”这个过程从“每次建镜像时运行”改成“只跑一次,结果固化成镜像”。这也是我选择 pig 的最根本原因。
1.2 pig 是什么,为什么选它
先说明一下,pig 在开发者工具里其实有好几个同名项目。我这里说的是无守护进程、能把指定目录直接打包成 OCI/Docker 镜像的命令行工具,和 Docker 官方的 BuildKit 是两类东西。它的工作方式非常朴素:输入一个完整的根文件系统目录,输出一个符合镜像规范的镜像层,整个过程不依赖 dockerd,不需要特权容器,普通用户就能跑。
这个“目录即镜像”的思路对 PostgreSQL 特别合适。因为我们真正想要的不是“让 Dockerfile 在构建时帮我干一堆活”,而是“我已经把整个系统准备好了,你把它固化成一个镜像就行”。一旦把 rootfs 做出来,后续部署到任何一台有运行时的机器上,直接用镜像启动就能拿到完整的、带扩展的数据库服务。
下表是它和常见构建工具的对比:
| 工具 | 依赖守护进程 | 权限要求 | 典型场景 | 和 pig 的核心差异 |
|---|---|---|---|---|
| docker build | 需要 dockerd | 需要 root/daemon 权限 | 常规镜像构建 | 层多,长 RUN 清理困难 |
| kaniko | 不需要 | 需要部分特权 | CI 里无 daemon 构建 | 从 Dockerfile 出发,逐行执行 |
| buildah | 不需要 | 普通用户可准备 | 构建和管理本地镜像 | 有容器隔离概念,命令较重 |
| pig | 不需要 | 普通用户可打包 | 把现成文件系统固化成镜像 | 不需要 Dockerfile,目录直接成镜像 |
如果你问“我能不能用 buildah/kaniko 做同样的事”,答案是当然可以,但 pig 这条路最轻。尤其在后面对接 CI、离线环境、内网镜像仓库时,少一个守护进程就是少一堆兼容性问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与基础目录构建
2.1 准备最小根文件系统
我在日常操作中习惯用 Ubuntu 22.04(jammy)作为基础发行版,搭配 PostgreSQL 15。原因很简单:PGDG 软件源对 jammy 的支持很成熟,postgis、pgvector 这些扩展要么有现成 apt 包,要么源码很容易编译,踩坑率最低。
第一步是使用 debootstrap 准备一个最小根文件系统:
bash复制sudo apt-get update
sudo apt-get install -y debootstrap
sudo debootstrap --arch=amd64 jammy /opt/pg-rootfs http://archive.ubuntu.com/ubuntu
debootstrap 拉出来的目录比 docker 基础镜像还干净,没有多余的历史层。这一步相当于“在宿主机上手工做了一个基础镜像层”,后续所有扩展都装进这个目录里。
进入 rootfs 的方式是 chroot:
bash复制sudo chroot /opt/pg-rootfs /bin/bash
进去之后先把软件源和基础工具装好。如果你准备在国内环境使用,可以将 ubuntu 官方源替换成对应的镜像源,这步不展开,但更换后记得执行 apt-get update。
2.2 把 PostgreSQL 本体装进 rootfs
在 rootfs 内执行:
bash复制apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y \
postgresql-15 \
postgresql-client-15 \
postgresql-contrib-15 \
postgresql-15-postgis-3 \
postgresql-15-pgvector
这里为了展示“任意扩展”这个能力,我先演示 apt 直接可用的两个扩展:postgis 和 pgvector。整个镜像制作流程中,凡是 apt 能装的扩展我都会用 apt,省时间,也保证依赖包齐全;apt 装不到的,再用源码编译,这就是后面第三节要做的事。
装 PostgreSQL 本体时有几个关键路径,我列在下面:
| 路径 | 作用 |
|---|---|
/usr/lib/postgresql/15/bin |
数据库服务端、pg_config 等可执行文件 |
/usr/lib/postgresql/15/lib |
扩展的动态库 .so 文件 |
/usr/share/postgresql/15/extension |
扩展的 control 和 sql 文件 |
/var/lib/postgresql/15/main |
默认数据目录(PGDATA) |
很多人在打包 PG 镜像时会漏掉 extension 目录,结果镜像起来了,执行 CREATE EXTENSION vector; 报错说文件找不到。其实扩展不只是 .so,还需要 .control 和 SQL 脚本,只有全部放到正确位置才能被 PG 识别。
2.3 确认 rootfs 内的数据库能初始化
在打包之前,至少要验证一次数据库主程序能否正常 initdb。这一步很容易被忽略,但你如果打算把某个 rootfs 打成镜像,结果 rootfs 里数据库本身是坏的,后面排查会非常痛苦。
bash复制chroot /opt/pg-rootfs /bin/bash -c \
"su - postgres -c '/usr/lib/postgresql/15/bin/initdb -D /tmp/initdb-test'"
如果这条命令能跑过,说明基础库没问题。跑完后删掉测试目录,避免污染:
bash复制rm -rf /opt/pg-rootfs/tmp/initdb-test
3. 编译安装“任意扩展”的完整流程
3.3 理解扩展的结构和安装目标
PostgreSQL 扩展的安装其实是有规范可循的,搞懂它,你就能装任何扩展,而不仅仅是复制命令。
一个扩展包包含以下几类文件:
- 动态库:如
vector.so,放在pg_config --pkglibdir对应的目录。 - 控制文件:如
vector.control,放在pg_config --sharedir下的extension子目录。 - SQL 脚本:如
vector--0.6.2.sql,同样是放在extension目录。 - 其他辅助工具:可能放在
pg_config --bindir。
所以无论遇到什么扩展,第一件事就是执行:
bash复制pg_config --pkglibdir
pg_config --sharedir
pg_config --bindir
然后在编译时把这三个目录告诉构建系统。大多数扩展的 Makefile 或 CMake 配置都支持 PG_CONFIG 这个变量来指定 pg_config 的位置,目的就是防止系统里有多个 PostgreSQL 版本时装错目录。
3.2 源码编译 pgvector 的完整示例
以 pgvector 为例,假设 apt 源里没有你想要的新版本,而是需要从 GitHub 拉源码编译。在 rootfs 内执行下面命令:
bash复制# 安装编译依赖
apt-get install -y git make gcc postgresql-server-dev-15
# 拉取代码
git clone --branch v0.6.2 --depth 1 https://github.com/pgvector/pgvector.git /tmp/pgvector
cd /tmp/pgvector
# 编译和安装
make PG_CONFIG=/usr/lib/postgresql/15/bin/pg_config -j"$(nproc)"
make install PG_CONFIG=/usr/lib/postgresql/15/bin/pg_config
# 清理源码目录
cd / && rm -rf /tmp/pgvector
这段命令有几个细节值得注意:
postgresql-server-dev-15是编译扩展必需的头文件包,很多人在宿主机上编译没问题,换到容器里就报 “postgres.h not found”,基本都是因为没装这个包。-j"$(nproc)"让编译过程利用多核,如果你在 CI 机器或者本地开发机资源足够,编译会快很多。PG_CONFIG参数必须显式指定为/usr/lib/postgresql/15/bin/pg_config。如果系统里同时存在 14、15 多个版本,不指定的话大概率会默认找最高版本,装错位置。
3.3 源码编译 TimescaleDB 的操作差异
比 pgvector 复杂一些的是 TimescaleDB,它不直接用 Makefile,而是使用 CMake 构建。安装 TimescaleDB 时建议把编译依赖装全:
bash复制apt-get install -y build-essential cmake git libssl-dev libkrb5-dev clang postgresql-server-dev-15
git clone --depth 1 --branch 2.15.3 https://github.com/timescale/timescaledb.git /tmp/timescaledb
cd /tmp/timescaledb
# 使用 bootstrap 脚本生成构建目录
./bootstrap \
-DCMAKE_BUILD_TYPE=Release \
-DPG_CONFIG=/usr/lib/postgresql/15/bin/pg_config
cd build
make -j"$(nproc)"
make install
这里我解释几个为什么:
clang不是编译 timescaledb 的必要编译器,但某些扩展的向量化代码在 clang 下性能更好,所以官方文档里经常推荐安装。-DPG_CONFIG用于指定 PostgreSQL 配置路径,作用等价于 pgvector 里的PG_CONFIG。- TimescaleDB 安装完成后还需要在 PostgreSQL 配置里预加载库,我后面会专门讲。
如果你还想编译 Citus,则是另一套 ./configure 的流程。Citus 适合分布式多节点场景,一般用不到,但思路是一样的:先确认 pg_config,再执行编译安装。
3.4 清理编译依赖,控制镜像体积
编译完成之后,编译依赖已经没用了。如果你把 gcc、make、cmake、clang 这些全部留在 rootfs 里,镜像体积会多出 400MB 到 1GB 不止。我习惯在打包前做一次彻底清理:
bash复制apt-get purge -y git make gcc postgresql-server-dev-15 cmake clang build-essential libssl-dev libkrb5-dev
apt-get autoremove -y
rm -rf /root/.cache /var/lib/apt/lists/*
这一步对最终镜像体积影响极大。有些扩展在运行时依赖特定的库,比如 libgdal 或 libproj,这时候不要一股脑把 apt 包全删掉,先用 ldd /usr/lib/postgresql/15/lib/vector.so 查看实际依赖,再决定保留什么。
4. 使用 pig 把目录打包成镜像
4.1 pig 基本命令与参数选择
现在到了核心环节:用 pig 把 /opt/pg-rootfs 打包成 postgresql 镜像。我用的这个版本的命令长这样:
bash复制pig build \
--from-dir /opt/pg-rootfs \
--tag registry.example.com/library/postgres:15-ext \
--user 999:999 \
--env PATH=/usr/lib/postgresql/15/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
--volume /var/lib/postgresql/15/main \
--publish 5432 \
--cmd "postgres -D /var/lib/postgresql/15/main" \
--output docker
不同版本的 pig 参数可能存在细微差异,如果你用的是别的发行版,可以用 pig build --help 查看具体字段。这里重点讲几个关键参数的含义:
--from-dir:告诉 pig 需要把哪个目录当作根文件系统打包。这必须是一个完整的 rootfs,包含/etc、/usr、/var这些基础目录。--user:设置镜像默认执行用户。Ubuntu/Debian 中 postgres 用户的 UID 是 999,GID 也是 999。如果你在另一个发行版里构建,先执行id postgres看实际 UID。--volume:声明挂载卷。PG 数据目录main必须当卷挂出来,否则容器重建后数据就没了。--publish:声明容器要暴露的端口。--cmd:容器入口命令。这里注意要写完整路径,因为我们的镜像不保证有 shell 环境变量。
如果你不习惯这种纯命令行方式,pig 也支持读取一个类似 Containerfile 的声明文件。在 rootfs 目录旁边创建一个 Postgresfile:
dockerfile复制FROM scratch
COPY /opt/pg-rootfs/ /
USER 999:999
ENV PATH=/usr/lib/postgresql/15/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
EXPOSE 5432
VOLUME ["/var/lib/postgresql/15/main"]
CMD ["postgres", "-D", "/var/lib/postgresql/15/main"]
然后执行 pig build -f Postgresfile -t registry.example.com/library/postgres:15-ext .。
对比两种方式,我会更推荐纯命令行的 --from-dir 方案。因为它是直接把整个目录作为一层,避免 COPY 带来的额外镜像层和权限问题;Postgresfile 方式虽然可读性好,但如果你对镜像层大小敏感,建议用前者。
4.2 如何正确处理扩展预加载参数
有些扩展(比如 TimescaleDB、Citus)要求 PostgreSQL 启动时通过 shared_preload_libraries 预加载它们的动态库。如果你在镜像里装了这些扩展,却不在启动参数里带上,运行时会直接报错:
code复制FATAL: could not access file "timescaledb": No such file or directory
这句话很有迷惑性,因为文件通常是在的,只是 PostgreSQL 启动阶段没有去加载它。解决办法有两种:
- 修改
postgresql.conf,加一行:
ini复制shared_preload_libraries = 'timescaledb'
- 或者更推荐在容器启动命令里传参数。这样镜像里的配置保持干净,不同环境可以灵活覆盖:
bash复制postgres -D /var/lib/postgresql/15/main -c shared_preload_libraries=timescaledb
如果你同时装了两个需要预加载的扩展,用逗号隔开,比如 -c shared_preload_libraries=timescaledb,citus。
4.3 验证镜像并推送仓库
打包完成后,本地应该能看到这个镜像:
bash复制pig images
# 或
docker images | grep postgres
确认镜像存在后,推送到内网或标准的镜像仓库:
bash复制docker push registry.example.com/library/postgres:15-ext
如果你在内网环境,没有 registry.example.com,可以用 docker tag 改成你私有仓库的地址再 docker push。
5. 镜像验证与 docker-compose 快速部署
5.1 用 docker run 验证扩展是否在镜像里
打包结束并不意味着万事大吉。我习惯先在本地跑一个容器,验证扩展真的能被数据库加载:
bash复制docker run -d --rm --name pg-test \
-e POSTGRES_PASSWORD=secret \
-p 5432:5432 \
registry.example.com/library/postgres:15-ext
等几秒后,进入容器查询扩展列表:
bash复制docker exec pg-test psql -U postgres -c \
"SELECT name, default_version, installed_version
FROM pg_available_extensions
WHERE name IN ('postgis','vector','timescaledb','pg_trgm','hstore','uuid-ossp');"
如果你能看到一行行扩展信息,说明 rootfs 里的扩展目录放对了。然后你可以实际创建扩展:
sql复制CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS postgis;
这一步能发现两个常见问题:
- 控制文件没找到:说明
/usr/share/postgresql/15/extension目录里的文件缺失。 - 动态库加载失败:说明
.so文件缺失或权限有问题,需要检查/usr/lib/postgresql/15/lib。
如果一切正常,这条命令会返回 CREATE EXTENSION。到这里,镜像里的扩展已经真正可用了。
5.2 docker-compose 编排与挂载路径
单机验证没问题后,就可以用 docker-compose 把服务编排起来。下面是我常用的配置:
yaml复制services:
postgres:
image: registry.example.com/library/postgres:15-ext
container_name: postgres-ext
restart: unless-stopped
environment:
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=appdb
- TZ=Asia/Shanghai
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/15/main
- ./init:/docker-entrypoint-initdb.d:ro
shm_size: 1g
command: ["postgres", "-D", "/var/lib/postgresql/15/main", "-c", "shared_preload_libraries=timescaledb"]
volumes:
pgdata:
有几个细节值得注意:
- 如果你需要扩展在数据库初始化时自动生效,把 SQL 脚本放到
./init目录并挂载到/docker-entrypoint-initdb.d。镜像里的官方 entrypoint 脚本会自动执行这些 SQL。 shm_size: 1g是我实测下来对 PostgreSQL 比较友好的配置。PostgreSQL 的并行查询和 some 索引构建会用到共享内存,容器默认 64MB 太小,容易导致查询卡顿或失败。command里显式加了shared_preload_libraries,这是为了绕过 postgresql.conf 配置,方便不同容器按需覆盖。
5.3 与外部数据库工具连接
镜像跑起来后,你可以用 DBeaver、pgAdmin,甚至 PowerDesigner 来连接。连接串示例:
text复制jdbc:postgresql://localhost:5432/appdb
如果是 PowerDesigner 这种用于数据建模的工具,只要数据库端口映射好了,连接和反向工程都没有问题。带上 PostGIS 或 vector 的扩展并不会影响这些外部工具,它们通常只关心标准 PostgreSQL 驱动。
6. 常见问题与排查技巧实录
6.1 扩展在 pg_available_extensions 里看不到
最常见的场景是:你明明把 .so、.control、SQL 脚本都放进了 rootfs,但镜像启动后查询 pg_available_extensions 就是看不到。
排查思路:
bash复制# 查看当前 pg_config 的路径
/usr/lib/postgresql/15/bin/pg_config --sharedir
/usr/lib/postgresql/15/bin/pg_config --pkglibdir
然后对照检查文件是否真的在这些目录里:
bash复制ls -l /usr/share/postgresql/15/extension/vector.control
ls -l /usr/lib/postgresql/15/lib/vector.so
注意,如果你是在宿主机而不是 rootfs 里执行这些命令,路径前缀要加上 /opt/pg-rootfs。我踩过最隐蔽的坑是:把文件放到了 /usr/share/postgresql/extension/,漏掉了中间的版本号目录 15,导致 PostgreSQL 扫不到。
6.2 容器启动时报权限错误
容器启动如果遇到类似 could not change permissions of directory "/var/lib/postgresql/15/main",一般是目录属主不对。解决办法:
bash复制chown -R 999:999 /opt/pg-rootfs/var/lib/postgresql
如果你在 rootfs 构建阶段改了 postgres 用户的 UID,或者从 CentOS 的 rootfs 拿过来,系统 UID 规则不一致会导致这个错误。最稳妥的做法是打包前确认 id postgres 的输出,然后用实际的 UID:GID 设置 --user。
6.3 镜像比预期大很多
镜像体积膨胀绝大多数是编译依赖残留造成的。每次在 rootfs 内装完编译工具、编译完扩展后,一定要回到 rootfs 里执行:
bash复制apt-get purge -y gcc make build-essential postgresql-server-dev-15
apt-get autoremove -y
rm -rf /var/lib/apt/lists/*
这里我特别强调:不要在构建完扩展之后直接打包,先清理,再打包,体积差距轻松超过 500MB。如果对体积还有要求,可以再检查 PostGIS 这类重型扩展用不到的 locale 文件。
6.4 加载 .so 文件时报“No such file or directory”
有时你确认文件在目录里,启动时还是报找不到 .so。这种情况多半是动态链接库依赖缺失。用 ldd 检查:
bash复制chroot /opt/pg-rootfs /bin/bash -c "ldd /usr/lib/postgresql/15/lib/vector.so"
看到 not found 的依赖库时,在 rootfs 内补装对应的运行库即可。PostGIS 常见的是 libgdal、libgeos、libproj;TimescaleDB 常见的是 libssl.so.3。
6.5 支持多扩展时的顺序与依赖冲突
如果你的镜像要同时包含多个扩展,建议安装顺序固定为:postgis → pgvector → timescaledb → citus。后三个的安装顺序影响不大,但 PostGIS 最好最先装,因为它的依赖链最长,提前把依赖库装好可以避免后续扩展编译时找不到底层动态库。
此外,shared_preload_libraries 里如果有多个扩展,顺序通常是 timescaledb,citus。某些扩展对启动顺序敏感,所以建议把顺序固化在 command 参数里,避免不同容器实例出现行为不一致。
| 现象 | 可能原因 | 解决要点 |
|---|---|---|
pg_available_extensions 看不到扩展 |
control 文件路径错误 | 检查 pg_config --sharedir |
| 启动报权限错误 | 数据目录属主不对 | chown -R 999:999 |
创建扩展报 No such file or directory |
.so 依赖缺失 |
使用 ldd 排查依赖 |
| 扩展加载但服务异常 | shared_preload_libraries 缺失 | 启动参数里显式指定 |
| 镜像体积过大 | 编译工具残留 | apt-get purge + 清理 lists |
7. 实操总结与后续扩展建议
这套“先做 rootfs,再用 pig 打包”的流程,我把应用场景总结成一句话:它适合任何对扩展版本有要求、对镜像体积有要求、对构建环境有要求的人。如果你只是本地起一个 PG 做测试,官方镜像够用;但当你需要 PostGIS、pgvector、TimescaleDB 同时存在,还要保证内网离线环境也能拿到同样镜像时,pig 这条路基本是唯一干净的选择。
我个人的经验是:在正式工程里,可以把这整套流程拆成两个阶段。第一阶段,用脚本自动构建 rootfs,编译并安装扩展;第二阶段,用 pig 把 rootfs 打包推送到镜像仓库。第一阶段建议交给 CI 的定时任务或发布流水线去跑,第二阶段可以本地执行。两周一次重新构建,镜像版本号和扩展版本号统一维护,团队其他人只需要拉取镜像,不再需要每次重复编译。
后来我用同一套思路处理了 Redis 镜像和自定义 Java 运行镜像,虽然它们不需要花哨的扩展,但“目录即镜像”的思想是共通的:任何需要在启动前准备好运行环境的服务,都可以先用配置管理工具或初始化脚本把环境准备好,再固化镜像。如果你已经照着这篇流程跑通了 PG 镜像,后续扩展里装新的扩展,只需要重复第三节的编译步骤、重复第四节打包步骤,不会有额外的学习成本。希望这篇能帮你少踩几个坑。
