Umami Docker 故障排查实录:从 PostgreSQL pg_control: Permission denied 到平滑迁移到 Named Volume

这次 Umami 故障,表面上看是应用无法启动,实际上根因在 PostgreSQL 数据目录权限异常。日志中最关键的报错是 pg_control: Permission denied,说明数据库虽然识别到了旧数据目录,但已经无法正常读取核心控制文件。

这篇文章记录了我从故障出现、恢复旧数据、排查误区,到最终迁移到更稳妥的 Docker named volume 方案的完整过程。结论很直接:如果 PostgreSQL 数据目录长期使用宿主机 bind mount,在异常关机、目录权限变化、面板操作等场景下,确实更容易出现类似问题。

一、故障现象

最开始看到的 PostgreSQL 日志如下:

postgres: could not find the database system
Expected to find it in the directory "/var/lib/postgresql/data",
but could not open file "/var/lib/postgresql/data/global/pg_control": Permission denied

PostgreSQL Database directory appears to contain a database; Skipping initialization

同时,Umami 容器中还有这样的报错:

Invalid `prisma.$queryRaw()` invocation:
Raw query failed. Message: `Can't reach database server at db`

从这两类日志结合来看,可以得出一个比较清晰的判断:

  • Umami 并不是第一故障点
  • PostgreSQL 先启动失败
  • Umami 随后因为连接不上数据库而报错

也就是说,这本质上是一次数据库层问题,而不是 Umami 应用本身的问题。

二、问题根因分析

我原本的 PostgreSQL 挂载方式是这样的:

volumes:
  - ./postgres-data:/var/lib/postgresql/data

这属于典型的 Docker bind mount,也就是把宿主机目录直接映射进容器。

这种方式的优点是直观,目录可见、便于备份和手工操作;但缺点也很明显:

  • PostgreSQL 对目录权限和属主要求严格
  • 宿主机目录一旦被修改权限、改属主,或者受到面板、脚本、异常关机影响
  • 容器就可能无法读取 pg_control
  • 一旦 pg_control 不可读,数据库会直接拒绝启动

这次的核心问题,本质上就是:

PostgreSQL 数据目录仍然存在,但容器进程已经没有权限正常读取关键数据库文件。

三、第一步修复:先把旧库救活

遇到这类问题时,最重要的不是立刻删卷重建,而是先确认旧数据是否还在。

从后续日志里可以看到类似信息:

database system was interrupted
database system was not properly shut down; automatic recovery in progress
database system is ready to accept connections

这说明:

  • 数据库之前经历过异常中断
  • 但数据本身未必已经损坏
  • 只要目录权限恢复正常,PostgreSQL 有机会自动恢复

所以我第一步做的不是迁移,而是先修复旧数据目录权限,把原有数据库拉起来。

修复思路大致是:

  1. 停掉容器
  2. 对旧数据目录重新执行 chownchmod
  3. 重新启动数据库
  4. 观察 PostgreSQL 是否自动完成恢复

这一步成功后,旧数据恢复正常,Umami 页面数据也重新出现。
这说明问题主要在目录权限,而不是数据库内容损坏。

四、为什么不应该止步于“先能跑起来”

虽然旧库恢复成功了,但这并不代表风险已经解除。

如果继续沿用 bind mount,把数据库文件直接放在宿主机目录里,那么类似问题后面仍然可能再次出现,尤其是在这些场景下:

  • VPS 异常断电
  • 面板或脚本误改目录权限
  • 手动操作数据目录
  • 宿主机挂载状态变化
  • 文件系统异常

所以我后面的目标就变成了:

在确认旧数据完整的前提下,把 PostgreSQL 从 bind mount 平滑迁移到 Docker named volume。


五、迁移过程中踩到的两个坑

1. 认证失败:password authentication failed

迁移初期我遇到了数据库认证报错,原因后来确认是:

  • 修改了 POSTGRES_PASSWORD
  • 但数据库卷并不是全新初始化的
  • PostgreSQL 不会自动把已有用户密码改成新的环境变量值

需要特别注意的是:

POSTGRES_PASSWORD 只在数据库首次初始化时生效。

如果卷里已经有旧数据,后面再改这个环境变量,并不会自动修改数据库里已有用户的密码。

因此迁移时最稳妥的做法是:

  • 不改数据库用户名
  • 不改数据库密码
  • 只改存储方式

这样可以显著降低迁移过程中的变量数量。

2. 路径错位:容器启动了,但数据“消失”了

另一个坑来自 PGDATA

一开始我尝试引入这样的配置:

PGDATA: /var/lib/postgresql/data/pgdata

但旧数据库实际上是按默认路径初始化的,数据文件原本就在:

/var/lib/postgresql/data

结果就出现了一个典型问题:

  • 旧数据还在原目录
  • PostgreSQL 却在 pgdata/ 子目录里重新初始化了一套新库
  • 容器可以正常启动
  • 但应用里看到的是一个全新的空数据库

这个问题非常隐蔽,因为从表面看服务是正常的,但实际已经连到了新库。

这次经验也非常明确:

迁移存储方案时,不要顺手改 PGDATA。

