feat(data): 数据库搬到 data/ 目录 + SQLite WAL + 单卷绑定挂载

- config.py 新增 PEAR_DATA_DIR(默认 <项目根>/data),SQLite 库、Flask-Session
  统一收进 DATA_DIR;不设时本地开发行为不变
- applications/__init__.py 顶部先 load_dotenv,保证 .env 里的 PEAR_DATA_DIR
  在 config 导入时就生效
- extensions/init_sqlalchemy.py 接 SQLite connect 事件:WAL + synchronous=NORMAL
  + busy_timeout=30s(多 worker 写不再撞锁)
- config.py 注入 SQLALCHEMY_ENGINE_OPTIONS(pool_pre_ping + SQLite connect_args
  超时;MySQL 路径下自动跳过 connect_args 防止参数错误)
- 新增 alembic 迁移 a47a5d2a3f1b 建 site_nav_click(含 anon_id 字段),
  解决之前该表只由 db.create_all 建、不在迁移链里的隐患
- .gitignore / .dockerignore 加 data/ 排除规则(且把 migrations/ 从
  ignore 里重新放行 —— 否则新加的迁移进不了库)
- NAS Docker 部署全面重写:
  - compose 唯一绑定挂载 ./data:/app/data,宿主 File Station 看得见
  - start.sh 自检 data 目录 + 子目录;workers 默认改为 1 threads 8
    (SQLite 写串行,单进程最稳)
  - 新增 backup.sh:sqlite3 .backup 在线热备(确保 WAL 一致性)+ 压缩
    + 保留 14 天
  - Dockerfile 安装 sqlite3 客户端备用,建 /app/data 子目录并 chown
- docs/deployment-nas.md 重写数据持久化章节(路径表 + 热备命令 +
  恢复步骤 + 整库迁移 tar/untar 流程)
