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:
+4
-1
@@ -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
@@ -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
|
||||
# ---- Gunicorn worker / thread 数 ----
|
||||
# SQLite + WAL 推荐 workers=1 threads=8:单进程写最稳,线程吃满并发。
|
||||
# 想更高吞吐可改 workers=2,但务必确认没有长事务(埋点已经够短)。
|
||||
GUNICORN_WORKERS=1
|
||||
GUNICORN_THREADS=8
|
||||
+6
-2
@@ -117,12 +117,16 @@ dmypy.json
|
||||
# idea
|
||||
.idea/
|
||||
|
||||
# 迁移文件
|
||||
migrations/
|
||||
# 迁移文件 — 必须入库,让别人的环境也能跑 flask db upgrade
|
||||
!migrations/
|
||||
|
||||
# sqlite
|
||||
*.db
|
||||
|
||||
# 数据目录(SQLite 库 + 备份 + session;不入库,部署时靠挂载恢复)
|
||||
data/
|
||||
data/*/
|
||||
|
||||
# 文件上传
|
||||
static/upload/
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
+25
-12
@@ -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
|
||||
+78
-37
@@ -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 并发的场景完全够用。
|
||||
**为什么默认 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')
|
||||
Reference in New Issue
Block a user