六、最终采用的稳妥迁移方案

最终我采用的是最保守、最可回滚的方式:

  1. 先在旧库正常运行时导出逻辑备份
  2. 停掉服务
  3. 保留旧物理目录不删除
  4. 把数据库卷改成 Docker named volume
  5. 用新卷初始化一个干净数据库
  6. 将 SQL 备份恢复到新卷
  7. 再启动 Umami
  8. 验证数据完整后再考虑清理旧目录

这个方案的关键是:

  • 旧数据目录保留,随时可以回滚
  • 逻辑备份可用于新卷恢复
  • 整个迁移过程中只改变“存储方式”这一项变量

七、最终稳定运行的 Compose 配置

下面是我最终稳定使用的 docker-compose.yml 示例:

services:
  umami:
    container_name: umami
    image: ghcr.io/umami-software/umami:3.2.0
    ports:
      - "<your_host_port>:3000"
    environment:
      DATABASE_URL: postgresql://<db_user>:<db_password>@db:5432/<db_name>
      APP_SECRET: <your_app_secret>
      TRACKER_SCRIPT_NAME: script.js
      DISABLE_TELEMETRY: "1"
    depends_on:
      db:
        condition: service_healthy
    init: true
    restart: unless-stopped
    networks:
      - umami-network
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/heartbeat >/dev/null 2>&1 || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 40s

  db:
    container_name: umami-db
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: <db_name>
      POSTGRES_USER: <db_user>
      POSTGRES_PASSWORD: <db_password>
      POSTGRES_INITDB_ARGS: --encoding=UTF-8 --locale=C
      TZ: Asia/Shanghai
    volumes:
      - postgres-data:/var/lib/postgresql/data
    restart: unless-stopped
    stop_grace_period: 60s
    networks:
      - umami-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s

networks:
  umami-network:
    driver: bridge

volumes:
  postgres-data:
    driver: local

这里最重要的变化就是:

volumes:
  - postgres-data:/var/lib/postgresql/data

它使用的是 Docker named volume,而不再是宿主机 bind mount。

八、迁移步骤记录

1. 在旧库正常运行时导出备份

docker exec -t umami-db pg_dump -U <db_user> -d <db_name> > umami-migrate.sql

导出后最好先检查一下文件大小,避免得到一个空文件。

2. 停止服务,并保留旧数据目录

docker compose down
mv postgres-data postgres-data.backup

这一步不要删除旧目录,只做重命名保留,作为回滚点。

3. 切换到 named volume 配置后,仅启动数据库

docker compose up -d db

这里不要急着同时启动 Umami,要先观察数据库本身是否稳定运行。

4. 确认数据库启动成功后,恢复逻辑备份

cat umami-migrate.sql | docker exec -i umami-db psql -U <db_user> -d <db_name>

如果此时数据库容器还在反复重启,就不能导入,必须先看日志定位问题。

5. 启动 Umami 并验证数据

docker compose up -d umami

然后检查容器状态和应用页面,确认统计数据、站点配置等内容已经恢复。


九、这次故障给我的几个结论

1. 先看 PostgreSQL,再看 Umami

很多时候应用只是“受害者”,真正的根因在数据库层。
如果 PostgreSQL 没起来,Umami 报错通常只是结果,不是原因。


2. bind mount 对数据库不是最稳的方案

它不是不能用,但对权限、属主、宿主机环境更敏感。
如果只是单机部署一个相对稳定的服务,Docker named volume 通常更省心。


3. 迁移时不要同时改太多变量

最容易出事的操作方式,就是在一次迁移中同时做这些事:

  • 改卷类型
  • 改数据库密码
  • 改版本
  • PGDATA
  • 改应用配置

正确做法是:一次只改一件事。


4. 旧目录一定先保留

没有验证成功之前,不要删除旧物理数据目录,也不要删除旧备份。
这样即使迁移失败,也能快速回切。


十、最终结果

经过这次处理,服务最终恢复正常,并成功迁移到 named volume 方案。
最初那类由于宿主机目录权限导致的:

pg_control: Permission denied

这类问题,在新的存储方式下基本已经规避掉了。

当然,这不代表数据库从此绝对安全。
异常断电、磁盘写满、宿主机层故障、错误升级,仍然可能导致新的问题。所以后续仍然需要:

  • 定期做数据库备份
  • 保留多个时间点的 SQL 文件
  • 监控磁盘空间
  • 升级时不要混合变更

十一、给未来自己的提醒

如果以后再遇到类似问题,优先按这个顺序处理:

  1. 先看 PostgreSQL 日志
  2. 确认是权限问题、认证问题还是路径问题
  3. 在确认旧数据安全前,不要删卷、不要删目录
  4. 先恢复旧库,再考虑迁移
  5. 迁移时只改一个变量
  6. 数据验证完成前,永远保留旧目录

  • All rights reserved.
  • No part of this website, including text and images, may be reproduced, modified, distributed, or transmitted in any form or by any means, without the prior written permission of the author.
  • Unauthorized commercial use is strictly prohibited.
  • Unauthorized personal use is strictly prohibited.