diff --git a/.dockerignore b/.dockerignore index fb2b7dc..d4ab49e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index a0f075c..820bd49 100644 --- a/.env.example +++ b/.env.example @@ -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/data,PEAR_DATA_DIR 默认 +# 就是 /app/data,不需要额外改。这里只是给你本地开发调整用的口子。 +# PEAR_DATA_DIR=D:/bwstudio/pear-admin-flask/data + +# 默认 SQLite,WAL 模式由 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 \ No newline at end of file +# ---- Gunicorn worker / thread 数 ---- +# SQLite + WAL 推荐 workers=1 threads=8:单进程写最稳,线程吃满并发。 +# 想更高吞吐可改 workers=2,但务必确认没有长事务(埋点已经够短)。 +GUNICORN_WORKERS=1 +GUNICORN_THREADS=8 \ No newline at end of file diff --git a/.gitignore b/.gitignore index c505ba7..58ebe76 100644 --- a/.gitignore +++ b/.gitignore @@ -117,12 +117,16 @@ dmypy.json # idea .idea/ -# 迁移文件 -migrations/ +# 迁移文件 — 必须入库,让别人的环境也能跑 flask db upgrade +!migrations/ # sqlite *.db +# 数据目录(SQLite 库 + 备份 + session;不入库,部署时靠挂载恢复) +data/ +data/*/ + # 文件上传 static/upload/ diff --git a/applications/__init__.py b/applications/__init__.py index f33061e..cfe614f 100644 --- a/applications/__init__.py +++ b/applications/__init__.py @@ -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) diff --git a/applications/config.py b/applications/config.py index ba6e501..28e824e 100644 --- a/applications/config.py +++ b/applications/config.py @@ -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.db(Docker 下由 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 diff --git a/applications/extensions/init_sqlalchemy.py b/applications/extensions/init_sqlalchemy.py index 8650704..df950c0 100644 --- a/applications/extensions/init_sqlalchemy.py +++ b/applications/extensions/init_sqlalchemy.py @@ -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: diff --git a/deploy/nas/Dockerfile b/deploy/nas/Dockerfile index 1bba123..00f87a5 100644 --- a/deploy/nas/Dockerfile +++ b/deploy/nas/Dockerfile @@ -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 给 appuser(uid 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 diff --git a/deploy/nas/backup.sh b/deploy/nas/backup.sh new file mode 100644 index 0000000..83bfd07 --- /dev/null +++ b/deploy/nas/backup.sh @@ -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}" \ No newline at end of file diff --git a/deploy/nas/docker-compose.yaml b/deploy/nas/docker-compose.yaml index 616ac1c..ad8c25d 100644 --- a/deploy/nas/docker-compose.yaml +++ b/deploy/nas/docker-compose.yaml @@ -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_db:SQLite 数据库文件 -# - flask_logs:容器内 /app/logs(gunicorn + Flask 日志) -# - flask_session:Flask-Session 文件会话 -# - flask_upload:上传的图片/文件 +# 数据持久化(一个目录搞定): +# ./data → 容器内 /app/data +# ├─ pear.db pear.db-wal pear.db-shm ← SQLite(WAL 模式) +# ├─ 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 \ No newline at end of file diff --git a/deploy/nas/start.sh b/deploy/nas/start.sh index f276be5..09c76b9 100644 --- a/deploy/nas/start.sh +++ b/deploy/nas/start.sh @@ -2,23 +2,34 @@ # Pear Admin Flask - NAS 生产启动脚本 # # 流程: -# 1. alembic 迁移到最新版本(幂等;migrations 文件夹随镜像打包) -# 2. flask admin init 幂等初始化菜单/权限(已有则跳过) -# 3. exec 切换到 gunicorn,PID 1 由 gunicorn 接管 +# 1. 数据目录检查:PEAR_DATA_DIR 必须存在且可写;不存在则用默认行为 +# 2. alembic 迁移到最新版本(幂等;migrations 文件夹随镜像打包) +# 3. flask admin init 幂等初始化菜单/权限(已有则跳过) +# 4. exec 切换到 gunicorn,PID 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 \ No newline at end of file diff --git a/docs/deployment-nas.md b/docs/deployment-nas.md index 9f058f3..69a507c 100644 --- a/docs/deployment-nas.md +++ b/docs/deployment-nas.md @@ -20,7 +20,7 @@ NAS 上需安装: ## 2. 准备 .env ```bash -cd /path/to/pear-admin-flask +cd /volume1/docker/pear-admin-flask cp .env.example .env # 编辑 .env:SECRET_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 并发的场景完全够用。 \ No newline at end of file +**为什么默认 workers=1 threads=8 而不是 2×4**:SQLite 写串行;多进程提交事务时仍可能撞锁,gunicorn worker 越多事故面越大。单进程写最稳,线程吃满网络并发。你的场景下 8 线程绰绰有余,要更高把线程拉到 16 就行。 \ No newline at end of file diff --git a/migrations/versions/a47a5d2a3f1b_add_site_nav_click_table.py b/migrations/versions/a47a5d2a3f1b_add_site_nav_click_table.py new file mode 100644 index 0000000..cec33ca --- /dev/null +++ b/migrations/versions/a47a5d2a3f1b_add_site_nav_click_table.py @@ -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') \ No newline at end of file