4. Shell脚本驱动PostgreSQL运维

当PostgreSQL实例数量从一两台增长到十几台甚至几十台时,手工执行 pg_dump、VACUUM、日志清理等操作会迅速变得不可维护。Shell脚本是把这些重复动作固化为可复用资产的最直接手段,但一个能长期稳定运行的运维脚本,必须同时解决四件事:连接怎么建立、错误怎么捕获、过程怎么留痕、并发怎么约束。本节围绕这四点,给出可直接改造使用的备份、检查、清理脚本骨架。

连接管理:把连接参数集中收口

连接信息散落在每个脚本里是运维事故的常见来源。推荐做法是用一个共享的环境文件统一管理,脚本通过 source 引入。PostgreSQL官方客户端工具(psql、pg_dump、pg_isready等)都识别 PGHOST、PGPORT、PGUSER、PGDATABASE、PGPASSWORD 等环境变量,因此无需在每个命令后重复拼参数。

# /etc/pgops/pg.env
export PGHOST=10.20.30.11
export PGPORT=5432
export PGUSER=ops_reader
export PGDATABASE=appdb
export PGPASSWORD_FILE=/etc/pgops/.pgpass_ops
export PGCONNECT_TIMEOUT=5
export PGAPPNAME=pgops_backup

密码不建议直接写进环境变量,而是通过 PGPASSFILE 指向权限为 600 的密码文件,或使用 .pgpass 机制。连接建立后,脚本应先做一次连通性探测,而不是直接进入业务命令:

source /etc/pgops/pg.env
export PGPASSFILE="$PGPASSWORD_FILE"

if ! pg_isready -q -t 5; then
  echo "[FATAL] PostgreSQL $PGHOST:$PGPORT 不可达" >&2
  exit 2
fi

pg_isready 返回0表示接受连接,1表示拒绝,2表示无响应,3表示参数错误。把返回码映射为脚本退出码,可以让上层调度器(cron、systemd timer、Airflow)准确判断失败类型。

错误处理:set -euo pipefail 与 trap 的组合

Shell默认不会因为某条命令失败而中止,这在运维脚本里非常危险。标准做法是在脚本头部声明严格模式:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

-e 让命令失败即退出,-u 让引用未定义变量报错,-o pipefail 让管道中任一环节失败都算失败。但严格模式也有盲区:if 条件、|| 右侧、! 取反的命令不会触发退出。因此关键操作仍应显式判断返回码,并配合 trap 做清理与告警:

LOG=/var/log/pgops/backup_$(date +%F).log
TMPDIR=$(mktemp -d /tmp/pgbackup.XXXXXX)

cleanup() {
  local rc=$?
  rm -rf "$TMPDIR"
  if (( rc != 0 )); then
    echo "[$(date +%T)] 备份失败,退出码=$rc" >>"$LOG"
    # 可在此接入告警:curl -s "https://alert/api?msg=pgbackup_fail"
  fi
  exit $rc
}
trap cleanup EXIT INT TERM

这里 trap ... EXIT 保证无论脚本正常结束还是中途失败,临时目录都会被清理,失败信息都会被记录。注意 cleanup 中先保存 $?,否则后续命令会覆盖真实退出码。

日志记录:结构化、可轮转、可追溯

运维脚本的日志要回答三个问题:什么时候、对哪个实例、做了什么、结果如何。建议统一日志函数,把时间戳、级别、脚本名、目标实例拼进每一行:

log() {
  local level=$1; shift
  printf '%s [%s] [%s@%s] %s\n' \
    "$(date '+%F %T')" "$level" "$(basename "$0")" "$PGHOST" "$*" \
    | tee -a "$LOG"
}

log INFO  "开始备份 $PGDATABASE"
log WARN  "磁盘剩余 $(df -P /backup | awk 'NR==2{print $4}') KB"
log ERROR "pg_dump 返回码 $rc"

日志文件按天命名并配合 find /var/log/pgops -name '*.log' -mtime +14 -delete 做保留策略,避免日志本身成为磁盘隐患。若接入集中日志系统,可把 tee -a 换成同时写入本地与 logger -t pgops。

