使用容器提供postgresql RESTful API服务

PostgreSQL是一款开源的SQL实现,可以用于关系型数据的存储。PostgREST能够将PostgreSQL数据库直接转换为 RESTful API,允许用户以HTTP方式查询和提交数据。

通常,安装PostgreSQL需要root权限,安装后默认用专用账户(postgres)访问,使用专用的目录(/var/lib/postgresql/)存储数据。服务的配置和启动由系统服务管理器管理,其控制通常需要root权限。全过程涉及多个用户、多个进程,操作较为复杂。

基于apptainer(旧名singularity)的容器化方案允许普通用户在个人目录下独立创建和管理多个数据库实例,能够极大地方便普通用户维护和调试数据库。

本文的目标是利用容器化方案,让普通用户在没有安装PostgreSQL的主机上,在用户目录下独立快速创建和维护一个PostgreSQL数据库,并使用PostgREST使其可以通过RESTful API访问。

容器镜像的制作

在空目录下创建两个文本文件Dockerfile(或apptainer.def)和entrypoint.sh,并clone GitHub仓库pgjwt

Dockerfile指示了容器构建方法。我们以Debian 13下的postgres 18官方容器(postgres:18-trixie)为基础,从postgrest/postgrest:latest镜像获取postgrest二进制文件。

为了使用PostgREST,我们还需要安装pgjwt来为PostgreSQL提供jwt扩展支持。

此外,我们还将指定必要的环境变量,来为后续的命令提供方便。

Dockerfile 复制代码
# 基础镜像
FROM postgres:18-trixie

# 安装 make
RUN apt-get update \
    && apt-get install -y build-essential \
    && rm -rf /var/lib/apt/lists/*

# 提取 postgrest
COPY --from=postgrest/postgrest:latest /bin/postgrest /usr/local/bin/postgrest

# 安装 pgjwt
COPY pgjwt /usr/src/pgjwt
RUN make -C /usr/src/pgjwt install

# 设置环境变量
ENV PGDATA=/var/lib/postgresql
ENV PGHOST=/var/lib/postgresql/socket
ENV PGDATABASE=main

# 设置入口脚本
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

# 设置默认命令(可选)
CMD ["psql"]

Dockerfile中还指定了数据库文件目录PGDATA、创建socket目录PGHOST,并将默认的database名设置为main。此外还指定了入口脚本entrypoint.sh,以及默认的启动命令psql
如果不想使用docker,也可以直接通过apptainer.def进行镜像定义(点击展开)

text 复制代码
Bootstrap: docker
From: postgrest/postgrest:latest
Stage: postgrest_bin

Bootstrap: docker
From: postgres:18-trixie
Stage: main_image

%files from postgrest_bin
    /bin/postgrest /usr/local/bin/postgrest

%files
    pgjwt /usr/src/pgjwt
    entrypoint.sh /usr/local/bin/entrypoint.sh

%environment
    export PGDATA=/var/lib/postgresql
    export PGHOST=/var/lib/postgresql/socket
    export PGDATABASE=main

%post
    apt-get update \
        && apt-get install -y build-essential \
        && rm -rf /var/lib/apt/lists/*
    make -C /usr/src/pgjwt install
    chmod +x /usr/local/bin/entrypoint.sh

%runscript
    if [ $# -eq 0 ]; then
        set -- "psql"
    fi
    exec /usr/local/bin/entrypoint.sh "$@"

entrypoint.sh是容器运行时会调用的脚本,用于在执行用户命令前(按需)初始化数据库并启动postgres服务,并在命令结束后终止postgres服务。

bash 复制代码
#!/bin/bash
set -e

if [ ! -f "$PGDATA/PG_VERSION" ]; then # 如果数据文件不存在
    initdb >&2 # 初始化数据库文件
    mkdir -p "$PGHOST" # 创建socket目录
    sed -i "s/^[# ]*listen_addresses\s*=.*/listen_addresses = ''/" "$PGDATA/postgresql.conf" # 禁用端口监听,只通过socket方式访问
    echo "unix_socket_directories = '$PGHOST'" >> "$PGDATA/postgresql.conf" # 指定socket目录
    pg_ctl start >&2 # 启动数据库服务
    createdb # 创建数据库
