在自动化部署里,我见过最多的一类“玄学故障”就是:脚本明明等到 PostgreSQL 进程起来了、端口也通了,紧接着的连接请求却摔在FATAL: the database system is starting up,或者更气人的直接Connection refused。问题不在于 PostgreSQL 没启动,而在于我们把“进程存在”当成了“数据库就绪”。这篇实践笔记想讲清楚的只有一件事:如何正确实现题面上的Check & Wait For PostgreSQL To Become Ready,让你的 shell 脚本、CI 服务、容器探针不再靠猜。适合正在写部署脚本、配 CI/CD、调容器化编排的同学参考。
1. “启动成功”和“就绪”之间,隔着一整个恢复流程
1.1 postmaster 启动时到底在忙什么
先回到最基础的层面。你执行pg_ctl start或者systemctl start postgresql之后,前台那个进程(确切地说是 postmaster)并不是把共享内存准备好、端口一开就交差。它要走完一串序列:读postgresql.conf、创建共享内存和信号量、拉起 logger、stats collector、walwriter、checkpointer 这些辅助进程,然后才进入接受连接请求的阶段。这一步在数据目录正常关闭后再次启动时很快就过去了;可一旦数据库是异常退出、或者你正在拉起一个 standby 节点,那么接下来还有 WAL 重放(recovery)的过程。
主库崩溃重启时,要把 checkpoint 之后产生的 WAL 逐条重放,才能把数据恢复到崩溃前的一致状态,这个阶段叫 crash recovery。standby 节点更特殊,它可能长期处于持续应用 WAL 的恢复模式。这两类场景里都有一个共同特征:端口可能已经能建立 TCP 连接了,但你真发一条SELECT过去,后端会直接拒绝——典型报错就是FATAL: the database system is starting up,在备库上则是FATAL: the database system is in recovery mode。
所以,“进程还活着”只说明 postmaster 撑过了初始化,不代表存储层面已经达到可查询、可写入的一致性状态。就绪检查的本质,就是在等这个“可查询”的门槛跨过去。
1.2 端口穿透与认证系统就绪是两码事
第二个容易混淆的点是:监听端口不等于“就绪”信号,它只是网络层接受连接的口子。PostgreSQL 在启动早期就会绑定并监听端口,因为 accept 连接这件事本身不依赖 WAL 重放完成;但连接进来之后,后端进程要完成身份认证、加载配置、初始化事务环境,这些能力是逐步准备好的。
我见过一个非常典型的场景:用 docker-compose 起 PostgreSQL,健康检查只写了nc -z 127.0.0.1 5432,检测到端口通就认为 ok,紧接着应用启动,第一次建连接池时全是FATAL: sorry, too many clients already或者the database system is starting up。端口通、进程活、数据库可查询,这三个状态在时间线上跨度比很多人想象的大,越是在并发连接场景里,越要等最后一个。
pg_ctl start -w虽然是个等待动作,但它的内部实现其实是循环读postmaster.pid里的状态,只要 postmaster 对外宣称“启动完成”就返回。这个“启动完成”仍然不等于“可执行事务”。所以从 wait 语义上看,自己的检查脚本必须比pg_ctl -w再多走一步。
1.3 等哪些指标才算等到了“就绪”
我自己习惯把就绪状态分成四级,从轻到重:
- TCP 能握手:说明端口被监听,网络路径通。
- 能发起连接并完成认证:说明认证系统可用,
pg_hba.conf里的规则真的生效了。 - 能执行最小查询
SELECT 1:说明后端进程能分配、事务系统可用、数据库元数据可访问。 - 业务相关特殊检查:比如主从切换场景里,等它退出 recovery、可以正常写入;或者反过来,等它进入可读的 hot standby。
这四级不是每次都全要,但至少覆盖到第三级,才称得上“Ready”。
2. 就绪检查的两条路线:pg_isready 很轻,但别把轻当万能
2.1 pg_isready 的真实行为:一次握手就结束
pg_isready是 PostgreSQL 自带的小工具,源码里就是个很朴素的做法:尝试建立一条到目标库的 TCP(或者 Unix socket)连接,成功就返回,然后立刻断开。它不真正做认证,也不发送任何 SQL。所以它返回码 0,表示“TCP 层面能够连上这个端口”,仅此而已。
返回码的含义值得记一下:
| 返回码 | 含义 |
|---|---|
| 0 | 服务可以接受连接(端口可达,握手成功) |
| 1 | 服务器拒绝了连接(进程活着但处于启动中/不接受新连接) |
| 2 | 无法连接服务器(端口不通、进程没起来、网络问题) |
| 3 | 服务器不存在(一般很少遇到,多半是参数解析问题) |
注意 0 和 1 的区别很重要。重启过程中,PostgreSQL 会开始监听端口,同时对进入的连接直接返回拒绝,这时pg_isready返回 1 而不是 2。所以等待脚本里可以把 2 当成“还没起来”,把 1 当成“正在起来”,两种情况都继续循环即可,但不要误以为 1 是致命错误。
另外pg_isready的-t参数只控制单次连接的握手超时时间,并不是整个等待循环的时长。这个细节很多人第一次写脚本时会搞混。
2.2 连接探测的正确姿势:用 psql 执行最小查询
既然pg_isready不做认证,等待脚本里更可靠的第二道保险,就是用psql实际执行一条最小查询。最轻量的就是SELECT 1,它要求后端进程完整度过认证和事务启动流程,能返回结果就说明整个查询管道是通的。
许多部署文档都只教你用pg_isready,这没问题,但一旦你的场景里有强制认证——比如用了 scram-sha-256、需要通过密码连接——就建议补上psql探测。两条命令组合的语义是:先证明网络层的服务在,再用一次真实会话证明认证和查询都可用。单独任何一条都有盲区。
如果要更贴近业务,可以在探测里加入SELECT pg_is_in_recovery(),用来判断备库是否还在重放 WAL。如果脚本的目的是主备切换后的“数据库可写”,那就等它返回f再动手。再加一步最小事务测试:BEGIN; SELECT 1; ROLLBACK,不写入任何脏数据,却能证明当前节点可以打开事务。
3. 一个可复用的等待脚本:参数、边界条件和日志陷阱
3.1 脚本主体:不是简单 while 循环
下面这版脚本是我在多个部署场景里反复用过的,核心逻辑不花哨,但把几个容易翻车的边界都处理了:
#!/usr/bin/env bash set -Eeuo pipefail PGHOST="${PGHOST:-127.0.0.1}" PGPORT="${PGPORT:-5432}" PGUSER="${PGUSER:-postgres}" PGDATABASE="${PGDATABASE:-postgres}" PGPASSWORD="${PGPASSWORD:-}" PG_READY_TIMEOUT="${PG_READY_TIMEOUT:-60}" PG_READY_INTERVAL="${PG_READY_INTERVAL:-2}" PG_LOG_FILE="${PG_LOG_FILE:-}" need_tcp_hit=0 if ! command -v pg_isready >/dev/null 2>&1; then echo "[wait-pg] pg_isready not found, fallback to psql only" need_tcp_hit=1 fi start_ts="$(date +%s)" while true; do ok=1 if [ "$need_tcp_hit" -eq 0 ]; then if pg_isready -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -t 2 >/dev/null 2>&1; then : else ok=0 fi fi if [ "$ok" -eq 1 ]; then if PGPASSWORD="$PGPASSWORD" psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -v ON_ERROR_STOP=0 -Atqc "SELECT 1" >/dev/null 2>&1; then : else ok=0 fi fi if [ "$ok" -eq 1 ]; then echo "[wait-pg] database is ready" exit 0 fi now_ts="$(date +%s)" elapsed=$((now_ts - start_ts)) if [ "$elapsed" -ge "$PG_READY_TIMEOUT" ]; then echo "[wait-pg] timeout after ${PG_READY_TIMEOUT}s, database not ready" >&2 if [ -n "$PG_LOG_FILE" ] && [ -f "$PG_LOG_FILE" ]; then tail -n 50 "$PG_LOG_FILE" >&2 || true fi exit 1 fi printf '[wait-pg] not ready yet, elapsed %ss, wait %ss\n' "$elapsed" "$PG_READY_INTERVAL" sleep "$PG_READY_INTERVAL" done有几个地方需要解释设计意图。
PGPASSWORD直接走环境变量注入,是为了避免在命令行里出现明文密码被进程列表看到;如果生产环境不方便用环境变量,也可以改用PGPASSFILE指向~/.pgpass。PG_READY_INTERVAL独立成变量,则是因为sleep 1的密集轮询表面上“反应快”,实际会在日志里刷出一大堆无意义行,日志系统如果又接入采集,成本很冤枉;我一般设 2 到 3 秒。
3.2 超时与失败处理:别把两秒当成两分钟
捕获超时后的表现,和正常循环一样重要。脚本在超时退出时会把PG_LOG_FILE最后 50 行打出来。这个设计来自我线上遇到的真实案例:脚本傻等了 5 分钟没就绪,最后只看到一行 timeout,根本不知道背后是权限问题还是磁盘问题。把日志尾巴带上,排查路径会短很多。
另一个细节是set -Eeuo pipefail与pg_isready的互动。pg_isready返回非 0 时会触发 errexit,所以循环里必须在 if 条件内调用,或者用|| true包住。psql同理,这就是为什么两处检查我都写在 if 的判断表达式里,而不是先执行再查$?。
还有一点:如果脚本是给 CI 用的,建议把PG_READY_TIMEOUT设成 90 秒而不是 30 秒。CI 宿主机第一次拉镜像、第一次初始化数据目录,耗时往往比本机长得多。我见过一堆 CI 偶发失败,都是超时设得太激进,数据库最后其实起来了,脚本却先放弃了。
3.3 一个隐藏的坑:Unix socket 与 -h 参数
很多人在容器里敲pg_isready不带-h,默认会走/var/run/postgresql下的 Unix socket。容器镜像里这个目录的权限有时候不属于 postgres 用户,于是明明数据库已经准备好了,检查工具却报权限不足。统一带上-h 127.0.0.1或者你自己的真实地址,走 TCP 路径,能避开一堆 socket 权限问题。
反过来,如果数据库本身只监听了 Unix socket(listen_addresses=''),脚本里硬连127.0.0.1会一直失败。这种场景要么改配置暴露 TCP,要么就让脚本走 socket。先确认数据库的listen_addresses和port设置,再决定探测地址,属于部署前的基本功课。
4. 容器、CI/CD 和 Kubernetes 里的就绪检查实践
4.1 Docker healthcheck 的正确写法
官方 postgres 镜像里同时带pg_isready和psql,所以 healthcheck 很方便。关键是不要只写nc或者bash /dev/tcp这类端口探测,端口通了,镜像里的数据库进程未必已经准备好接受连接。
FROM postgres:16-alpine HEALTHCHECK --interval=5s --timeout=3s --start-period=10s --retries=12 \ CMD pg_isready -U postgres -d postgres -h 127.0.0.1 -p 5432 || exit 1--start-period是给容器里 PostgreSQL 做 initdb、跑/docker-entrypoint-initdb.d下初始化脚本的预留时间,这个窗口里即使健康检查不通过也不算 unhealthy。interval和timeout设多少取决于数据目录是否在新卷上、是否要恢复 WAL;新机器上第一次启动可能要 20 秒以上,start-period 我建议直接给到 30 秒,宁可保守也不要让编排系统过早杀掉容器。
如果自定义镜像里没有pg_isready,但又有完整的 psql 客户端,这样写:
HEALTHCHECK --interval=5s --timeout=3s --start-period=10s --retries=12 \ CMD ["/bin/sh", "-c", "psql -U postgres -h 127.0.0.1 -tc \"SELECT 1\" >/dev/null 2>&1 || exit 1"]4.2 CI 里等数据库服务的三种姿势
GitHub Actions 里用 services 启动 PostgreSQL 时,可以直接配置 health:
services: postgres: image: postgres:16-alpine env: POSTGRES_PASSWORD: test POSTGRES_DB: app ports: - 5432:5432 options: >- --health-cmd="pg_isready -U postgres -d app" --health-interval=2s --health-timeout=3s --health-retries=30有 health 后,job 会在服务变为 healthy 之后才开始跑,省掉脚本里漫长的 sleep。
GitLab CI 的 services 也有类似机制,通过health_check关键字配置。但有些自建 Runner 版本比较老,health_check配置不完全可靠,这时最稳的办法还是 job 脚本里先跑等待命令,也就是第三章那个脚本,把超时时间放开,输出日志的关键行。
我个人非常反对在 CI 里写sleep 30这种“完全靠猜”的等待。数据库启动时间在不同 runner 上差异极大:空载机器上可能 8 秒就绪,负载高的共享 runner 上要 60 秒。固定值 sleep 要么太短导致偶发失败,要么太长浪费流水线时间,用带超时的轮询脚本才可控。
4.3 Kubernetes 探针的分工:startupProbe 与 readinessProbe
在 K8s 里判断一个 PostgreSQL Pod 是否“就绪”,不应该只依赖资源层面。常见做法是在 Deployment 里同时配置startupProbe和readinessProbe:前者给慢启动留窗口,后者负责持续性健康检查。
ports: - containerPort: 5432 startupProbe: exec: command: ["/bin/sh", "-c", "pg_isready -U postgres -d postgres -h 127.0.0.1"] failureThreshold: 24 periodSeconds: 5 timeoutSeconds: 3 readinessProbe: exec: command: ["/bin/sh", "-c", "psql -U postgres -d postgres -h 127.0.0.1 -Atqc 'SELECT 1' >/dev/null 2>&1"] initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3两个探针都放在同一个 PostgreSQL 容器里,用的是镜像自带的pg_isready和psql,是最省事的方式。startupProbe的failureThreshold乘以periodSeconds决定了最大启动容忍时间,比如 24 * 5 = 120 秒,足够覆盖大部分慢启动场景,而且不会影响后续就绪检查。readinessProbe用psql做真实查询,语义上比端口握手可靠得多。
如果要给 sidecar 模式或像 Patroni 这类高可用集群做探针,建议把探针命令换成指向 Patroni 的 REST API,或者直接查pg_is_in_recovery()再加一个只读事务测试,不然主备切换过程中会出现误判。
5. 排查“等了很久还没就绪”的几条实用路径
5.1 端口能连但认证失败:hba 规则与加密插件
等待脚本在循环里卡住,最常见的原因不是数据库起得慢,而是认证环节失败。PostgreSQL 从 trust 到 scram-sha-256 的演进中,很多部署用官方镜像时 root 用户和 postgres 用户的连接路径不一样,pg_hba.conf里同一行规则可能同时命中 host 和 local,顺序错了就会先命中拒绝规则。
排查时不要漫无目的地试密码,直接用psql带对应参数连一次,看返回的具体 FATAL 行。是password authentication failed for user,还是no pg_hba.conf entry for host,后者通常是连接来源 IP 没匹配到任何规则,需要检查pg_hba.conf里 host 行的网段写法。如果数据库要对外提供服务,我建议显式写规则,不要靠默认的127.0.0.1/32猜。
5.2 IPv6 与 localhost 的解析陷阱
机器上localhost可能同时解析到::1,而 PostgreSQL 默认listen_addresses如果是'*',IPv6 也能监听;但如果你只写了127.0.0.1,脚本里-h localhost会优先尝试::1,连接自然被拒。这个现象在 macOS 和部分 Linux 上特别明显。
最省心的做法是探测脚本统一用明确 IP,比如-h 127.0.0.1,不要依赖系统/etc/hosts的解析结果。如果双栈环境下数据库监听在[::1],那就反过来用-h ::1,或者配置listen_addresses包含具体地址。
5.3 数据目录的问题:磁盘满、所有者不对、权限不一致
PostgreSQL 启动早期就要在数据目录里写postmaster.pid、打开 WAL 文件。如果磁盘空间满,启动进程可能一直卡在写 WAL 阶段,进程看着在、端口也半掩半掩,就是永远到不了就绪。这时等待脚本再增加超时也没用,必须处理存储。
还有一种是数据目录被 root 创建,然后以 postgres 用户启动,报permission denied后进程反复重启。系统日志里会留下FATAL: data directory ... has invalid permissions之类的信息。遇到这种情况,先chown -R postgres:postgres数据目录,再让等待脚本继续。磁盘满这个问题在容器里尤其隐蔽,因为镜像层和卷的空间判断往往不能用直觉,所以要养成保留 tail 输出的习惯,日志尾巴里通常直接写着disk is full或者No space left on device。
5.4 首次初始化与资源限制下的慢启动
首次启动是最慢的:要 initdb、生成 PG_VERSION、创建系统表,还要执行镜像或初始化脚本里预置的 SQL。这个过程在低配机器上可能花几十秒。如果容器的 CPU limit 给得很低,或者宿主机 IO 负载高,启动时间还会放大。因此在编排器里给 PostgreSQL 留足够的 startup 容忍时间,比把探针 interval 调到 1 秒更有价值。
资源限制还有个容易被忽视的副作用:如果shared_buffers设得比实际可用内存还大,启动时内存分配就可能触发 OOM,进程反复被杀,脚本看起来就是“永远未就绪”。这时候该做的不是延长等待,而是减小shared_buffers或者给容器提高内存限制。查一下 dmesg 或容器事件里有没有 OOM Kill 的记录,比盯着连接超时快得多。
我个人这几年下来最大的体会是,“等待就绪”看起来是个小工具函数,但它背后是一整套对 PostgreSQL 启动语义的理解。你愿意在这个环节花时间设计好超时、双保险认证和日志捕获,后续的自动化编排就会省心很多。如果你的部署环境还涉及流复制、Patroni 这类高可用组件,强烈建议把pg_is_in_recovery()的探测也纳入等待条件,那会是另一个更长的故事了。