并发控制:flock 防止任务重叠

备份、VACUUM FULL、索引重建这类任务一旦重叠执行,会同时占用大量I/O和连接,甚至互相阻塞。最轻量的互斥手段是 flock,它基于文件锁,进程退出自动释放,不会留下死锁:

LOCK=/var/lock/pgops_backup.lock
exec 9>"$LOCK"
if ! flock -n 9; then
  log WARN "已有备份任务在运行,本次跳过"
  exit 0
fi

-n 表示非阻塞,拿不到锁立即返回;若希望排队等待,可改为 flock -w 300 9 最多等300秒。锁文件放在 /var/lock 或 /run 下,重启后自动清理。对于需要限制数据库侧并发的场景,还可以在会话级设置 SET statement_timeout 和 SET lock_timeout,避免单个慢查询拖垮实例。

Shell运维脚本四层防护结构 ① 连接管理 pg.env 集中参数 PGPASSFILE 600 pg_isready 探测 超时 5s 连通性 ② 错误处理 set -euo pipefail trap EXIT 清理 显式判断 $? 退出码 0/1/2/3 ③ 日志记录 时间戳+级别+实例 tee 本地+集中 按天命名 保留14天 ④ 并发控制 flock -n 9 非阻塞 flock -w 300 排队 lock_timeout 防重叠 失败→告警

可复用脚本骨架:备份、检查、清理

把上述四层能力封装成公共库 pgops_lib.sh,三个业务脚本只需关注自身逻辑。以下为备份脚本核心片段(示例参数,非实测结果):

#!/usr/bin/env bash
set -euo pipefail
source /etc/pgops/pg.env
source /etc/pgops/pgops_lib.sh

BACKUP_DIR=/backup/pg
STAMP=$(date +%F_%H%M)
OUT="$BACKUP_DIR/${PGDATABASE}_${STAMP}.dump"

acquire_lock /var/lock/pgops_backup.lock || { log WARN "跳过:已有任务"; exit 0; }
check_conn || exit 2

log INFO "开始 pg_dump -Fc 到 $OUT"
if pg_dump -Fc -Z 6 -f "$OUT"; then
  SIZE=$(stat -c %s "$OUT")
  log INFO "备份完成,大小 ${SIZE} 字节"
else
  log ERROR "pg_dump 失败"
  exit 1
fi

find "$BACKUP_DIR" -name '*.dump' -mtime +7 -delete
log INFO "清理7天前备份完成"

检查脚本则侧重只读探测,输出可直接被监控采集:

psql -Atc "SELECT count(*) FROM pg_stat_activity WHERE state='active'" \
  | awk '{ if ($1>50) print "WARN active=" $1; else print "OK active=" $1 }'

psql -Atc "SELECT datname, pg_database_size(datname)/1024/1024 AS mb
           FROM pg_database WHERE datistemplate=false ORDER BY mb DESC LIMIT 5"

清理脚本处理的是归档日志、临时文件与过期备份,务必先 find ... -print 预览,确认无误后再改为 -delete,并同样加锁防止与备份任务冲突。

建议把三个脚本统一放在 /opt/pgops/bin/,公共库放 /opt/pgops/lib/,环境文件放 /etc/pgops/,日志放 /var/log/pgops/。目录职责清晰后,cron 或 systemd timer 只需调用脚本路径,无需关心内部实现。
严格模式下 grep 未匹配返回1会导致脚本意外退出,常见写法是 grep -q pattern file || true,或把可能失败的判断放进 if 条件中。此外,trap 中不要执行可能再次失败的命令,否则会掩盖原始退出码。

判断脚本是否真正可复用,可以看三个信号:换一台实例只需改 pg.env 而不动脚本;任何一步失败都能在日志里定位到具体命令与退出码;同一任务被重复触发时不会产生第二份并发执行。做到这三点,Shell脚本才从“一次性命令集合”升级为可纳入自动化调度的运维资产。