else
    pg_ctl start >&2 # 启动数据库服务
fi

trap 'pg_ctl stop >&2' EXIT # 退出时停止数据库服务

"$@" # 执行传入命令

用docker构建容器,之后在apptainer中拉取为保存为sif镜像文件:

bash 复制代码
# 利用 docker 构建
docker build -t pg18-postgrest:latest .
apptainer build pg18-postgrest.sif docker-daemon://pg18-postgrest:latest
# 或者直接从 apptainer.def 文件创建镜像
apptainer build pg18-postgrest.sif apptainer.def

这样就制作完成了一个包含PostgreSQL和PostgREST的镜像pg18-postgrest.sif

容器镜像的使用

使用容器镜像部署PostgreSQL数据库

我们构建镜像时设置了数据库文件(在镜像内的)目录PGDATA = /var/lib/postgresql,为了让其中的内容在镜像运行结束后依然能保存在硬盘上,我们需要通过--bind将本地目录"挂载"到镜像的/var/lib/postgresql目录下。

利用singularity run启动镜像并运行命令(如psql),入口脚本会自动以挂载的目录为数据库数据目录启动postgresql,如果检测到数据目录为空时还会自动初始化。

bash 复制代码
singularity run --bind /path/to/database/:/var/lib/postgresql pg18-postgrest.sif psql

运行上述命令后会进入psql命令行界面,退出后可以看到/path/to/database/下自动创建了postgresql相关数据文件。

之后再次运行,可以在psql中对位于/path/to/database/的数据库进行操作,例如查询、插入或删除表和数据。

使用容器镜像运行PostgREST

PostgREST利用数据库中的结构约束和权限决定API端点和操作。
graph RL A[Web Client] -->|Request| B[PostgREST] B -->|Response| A B -->|Query| C[PostgreSQL] C -->|Result| B

PostgREST的认证涉及数据库、PostgREST和web客户端三方,PostgREST同时作为数据库客户端和web服务端,需要在两套机制之间进行转换,较为复杂。

具体而言,PostgREST利用配置中给定的用户名、密码和登录方式访问数据库,通过jwt验证web客户端所声称的身份,验证通过后以其声称的身份对数据库数据进行读取或修改。其中任何一个环节权限校验失败都会导致错误。

jwt(JSON Web Tokens)是一段纯文本字符串,由三部分组成,第一、二部分是base64编码的元数据(指定签名算法)和数据(即web客户端"声称"的身份),第三部分是利用一个密钥字符串对前两部分签名后的结果,持有密钥者可以根据密钥字符串验证jwt的合法性,以拒绝第三部分签名不合要求的访问请求。

PostgREST本身只会校验jwt的合法性,不带有认证用户或签发token的能力。为了让web客户端能够向PostgREST提供正确签名的jwt,有两种手段:

  • 一是向可信的web客户端分发密钥字符串,由客户端自行签名构造jwt。一旦如此,web客户端就可以任意"声称"身份访问数据库。
  • 二是由数据库根据web客户端传递的数据(如用户名、密码)签发jwt,web客户端只能以数据库签发的身份访问数据库。

第二种手段允许我们向不同的web客户端签发不同权限的jwt,以实现差异化授权。为此,需要在PostgreSQL数据库中定义一个认证函数login

下面的sql展示了一套适用于PostgREST的数据库定义:

sql 复制代码
-- setup.sql
-- 启用 pgjwt 扩展
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS pgjwt;
-- 创建用户用于postgrest访问
CREATE USER postgrest NOINHERIT LOGIN PASSWORD 'mypassword'; -- 用于postgrest登录,否则postgrest无法连接到数据库
CREATE ROLE web_anon NOLOGIN;
CREATE ROLE web_auth NOLOGIN; -- 允许web客户端声称不同的身份,以实现差异化授权
GRANT web_anon, web_auth TO postgrest; -- 允许postgrest登录后以'web_anon'或'web_auth'的身份执行操作
CREATE SCHEMA data;
-- 认证函数,用于签发jwt
CREATE OR REPLACE FUNCTION data.login(username text, password text)
RETURNS text AS $$
DECLARE
  secret text := 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; -- 密钥字符串
