这次 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 有机会自动恢复
所以我第一步做的不是迁移,而是先修复旧数据目录权限,把原有数据库拉起来。
修复思路大致是:
- 停掉容器
- 对旧数据目录重新执行
chown和chmod - 重新启动数据库
- 观察 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。
六、最终采用的稳妥迁移方案
最终我采用的是最保守、最可回滚的方式:
- 先在旧库正常运行时导出逻辑备份
- 停掉服务
- 保留旧物理目录不删除
- 把数据库卷改成 Docker named volume
- 用新卷初始化一个干净数据库
- 将 SQL 备份恢复到新卷
- 再启动 Umami
- 验证数据完整后再考虑清理旧目录
这个方案的关键是:
- 旧数据目录保留,随时可以回滚
- 逻辑备份可用于新卷恢复
- 整个迁移过程中只改变“存储方式”这一项变量
七、最终稳定运行的 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 文件
- 监控磁盘空间
- 升级时不要混合变更
十一、给未来自己的提醒
如果以后再遇到类似问题,优先按这个顺序处理:
- 先看 PostgreSQL 日志
- 确认是权限问题、认证问题还是路径问题
- 在确认旧数据安全前,不要删卷、不要删目录
- 先恢复旧库,再考虑迁移
- 迁移时只改一个变量
- 数据验证完成前,永远保留旧目录
- 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.
