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
+4 -1
View File
@@ -22,10 +22,13 @@ venv/
env/
# ---- 数据库 / 运行时数据 ----
# 注意:pear.db 不在源码里(运行时由容器创建 + 卷挂载),这里排除只是兜底
# pear.db 不在源码里(运行时由容器创建 + 卷挂载),这里排除只是兜底
*.db
*.db.bak
pear.db.before_dedup.*
# 数据目录(含 pear.db / backup / flask_session),整目录不进镜像,由 volume 接管
data/
data/*/
# ---- 日志 ----
*.log
+13 -5
View File
@@ -20,9 +20,14 @@ FLASK_APP=app.py
SECRET_KEY=PLEASE_REPLACE_WITH_RANDOM_STRING
# ---- 数据库 ----
# 默认 SQLite,存到容器内 /app/pear.db(已挂载为命名卷 pear_db,重启不丢)
# 如需切换 MySQL
# SQLALCHEMY_DATABASE_URI=mysql+pymysql://user:pass@db:3306/pear?charset=utf8mb4
# 默认 SQLite;库文件、session、备份都收进 PEAR_DATA_DIR 指向的目录。
# docker-compose.yaml 已经把宿主 ./data 绑到容器 /app/dataPEAR_DATA_DIR 默认
# 就是 /app/data,不需要额外改。这里只是给你本地开发调整用的口子。
# PEAR_DATA_DIR=D:/bwstudio/pear-admin-flask/data
# 默认 SQLiteWAL 模式由 extensions 自动开启(多 worker 写也不容易撞锁)。
# 想换成 MySQL 就解注释下面这行;docker-compose.yaml 里也写好了 db 服务可参考:
# SQLALCHEMY_DATABASE_URI=mysql+pymysql://pear:pear@db:3306/pear?charset=utf8mb4
# SQLALCHEMY_DATABASE_URI=sqlite:///../pear.db
# ---- 时区 ----
@@ -40,5 +45,8 @@ TZ=Asia/Shanghai
# 仅在 docker-compose.yaml 的 ports 段同步修改时使用;默认 5000
APP_PORT=5000
# ---- Gunicorn worker 数量(NAS 单容器推荐 2-4----
# GUNICORN_WORKERS=2
# ---- Gunicorn worker / thread 数 ----
# SQLite + WAL 推荐 workers=1 threads=8:单进程写最稳,线程吃满并发。
# 想更高吞吐可改 workers=2,但务必确认没有长事务(埋点已经够短)。
GUNICORN_WORKERS=1
GUNICORN_THREADS=8
+6 -2
View File
@@ -117,12 +117,16 @@ dmypy.json
# idea
.idea/
# 迁移文件
migrations/
# 迁移文件 — 必须入库,让别人的环境也能跑 flask db upgrade
!migrations/
# sqlite
*.db
# 数据目录(SQLite 库 + 备份 + session;不入库,部署时靠挂载恢复)
data/
data/*/
# 文件上传
static/upload/
+20 -7
View File
@@ -3,18 +3,21 @@ import os
from dotenv import load_dotenv
from flask import Flask
from applications.common.script import init_script
from applications.config import BaseConfig, get_config_by_name
from applications.extensions import init_plugs
from applications.view import init_bps
# 项目根目录 = 本文件父目录的父目录
BASE_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
# 必须先于 applications.config 加载:config 在模块级就要读 PEAR_DATA_DIR
# 等环境变量来拼 data 目录路径,晚一步就会沿用默认值。
load_dotenv(os.path.join(BASE_DIR, ".env"), override=False)
from applications.common.script import init_script
from applications.config import BaseConfig, get_config_by_name, ensure_data_dir
from applications.extensions import init_plugs
from applications.view import init_bps
def create_app():
# 1) 加载 .env在 BaseConfig 读取 os.environ 之前
# override=False:环境变量优先级高于 .env,便于 CI/CD 在部署时再覆盖
# 1) 加载一次 .env幂等;override=False 保证环境变量优先级更高
load_dotenv(os.path.join(BASE_DIR, ".env"), override=False)
app = Flask(BASE_DIR)
@@ -32,6 +35,16 @@ def create_app():
if hasattr(config_cls, "init_app"):
config_cls.init_app(app)
# 3.6) 数据目录兜底:确保 data/ 存在,并以运行时解析结果为准写回路径
# .env 里配了 PEAR_DATA_DIR 时,这里保证它一定生效)
data_dir = ensure_data_dir()
app.config["DATA_DIR"] = data_dir
if not os.environ.get("SQLALCHEMY_DATABASE_URI"):
app.config["SQLALCHEMY_DATABASE_URI"] = (
"sqlite:///" + os.path.join(data_dir, "pear.db").replace("\\", "/")
)
app.config["SESSION_FILE_DIR"] = os.path.join(data_dir, "flask_session")
# 4) 注册 flask 组件 / 蓝图 / 命令
init_plugs(app)
init_bps(app)
+50 -3
View File
@@ -17,6 +17,44 @@ def _env_str(key: str, default: str = "") -> str:
return val if val is not None and val != "" else default
# ---- 数据目录(持久化根目录)----
#
# 所有「会变、需要留下来」的东西统一放这里:
# data/pear.db SQLite 主库(含 -wal / -shm
# data/backup/ 在线热备产物
# data/flask_session/ Flask-Session 文件会话
#
# 为什么要单独一个目录:
# 项目根目录会被 Git / Docker 镜像整包替换,数据库混在源码里既容易误提交,
# 也会在容器重建时随镜像层一起丢掉。独立成 data/ 之后:
# - 宿主机/NAS 上只要备份或绑定挂载这一个目录,数据就在;
# - .gitignore / .dockerignore 各写一条就能彻底隔开。
#
# 默认值:项目根目录下的 data/applications/ 的上一级)。
# Docker 部署时用环境变量 PEAR_DATA_DIR=/app/data 覆盖,
# 再把 NAS 共享文件夹绑到 /app/data 即可。
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.abspath(os.environ.get("PEAR_DATA_DIR") or os.path.join(_PROJECT_ROOT, "data"))
def _sqlite_uri(filename: str) -> str:
"""拼 SQLite URI。统一换成正斜杠,保证 Linux 容器与 Windows 都能解析。"""
return "sqlite:///" + os.path.join(DATA_DIR, filename).replace("\\", "/")
def ensure_data_dir() -> str:
"""确保数据目录(及子目录)存在,返回根目录绝对路径。
在 create_app 里调用;这里再兜一层 try,避免只读环境下 import 就崩。
"""
for sub in ("", "backup", "flask_session"):
try:
os.makedirs(os.path.join(DATA_DIR, sub), exist_ok=True)
except OSError:
pass
return DATA_DIR
class BaseConfig:
"""通用配置:所有环境共有的项。子类按需覆盖。"""
@@ -39,10 +77,18 @@ class BaseConfig:
JSON_AS_ASCII = False
# ---- 数据库 ----
# 默认走项目根目录下的 pear.db(与 instance/ 同一级,Flask 2.x 行为)。
# 生产环境推荐通过 SQLALCHEMY_DATABASE_URI 环境变量切换到 MySQL
SQLALCHEMY_DATABASE_URI = 'sqlite:///../pear.db'
# 默认走 data/pear.dbDocker 下由 PEAR_DATA_DIR 重定向到挂载点)。
# 想换成 MySQL 就直接设环境变量 SQLALCHEMY_DATABASE_URI,优先级最高
SQLALCHEMY_DATABASE_URI = os.environ.get(
"SQLALCHEMY_DATABASE_URI", _sqlite_uri("pear.db")
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True}
if SQLALCHEMY_DATABASE_URI.startswith("sqlite"):
# SQLite 写保护:拿不到锁最多等 30s 再放弃,避免 gunicorn 多 worker 下
# 偶发 "database is locked"。WAL 模式由 extensions 里的 connect 事件开启。
# 注意:这组 connect_args 是 sqlite3 专用的,切 MySQL 时不能带上。
SQLALCHEMY_ENGINE_OPTIONS["connect_args"] = {"timeout": 30}
# ---- 日志 ----
LOG_LEVEL = logging.WARN
@@ -50,6 +96,7 @@ class BaseConfig:
# ---- Session ----
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
SESSION_TYPE = "filesystem"
SESSION_FILE_DIR = os.path.join(DATA_DIR, "flask_session")
SESSION_PERMANENT = False
SESSION_USE_SIGNER = True
@@ -4,6 +4,8 @@ import os
from flask import Flask, request
from flask_sqlalchemy import SQLAlchemy
from flask_sqlalchemy.query import Query as BaseQuery
from sqlalchemy import event
from sqlalchemy.engine import Engine
from flask_marshmallow import Marshmallow
from marshmallow import fields
from marshmallow.validate import (
@@ -115,6 +117,24 @@ ma = Marshmallow()
def init_databases(app: Flask):
db.init_app(app)
ma.init_app(app)
# SQLite 才启用 WAL。WAL 把读与写互不阻塞,gunicorn 多 worker 写时基本不再
# 撞 "database is locked";同目录下会产生 pear.db-wal / pear.db-shm 两个文件,
# 备份必须用 VACUUM INTO 或 sqlite3 .backup,不能直接 cp(见 deploy/nas/backup.sh)。
if app.config.get("SQLALCHEMY_DATABASE_URI", "").startswith("sqlite"):
@event.listens_for(Engine, "connect")
def _sqlite_pragmas(dbapi_connection, connection_record):
cur = dbapi_connection.cursor()
try:
cur.execute("PRAGMA journal_mode=WAL")
cur.execute("PRAGMA synchronous=NORMAL")
cur.execute("PRAGMA foreign_keys=ON")
# busy_timeout 在 config.py 的 connect_args 里已设;这里再兜一次
# 兜底,防止用户自定义 connect_args 时漏配
cur.execute("PRAGMA busy_timeout=30000")
finally:
cur.close()
if os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
with app.app_context():
try:
+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
+78 -37
View File
@@ -20,7 +20,7 @@ NAS 上需安装:
## 2. 准备 .env
```bash
cd /path/to/pear-admin-flask
cd /volume1/docker/pear-admin-flask
cp .env.example .env
# 编辑 .envSECRET_KEY 必须改成强随机字符串
python -c "import secrets; print(secrets.token_urlsafe(48))"
@@ -37,6 +37,10 @@ python -c "import secrets; print(secrets.token_urlsafe(48))"
# 进入项目目录
cd /volume1/docker/pear-admin-flask # 改成你自己的路径
# 如果你之前用过 pear-admin-flask,要把现有库搬过来:
mkdir -p data
cp pear.db data/pear.db # 把根目录的库搬到 data/ 下(容器会把整个 ./data 挂进 /app/data
# 构建镜像(首次约 3-5 分钟)
docker compose -f deploy/nas/docker-compose.yaml build
@@ -84,37 +88,74 @@ ports:
## 6. 数据持久化
| 卷名 | 容器内路径 | 内容 |
|---|---|---|
| `pear-admin-nas_pear_db` | `/app/pear.db` | SQLite 数据库 |
| `pear-admin-nas_logs` | `/app/logs` | gunicorn / Flask 日志 |
| `pear-admin-nas_session` | `/app/flask_session` | 登录会话文件 |
| `pear-admin-nas_upload` | `/app/static/upload` | 上传图片 |
整个数据库 / 会话 / 备份 / 上传,**统一收进宿主 `./data` 目录**,容器内路径 `/app/data`
查看卷:
```bash
docker volume ls | grep pear
```
./data # docker-compose 唯一绑定挂载
├── pear.db # SQLite 主库(WAL 模式)
├── pear.db-wal # WAL 文件(不要删)
├── pear.db-shm # 共享内存文件(不要删)
├── backup/ # 在线热备(自动保留最近 14 天)
│ └── pear-20260906_020000.db.gz
├── flask_session/ # Flask-Session 文件
├── logs/ # gunicorn / Flask 日志
│ ├── access.log
│ └── error.log
└── upload/ # 上传图片
```
### 备份 SQLite 数据库
在 NAS File Station 里直接看得到、可以直接 tar 走 —— 这是从"看不见的虚拟卷"改成"看得见的文件夹"的核心收益。
### 备份(在线热备)
容器镜像里带了 `sqlite3``deploy/nas/backup.sh``sqlite3 .backup`(含 WAL 一致性,比 `cp` 安全):
```bash
# 备份(推荐每天 1 次,可加入 cron)
docker run --rm \
-v pear-admin-nas_pear_db:/data \
-v /volume1/docker/pear-backup:/backup \
alpine sh -c "cp /data/pear.db /backup/pear-$(date +%F).db && ls -la /backup/"
docker exec pear-admin-nas /app/deploy/nas/backup.sh
# 输出:[backup] done. latest = /app/data/backup/pear-20260906_020000.db.gz
```
恢复:
### 自动备份(推荐,群晖 / 威联通 / Linux 都适用)
群晖 DSM:「控制面板 → 任务计划表 → 新增 → 计划的任务 → 用户定义的脚本」:
```
每天 02:00 跑:
docker exec pear-admin-nas /app/deploy/nas/backup.sh
```
或者宿主 crontab
```bash
0 2 * * * docker exec pear-admin-nas /app/deploy/nas/backup.sh
```
默认保留 14 天(`BACKUP_KEEP=14` 可改)。
### 恢复
```bash
# 停容器
docker compose -f deploy/nas/docker-compose.yaml down
docker run --rm \
-v pear-admin-nas_pear_db:/data \
-v /volume1/docker/pear-backup:/backup \
alpine sh -c "rm -f /data/pear.db && cp /backup/pear-2026-09-06.db /data/pear.db"
# 把备份解开(注意:必须先停容器才能覆盖 pear.db,否则 SQLite 还在持有文件锁)
gunzip -c data/backup/pear-20260906_020000.db.gz > data/pear.db
# 顺带把 -wal / -shm 也清掉(WAL 会自动重建)
rm -f data/pear.db-wal data/pear.db-shm
# 起回去
docker compose -f deploy/nas/docker-compose.yaml up -d
```
整库迁移到新机器:
```bash
# 旧机器:tar 整个 data 目录(不算大,库 100MB 级别)
cd /volume1/docker/pear-admin-flask
tar czf pear-data-$(date +%F).tgz data/
# 新机器:解开 → 启动即用
tar xzf pear-data-2026-09-06.tgz -C /volume1/docker/pear-admin-flask/
docker compose -f deploy/nas/docker-compose.yaml up -d
```
@@ -137,7 +178,7 @@ docker compose -f deploy/nas/docker-compose.yaml up -d
curl -i http://NAS_IP:5000/healthz
```
数据库迁移由 `start.sh` 中的 `flask db upgrade` 自动执行(幂等)。
数据库迁移由 `start.sh` 中的 `flask db upgrade` 自动执行(幂等;含 `site_nav_click` 等插件表的迁移)。
---
@@ -152,6 +193,7 @@ docker compose -f deploy/nas/docker-compose.yaml logs --tail=50
最常见原因:
- `SECRET_KEY` 还是占位符 `PLEASE_REPLACE_WITH_RANDOM_STRING` → ProConfig 会拒绝启动
- 端口被占用 → 修改 `ports`
- `data/` 目录存在但权限不对(容器内 uid 1001 没写权限)→ `chown -R 1001:1001 data/`
### Q2:访问首页 502 / 拒绝连接
@@ -165,26 +207,24 @@ docker inspect --format='{{.State.Health.Status}}' pear-admin-nas
### Q3:忘记 admin 密码
直接重置(删容器、再用空 pear.db 启动,再 `flask admin init` 重新生成默认账号):
直接重置(删除整个 `data/pear.db`,再启动让 `flask admin init` 重新生成默认账号):
```bash
docker compose -f deploy/nas/docker-compose.yaml down
docker volume rm pear-admin-nas_pear_db
rm -f data/pear.db data/pear.db-wal data/pear.db-shm
docker compose -f deploy/nas/docker-compose.yaml up -d
```
⚠️ **此操作会清空所有数据**,务必先份。
⚠️ **此操作会清空所有数据**,务必先 `docker exec … backup.sh`份。
### Q4:日志占满磁盘
```bash
# 查看日志大小
docker system df -v
# 清理已停止容器的日志
docker compose -f deploy/nas/docker-compose.yaml down
docker volume rm pear-admin-nas_logs
docker compose -f deploy/nas/docker-compose.yaml up -d
# 看 data/logs 实际大小
du -sh data/logs
# 清空(保留文件)
: > data/logs/access.log
: > data/logs/error.log
```
### Q5:想要外网访问
@@ -211,9 +251,10 @@ deploy:
或减少 worker
```yaml
environment:
- GUNICORN_WORKERS=1 # 内存紧张时降到 1
```env
# .env 里
GUNICORN_WORKERS=1
GUNICORN_THREADS=16 # SQLite + WAL 单进程跑,线程数给够
```
Gunicorn 默认 `2 workers × 4 threads = 8 个并发槽位**,对 ≤ 50 并发的场景完全够用
**为什么默认 workers=1 threads=8 而不是 2×4**:SQLite 写串行;多进程提交事务时仍可能撞锁,gunicorn worker 越多事故面越大。单进程写最稳,线程吃满网络并发。你的场景下 8 线程绰绰有余,要更高把线程拉到 16 就行
@@ -0,0 +1,45 @@
"""手动迁移:建 site_nav_click(之前是 db.create_all() 建的,不在 alembic 里)。
现有 DB 已经有一张同名的表,迁移以空操作为主;对空 DB(首次部署 / 新容器)会建表。
"""
from alembic import op
import sqlalchemy as sa
revision = 'a47a5d2a3f1b'
down_revision = 'afa5cfb23a85'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'site_nav_click' in inspector.get_table_names():
# 已存在的 DB:表在,但 alembic 视角下没版本记录 → 跳过 DDL
# 同时保证 anon_id 列存在(方案 C 升级路径)
cols = {c['name'] for c in inspector.get_columns('site_nav_click')}
with op.batch_alter_table('site_nav_click') as batch:
if 'anon_id' not in cols:
batch.add_column(sa.Column('anon_id', sa.String(length=36), nullable=True, comment='匿名访客ID'))
batch.create_index('ix_site_nav_click_anon_id', ['anon_id'], unique=False)
return
op.create_table(
'site_nav_click',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='记录ID'),
sa.Column('nav_id', sa.Integer(), nullable=False, comment='被点击的导航ID'),
sa.Column('anon_id', sa.String(length=36), nullable=True, comment='匿名访客ID'),
sa.Column('day', sa.String(length=10), nullable=True, comment='日期 YYYY-MM-DD'),
sa.Column('ip', sa.String(length=64), nullable=True, comment='访客IP'),
sa.Column('clicked_at', sa.DateTime(), nullable=True, comment='点击时间'),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('site_nav_click') as batch:
batch.create_index('ix_site_nav_click_nav_id', ['nav_id'], unique=False)
batch.create_index('ix_site_nav_click_anon_id', ['anon_id'], unique=False)
batch.create_index('ix_site_nav_click_day', ['day'], unique=False)
batch.create_index('ix_site_nav_click_clicked_at', ['clicked_at'], unique=False)
def downgrade():
op.drop_table('site_nav_click')