BEGIN
  -- 生产环境中会根据传入的username和password进行认证判断
  -- 这里略过了认证步骤,对任意访问者签发'web_auth'身份的(永久)令牌
  -- 生产环境中通常会在令牌payload中包含过期时间字段('exp')
  RETURN sign(json_build_object(
    'role', 'web_auth'                                   -- 签发的身份
 --,'exp', extract(epoch from now() + interval '1 hour') -- 过期时间
  ), secret);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 创建表格
CREATE TABLE data.users (
  uid          bigint GENERATED ALWAYS AS IDENTITY primary key,
  username     text not null,
  department   text
);
-- 设置访问权限
GRANT USAGE ON SCHEMA data TO web_anon, web_auth;
GRANT execute ON FUNCTION data.login(text, text) TO web_anon; -- 允许普通身份申请jwt
GRANT select ON ALL TABLES IN SCHEMA data TO web_anon;   -- 允许普通身份查询数据
GRANT select, insert, update, delete ON ALL TABLES IN SCHEMA data TO web_auth; -- 允许'web_auth'身份修改数据
-- 导入数据,这里用3条测试记录作为示例
INSERT INTO data.users (username, department) VALUES
('Alice', 'Engineering'),
('Bob', 'Marketing'),
('Charlie', 'Human Resources');

将上述内容导入PostgreSQL:

bash 复制代码
cat setup.sql | singularity run --bind /path/to/database/:/var/lib/postgresql pg18-postgrest.sif psql -f -

新建一个postgrest.conf文件:

ini 复制代码
db-uri = "postgres://postgrest:mypassword@/main?host=/var/lib/postgresql/socket"
db-schemas = "data"
db-anon-role = "web_anon"
jwt-secret = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
server-port = 3000
log-level = "info"

这里的db-schemas是允许访问的schema,db-anon-role指定了没有传递jwt时访问数据库时的默认身份。PostgREST会通过jwt-secret密钥校验请求传递的jwt。

之后即可通过PostgREST启动RESTful API:

bash 复制代码
singularity run --bind /path/to/database/:/var/lib/postgresql pg18-postgrest.sif postgrest postgrest.conf

访问postgrest RESTful API

可以通过任意http客户端访问PostgREST RESTful API,这里以命令行客户端curl为例:

GET方法获取数据

复制代码
curl "http://localhost:3000/users"

返回

复制代码
[{"uid":1,"username":"Alice","department":"Engineering"},
 {"uid":2,"username":"Bob","department":"Marketing"},
 {"uid":3,"username":"Charlie","department":"Human Resources"}]

从login端点获取jwt

复制代码
curl "http://localhost:3000/rpc/login?username=guest&password=123456"

返回

复制代码
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIiA6ICJ3ZWJfYXV0aCJ9.WmyKgKms-SJ9unFwSpOzqGLFVVAN6iO9sKYR2hh_KKQ"

利用签发的jwt,我们可以以web_auth的身份访问数据库,并通过POST方法插入行、PATCH方法修改行、DELETE方法删除行。

bash 复制代码
# POST: 插入行
curl -X POST "http://localhost:3000/users" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIiA6ICJ3ZWJfYXV0aCJ9.WmyKgKms-SJ9unFwSpOzqGLFVVAN6iO9sKYR2hh_KKQ" \
  -H "Content-Type: application/json" \
  -d '{"username":"Dave","department":"Engineering"}'
# PATCH: 修改行
curl -X PATCH "http://localhost:3000/users?username=eq.Alice" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIiA6ICJ3ZWJfYXV0aCJ9.WmyKgKms-SJ9unFwSpOzqGLFVVAN6iO9sKYR2hh_KKQ" \
  -H "Content-Type: application/json" \
  -d '{"department":"Marketing"}'
# DELETE: 删除行
curl -X DELETE 'http://localhost:3000/users?username=eq.Bob' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIiA6ICJ3ZWJfYXV0aCJ9.WmyKgKms-SJ9unFwSpOzqGLFVVAN6iO9sKYR2hh_KKQ'

再次查询数据

复制代码
curl "http://localhost:3000/users"

返回

复制代码
[{"uid":3,"username":"Charlie","department":"Human Resources"},
 {"uid":4,"username":"Dave","department":"Engineering"},
 {"uid":1,"username":"Alice","department":"Marketing"}]