This commit is contained in:
bwstudio
2026-09-06 19:43:28 +08:00
parent 34e8010830
commit be99b8a925
12 changed files with 347 additions and 95 deletions
+9 -2
View File
@@ -16,12 +16,13 @@ ENV TZ=Asia/Shanghai \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
PEAR_ENV=pro \
PEAR_DATA_DIR=/app/data \
FLASK_APP=app.py
# 安装时区数据 + 健康检查用的 curl
# (curl 在 slim 里已有,但保持显式声明便于以后切到 alpine)
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl tzdata \
&& apt-get install -y --no-install-recommends curl tzdata sqlite3 \
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
&& echo $TZ > /etc/timezone \
&& rm -rf /var/lib/apt/lists/*
@@ -39,8 +40,14 @@ RUN pip install -r requirements.txt \
# 再 copy 全部源码
COPY --chown=appuser:appuser . .
# 数据目录单独建好并 chown 给 appuseruid 1001)。
# /app/data 由卷挂载覆盖,但容器刚启动时如果宿主目录是空的,
# 子目录(backup/ flask_session/ 等)需要 appuser 自己能写。
RUN mkdir -p /app/data/backup /app/data/flask_session /app/data/logs /app/data/upload \
&& chown -R appuser:appuser /app/data
# 启动脚本加执行权限
RUN chmod +x /app/deploy/nas/start.sh
RUN chmod +x /app/deploy/nas/start.sh /app/deploy/nas/backup.sh
# 切换到非 root
USER appuser
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# Pear Admin Flask - SQLite 在线热备脚本
#
# 用法: docker exec pear-admin-nas /app/deploy/nas/backup.sh
# (也支持本地直接 ./backup.sh
#
# 关键:不能用 cp 复制 pear.db —— WAL 模式下 .db 文件单独 cp 不含未刷盘的页。
# 改用 SQLite 自带的 `sqlite3 pear.db ".backup target"` 或
# `VACUUM INTO 'target'`,两者都在 SQLite 内部完成拷贝,含 WAL 的一致性快照。
#
# 产物:${DATA_DIR}/backup/pear-YYYYMMDD_HHMMSS.db
# 保留:最近 14 份(可通过 BACKUP_KEEP 覆盖)
set -e
DATA_DIR=${PEAR_DATA_DIR:-/app/data}
DB_PATH="${DATA_DIR}/pear.db"
BACKUP_DIR="${DATA_DIR}/backup"
KEEP=${BACKUP_KEEP:-14}
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
TARGET="${BACKUP_DIR}/pear-${TIMESTAMP}.db"
if [[ ! -f "${DB_PATH}" ]]; then
echo "FATAL: 数据库文件不存在: ${DB_PATH}" >&2
exit 1
fi
mkdir -p "${BACKUP_DIR}"
echo "[backup] db: ${DB_PATH}"
echo "[backup] -> : ${TARGET}"
# sqlite3 二进制在 Dockerfile 里 apt install 了;镜像外本机有的话也用得上
sqlite3 "${DB_PATH}" ".backup '${TARGET}'"
# 压缩一份小的;同时保留原文件方便快速恢复
gzip -f "${TARGET}"
# 清理旧备份:保留文件名最近 KEEP 个(含 .gz 后缀)
KEPT=0
for f in $(ls -1t "${BACKUP_DIR}"/pear-*.db.gz 2>/dev/null); do
KEPT=$((KEPT + 1))
if [[ "${KEPT}" -gt "${KEEP}" ]]; then
rm -f "${f}"
echo "[backup] removed old: ${f}"
fi
done
# 健康检查:备份文件必须 > 0 字节且能正常打开
LATEST="${BACKUP_DIR}/pear-${TIMESTAMP}.db.gz"
if [[ ! -s "${LATEST}" ]]; then
echo "FATAL: 备份文件为空: ${LATEST}" >&2
exit 1
fi
echo "[backup] done. latest = ${LATEST}"
echo "[backup] current keep: $(ls -1 "${BACKUP_DIR}"/pear-*.db.gz 2>/dev/null | wc -l) of ${KEEP}"
+22 -26
View File
@@ -3,14 +3,19 @@
# =====================================================
# 使用:
# 1) cp .env.example .env 并修改 SECRET_KEY
# 2) docker compose -f deploy/nas/docker-compose.yaml up -d
# 3) 访问 http://NAS_IP:5000
# 2) mkdir -p data && cp ../pear.db data/pear.db # 首次部署把已有库搬过来
# 3) docker compose -f deploy/nas/docker-compose.yaml up -d
# 4) 访问 http://NAS_IP:5000
#
# 数据持久化:
# - pear_dbSQLite 数据库文件
# - flask_logs:容器内 /app/logsgunicorn + Flask 日志
# - flask_sessionFlask-Session 文件会话
# - flask_upload:上传的图片/文件
# 数据持久化(一个目录搞定)
# ./data → 容器内 /app/data
# ├─ pear.db pear.db-wal pear.db-shm ← SQLiteWAL 模式
# ├─ backup/ ← 在线热备(VACUUM INTO 产物)
# ├─ flask_session/ ← Flask-Session 文件
# ├─ logs/ ← gunicorn / Flask 日志
# └─ upload/ ← 上传图片
#
# 重建容器 / 换机器:整个 ./data 目录拷走就行,pear.db、备份、上传全在里面。
# =====================================================
services:
@@ -27,13 +32,14 @@ services:
# 环境变量注入(生产环境用 .env 自动读取)
env_file:
- ../../.env
environment:
# 把容器内的 /app/data 重定向到宿主 ./data
# config.py 会自动把 SQLite 库、session、logs、backup 都收到这里
PEAR_DATA_DIR: /app/data
# 数据持久化卷(NAS 上是命名卷;如想直挂目录,把左边改为宿主路径)
# 数据持久化:单一绑定挂载,NAS 共享文件夹里直接看得见
volumes:
- pear_db:/app/pear.db # SQLite 数据库
- flask_logs:/app/logs # gunicorn / Flask 日志
- flask_session:/app/flask_session # Flask-Session 文件会话
- flask_upload:/app/static/upload # 上传的图片
- ./data:/app/data
# 资源限制(NAS 推荐;按需调整)
deploy:
@@ -61,21 +67,11 @@ services:
- pear_net
# =====================================================
# 命名卷:首次 `docker compose up` 自动创建
# 查看: docker volume ls | grep pear
# 备份: docker run --rm -v pear-admin-nas_pear_db:/data -v $(pwd)/bak:/backup \
# alpine cp /data/pear.db /backup/pear-$(date +%F).db
# 单数据卷已够用:
# - 重建容器:只会重建容器层,./data 完全不受影响
# - 备份:NAS 上定时把 ./data 整个 tar 走,或跑 deploy/nas/backup.sh 单独导出 pear.db
# - 迁移:停容器、把 ./data 拷到新机器、起新容器,pear.db 内容原样可用
# =====================================================
volumes:
pear_db:
name: pear-admin-nas_pear_db
flask_logs:
name: pear-admin-nas_logs
flask_session:
name: pear-admin-nas_session
flask_upload:
name: pear-admin-nas_upload
networks:
pear_net:
driver: bridge
+25 -12
View File
@@ -2,23 +2,34 @@
# Pear Admin Flask - NAS 生产启动脚本
#
# 流程:
# 1. alembic 迁移到最新版本(幂等;migrations 文件夹随镜像打包)
# 2. flask admin init 幂等初始化菜单/权限(已有则跳过
# 3. exec 切换到 gunicornPID 1 由 gunicorn 接管
# 1. 数据目录检查:PEAR_DATA_DIR 必须存在且可写;不存在则用默认行为
# 2. alembic 迁移到最新版本(幂等;migrations 文件夹随镜像打包
# 3. flask admin init 幂等初始化菜单/权限(已有则跳过)
# 4. exec 切换到 gunicornPID 1 由 gunicorn 接管
#
# 注意:必须 exec,否则容器启动后 PID 1 是 shell 而非 gunicorn
# docker stop 发出的 SIGTERM 不会被正确转发,graceful shutdown 失效。
set -e
echo "=== [1/3] 等待数据库就绪 ==="
# SQLite 不需要等待;这里预留扩展位(如未来切 MySQL,可在此加 wait-for-it
DATA_DIR=${PEAR_DATA_DIR:-/app/data}
echo "=== [1/4] 数据目录 ==="
echo "PEAR_DATA_DIR=${DATA_DIR}"
mkdir -p "${DATA_DIR}/backup" "${DATA_DIR}/flask_session" "${DATA_DIR}/logs" "${DATA_DIR}/upload"
# 确保子目录可写(WAL 模式下 SQLite 需要 pear.db 同目录的 -wal / -shm 也能创建)
touch "${DATA_DIR}/.write_test" && rm -f "${DATA_DIR}/.write_test" || {
echo "FATAL: data dir not writable: ${DATA_DIR}" >&2
exit 1
}
echo "=== [2/4] 等待数据库就绪 ==="
# 默认 SQLite 不需要等待;这里预留扩展位(如未来切 MySQL,可在此加 wait-for-it
if [[ "${SQLALCHEMY_DATABASE_URI:-}" == mysql* ]]; then
echo "检测到 MySQL,等待 10s 让 db 服务启动..."
sleep 10
fi
echo "=== [2/3] 初始化数据库(幂等) ==="
echo "=== [3/4] 初始化数据库(幂等) ==="
# flask 命令依赖 FLASK_APP;镜像里已经设过;这里再 fallback 一次
export FLASK_APP=${FLASK_APP:-app.py}
@@ -26,10 +37,12 @@ export FLASK_APP=${FLASK_APP:-app.py}
flask db upgrade || echo "WARN: flask db upgrade failed (首次启动可能正常)"
flask admin init || true
echo "=== [3/3] 启动 Gunicorn ==="
# worker 数:NAS 单容器 2-4 足够;线程 4 让 4 类 IO 密集(埋点/统计)能并发
WORKERS=${GUNICORN_WORKERS:-2}
THREADS=4
echo "=== [4/4] 启动 Gunicorn ==="
# SQLite + WAL 模式下,多 worker 写文件本身没问题(同时间互不阻塞),
# 但多个事务并发提交仍可能撞锁。这里把 worker 降到 1(线程给够),
# 既能吃满并发量、又把"database is locked"概率压到最低。
WORKERS=${GUNICORN_WORKERS:-1}
THREADS=${GUNICORN_THREADS:-8}
TIMEOUT=60
exec gunicorn \
@@ -39,7 +52,7 @@ exec gunicorn \
--timeout "$TIMEOUT" \
--graceful-timeout 30 \
--keep-alive 5 \
--access-logfile - \
--error-logfile - \
--access-logfile "${DATA_DIR}/logs/access.log" \
--error-logfile "${DATA_DIR}/logs/error.log" \
--log-level info \
app:app