From 10db94c1627d209b2dacab4fe0ce6fbe30018d82 Mon Sep 17 00:00:00 2001 From: wangchuanli Date: Mon, 14 Sep 2026 15:09:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(docker):=20=E6=95=B0=E6=8D=AE=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20Docker=20=E5=91=BD=E5=90=8D=E5=8D=B7=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=AE=B9=E5=99=A8=E6=89=93=E4=B8=8D=E5=BC=80=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 容器跑着跑着页面全部 500,应用日志: sqlite3.OperationalError: unable to open database file (db.py:33, conn.execute("PRAGMA journal_mode=WAL")) ## 根因(已最小复现) 数据原本用绑定挂载(./data:/app/data)。Windows + Docker Desktop 的绑定挂载走 9p (mount 里是 type 9p, aname=drvfs;path=C:\)。9p 本身支持 WAL(新建库能开 WAL), 但**宿主的 Windows 进程打开过这个 WAL 库之后**,容器侧缓存的 -shm 映射就失效, 下一次连接无法重建共享内存文件 → 打不开数据库,且**不会自愈**。 复现: docker compose -f docker-compose.yml -f docker-compose.hostdir.yml up -d docker compose exec portal python -c "..." # OK, 1665 条 python manage.py stats # 宿主侧纯读一次 docker compose exec portal python -c "..." # ERR unable to open database file # 只有 docker compose restart portal 才恢复 ## 处理 - docker-compose.yml 改用命名卷 wb_data / wb_logs(容器独占 /app/data) - 新增 docker-compose.hostdir.yml 叠加层:需要宿主目录时用 docker compose -f docker-compose.yml -f docker-compose.hostdir.yml up -d (注明**只建议 Linux**;Linux 的 bind mount 与容器同一文件系统,无此问题) - 数据迁移:docker run --rm -v workbuddy-portal_wb_data:/to -v "$PWD/data":/from:ro \ alpine:3.20 sh -c 'cp -a /from/. /to/' - 文档同步:DEPLOYMENT 2.4/5.4/第六节全部改为命名卷 + 备份恢复用 docker run; 新增第九节「Windows 绑定挂载的坑」(含复现步骤);FAQ、USER-GUIDE、README、CHANGELOG 同步 ## 验证 - 宿主跑 manage.py stats 与 smoke.py 之后,容器侧仍能正常读写(此前会立刻失效) - 容器实例 check_live 56/56;离线 smoke 99/99;hostdir 叠加层 config 校验通过 --- .env.example | 7 ++ README.md | 13 ++- docker-compose.hostdir.yml | 21 ++++ docker-compose.yml | 19 +++- docs/CHANGELOG.md | 12 ++- docs/DEPLOYMENT.md | 206 ++++++++++++++++++++++++++++++------- docs/FAQ.md | 56 ++++++++-- docs/USER-GUIDE.md | 10 +- 8 files changed, 281 insertions(+), 63 deletions(-) create mode 100644 docker-compose.hostdir.yml diff --git a/.env.example b/.env.example index d14dfaa..bb65af7 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,10 @@ WB_IMPORT_XLSX= # ---------- 镜像名(推送 Gitea 注册表时用)---------- # WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:latest # WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:1.1.0 + +# ---------- 仅叠加 docker-compose.hostdir.yml 时有效 ---------- +# 把数据/日志放到宿主机目录而不是命名卷。**只建议 Linux 宿主机使用**: +# Windows + Docker Desktop 的 9p 挂载下,宿主进程访问过 WAL 库之后, +# 容器侧会打不开数据库且不自愈(详见 docs/DEPLOYMENT.md)。 +# WB_HOST_DATA_DIR=./data +# WB_HOST_LOG_DIR=./logs diff --git a/README.md b/README.md index 74c320d..c45cdfe 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,12 @@ docker compose logs -f # Ctrl-C 退出日志跟踪,容器继续跑 打开 `http://<本机IP>:8848` → 用 `.env` 里设的账号登录 → 去「配置管理」粘贴 Cookie。 -> 数据落在宿主机 `./data/`、日志落在 `./logs/`,`docker compose down` 不会删数据。 -> **Linux 宿主机**上首次运行可能要 `sudo chown -R 1000:1000 ./data ./logs`(容器内以 uid 1000 运行)。 +> 数据与日志放在 Docker **命名卷**(`workbuddy-portal_wb_data` / `_wb_logs`)里, +> `docker compose down` 不会删。要用 CLI 就 `docker compose exec portal python manage.py …`。 +> **不要在宿主机上跑 `manage.py` 去连容器的库**——Windows + Docker Desktop 的 9p 挂载下, +> 宿主进程碰一次 WAL 库就会让容器打不开数据库(纯读也会触发,且不自愈)。 +> 想直接看到数据/日志,用 `docker-compose.hostdir.yml` 叠加层(**仅建议 Linux 宿主机**)。 +> 详见 [部署与运维指南](docs/DEPLOYMENT.md#windows-绑定挂载的坑容器打不开数据库)。 ### 方式二:裸机 Python @@ -197,8 +201,9 @@ workbuddy-portal/ ├── manage.py 统一 CLI(唯一入口) ├── requirements.txt ├── Dockerfile 多阶段构建(依赖层 / 运行层) -├── docker-compose.yml 单服务编排(数据绑定挂载) -├── .env.example 环境变量样例 +├── docker-compose.yml 单服务编排(数据放 Docker 命名卷) +├── docker-compose.hostdir.yml 可选叠加层:改用宿主机目录(仅建议 Linux) +├── .env.example 环境变量样例 ├── docker/ │ ├── entrypoint.sh 幂等初始化 → exec serve(LF 行尾) │ └── healthcheck.py 标准库健康检查(免登录页 /login) diff --git a/docker-compose.hostdir.yml b/docker-compose.hostdir.yml new file mode 100644 index 0000000..484c4c4 --- /dev/null +++ b/docker-compose.hostdir.yml @@ -0,0 +1,21 @@ +# ============================================================================= +# 可选叠加层:把数据 / 日志放到宿主机目录,而不是 Docker 命名卷。 +# +# docker compose -f docker-compose.yml -f docker-compose.hostdir.yml up -d +# +# 好处:`./data/usage.sqlite` 直接可见,备份就是拷目录,宿主机的 manage.py 也能直接读。 +# +# ⚠️ 只建议在 **Linux 宿主机** 上用(bind mount 与容器是同一个文件系统, +# SQLite 的 WAL / 文件锁语义正常)。 +# +# ⚠️ **Windows + Docker Desktop 上不要用**:那里的绑定挂载走 9p(`path=C:\`), +# 宿主的 Windows 进程一旦访问过这个 WAL 库——哪怕只是 `manage.py status` 这种纯读—— +# 容器侧下一次打开就会 `sqlite3.OperationalError: unable to open database file`, +# 而且**不会自愈**,必须 `docker compose restart portal`。 +# 复现步骤与原理见 docs/DEPLOYMENT.md「Windows 绑定挂载的坑」。 +# ============================================================================= +services: + portal: + volumes: + - ${WB_HOST_DATA_DIR:-./data}:/app/data + - ${WB_HOST_LOG_DIR:-./logs}:/app/logs diff --git a/docker-compose.yml b/docker-compose.yml index 822de02..ea0a932 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,13 +3,18 @@ # # docker compose up -d --build 本机构建并启动 # docker compose logs -f 跟踪日志 -# docker compose down 停止(数据留在 ./data,不会丢) +# docker compose down 停止(数据在命名卷里,不会丢) # # 设计取舍: # * 刻意只有**一个**服务:SQLite 是单写者,调度线程也在 Web 进程内, # 多副本只会带来锁竞争与重复采集,所以不做横向扩展。 -# * data/ 与 logs/ 用**绑定挂载**而非命名卷:正本就是宿主机上的 -# data/usage.sqlite,备份就是拷目录,宿主机上的 manage.py 也能直接读同一份数据。 +# * 数据用**命名卷**而不是绑定挂载。这不是随手选的: +# Windows + Docker Desktop 走 9p 挂载,宿主的 Windows 进程一旦访问过 +# 这个 WAL 库(哪怕只是 `manage.py stats` 这种纯读),容器侧下一次打开就会 +# `sqlite3.OperationalError: unable to open database file`,且**不会自愈**, +# 必须重启容器。命名卷住在 Linux VM 的本地文件系统里,不存在这个问题。 +# 需要在宿主机直接看到数据/日志时,叠加 docker-compose.hostdir.yml +# (只建议 Linux 宿主机用)。 # ============================================================================= name: workbuddy-portal @@ -35,8 +40,8 @@ services: WB_IMPORT_CREDS: ${WB_IMPORT_CREDS:-0} WB_IMPORT_XLSX: ${WB_IMPORT_XLSX:-} volumes: - - ./data:/app/data # 数据正本 + 导出 + secret_key - - ./logs:/app/logs # 应用日志(滚动 2 MB × 3) + - wb_data:/app/data # 数据正本 + 导出 + secret_key + - wb_logs:/app/logs # 应用日志(滚动 2 MB × 3) # 可选:把编辑器配置挂进来,配合 WB_IMPORT_CREDS=1 自动接管 cookie # - ${WB_EDITOR_SETTINGS:-./nonexistent.json}:/mnt/editor-settings.json:ro healthcheck: @@ -50,3 +55,7 @@ services: options: max-size: "10m" max-file: "3" + +volumes: + wb_data: + wb_logs: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9dcb28c..381cf59 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,7 +15,8 @@ ### 新增 - **Docker 化**:多阶段 `Dockerfile`(依赖层与运行层分离,改业务代码不触发重装依赖)、 - `docker-compose.yml`(单服务、数据绑定挂载、健康检查、日志轮转)、 + `docker-compose.yml`(单服务、**数据放 Docker 命名卷**、健康检查、日志轮转)、 + `docker-compose.hostdir.yml`(可选叠加层:把数据/日志放到宿主机目录,仅建议 Linux)、 `docker/entrypoint.sh`(幂等初始化 → exec 交接)、`docker/healthcheck.py`(纯标准库)、 `.dockerignore`、`.env.example` - **容器环境变量**:`WB_HOST` `WB_PORT` `WB_DATA_DIR` `WB_LOG_DIR` `WB_DB` @@ -58,6 +59,15 @@ > 真实 HTTP 只看状态码),是加上 Playwright 截图后才发现的。 > 据此给 `tools/smoke.py` 补了「页面所有 `src`/`href` 资源引用逐个断言 200」一节。 +另外在容器实测中发现并修掉一个**部署期才暴露**的问题: + +| 症状 | 根因 | 处理 | +|---|---|---| +| 容器跑着跑着页面全 500,日志里 `sqlite3.OperationalError: unable to open database file` | 数据原本用**绑定挂载**;Windows + Docker Desktop 走 **9p**,宿主的 Windows 进程只要访问过这个 WAL 库(**纯读也会触发**),容器侧下一次连接就重建不了 `-shm`,且**不会自愈** | 改为 **Docker 命名卷**(容器独占数据目录);需要宿主目录时叠加 `docker-compose.hostdir.yml`(仅建议 Linux) | + +最小复现:容器正常 → 宿主跑一次 `manage.py stats` → 容器立刻打不开库、且重启前不再恢复。 +已写进 [DEPLOYMENT.md 第九节](DEPLOYMENT.md#windows-绑定挂载的坑容器打不开数据库)。 + ### 安全 - 新增 `security.safe_next()`:登录跳转的 `next` 拒绝 `//evil.com`(协议相对 URL)、 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index f282bf1..643c339 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -65,17 +65,51 @@ docker compose logs -f | `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(只跑手动采集) | | `WB_IMPORT_CREDS` | `0` | `1` = 启动时尝试从挂载的编辑器配置导入 Cookie | -### 2.4 数据落点 +### 2.4 数据落点:用命名卷,不用绑定挂载 -| 容器内 | 宿主机 | 内容 | +| 容器内 | 存放位置 | 内容 | |---|---|---| -| `/app/data` | `./data` | `usage.sqlite`(正本)、`instance.json`(secret_key)、`exports/` | -| `/app/logs` | `./logs` | `app.log`(滚动 2 MB × 3) | +| `/app/data` | Docker 命名卷 `workbuddy-portal_wb_data` | `usage.sqlite`(正本)、`instance.json`(secret_key)、`exports/` | +| `/app/logs` | Docker 命名卷 `workbuddy-portal_wb_logs` | `app.log`(滚动 2 MB × 3) | -**绑定挂载**而非命名卷,是为了:备份就是拷目录;宿主机上的 `manage.py` 能直接读同一份数据。 +**为什么是命名卷而不是脚本目录里的 `./data`**(这不是随手选的): -> **Linux 宿主机首次运行**若报 `unable to open database file`, -> 是宿主目录属主与容器内 uid 1000 不一致: +> Windows + Docker Desktop 的绑定挂载走 **9p**(`aname=drvfs;path=C:\`)。 +> 宿主的 Windows 进程只要访问过这个 WAL 库——**哪怕只是 `manage.py stats` 这种纯读**—— +> 容器侧下一次打开就会 `sqlite3.OperationalError: unable to open database file`, +> 而且**不会自愈**,必须 `docker compose restart portal`。实测复现见 +> [第九节「Windows 绑定挂载的坑」](#windows-绑定挂载的坑容器打不开数据库)。 + +命名卷住在 Linux VM 的本地文件系统里,不存在跨文件系统翻译的问题, +所以容器独占数据目录是**唯一在所有平台上都正确**的做法。 + +**代价**:宿主机的 `manage.py` 不能直接读容器里的库了。随之而来的三件事都有一行命令替代: + +```bash +# 在容器里跑 CLI(推荐,始终打到同一份数据) +docker compose exec portal python manage.py stats +docker compose exec portal python manage.py vacuum +docker compose exec portal python manage.py passwd admin 新密码 + +# 看日志 +docker compose logs -f portal + +# 备份 / 恢复:见第六节 +``` + +#### 想要「宿主机直接看到数据/日志」怎么办 + +叠加 `docker-compose.hostdir.yml` 即可: + +```bash +docker compose -f docker-compose.yml -f docker-compose.hostdir.yml up -d +# 可用 WB_HOST_DATA_DIR / WB_HOST_LOG_DIR 指定目标目录 +``` + +> ⚠️ **只建议 Linux 宿主机使用**(bind mount 与容器是同一个文件系统, +> SQLite 的 WAL 与文件锁语义正常)。Windows + Docker Desktop 下会踩上面那个坑。 +> +> Linux 首次运行若报 `unable to open database file`,是宿主目录属主与容器内 uid 1000 不一致: > ```bash > sudo chown -R 1000:1000 ./data ./logs > ``` @@ -97,8 +131,9 @@ docker compose exec portal python manage.py vacuum docker compose exec portal python manage.py collect # 手动采集一次 ``` -> **本机调试**(Docker Desktop on Windows)已验证: -> 绑定挂载上的 SQLite(WAL)读写正常,调度补跑、采集、导出、CSV 流式下载都可用。 +> **本机调试**(Docker Desktop on Windows)已实测:命名卷上的 SQLite(WAL)读写正常, +> 启动补采、采集、导出、CSV 流式下载、健康检查全部可用; +> 且**宿主侧再跑 `manage.py` / `tools/smoke.py` 都不会影响容器**(这正是改用命名卷的原因)。 --- @@ -283,9 +318,14 @@ services: ports: ["8848:8848"] environment: TZ: Asia/Shanghai + WB_ADMIN_PASSWORD: "改成你的强密码" volumes: - - ./data:/app/data - - ./logs:/app/logs + - wb_data:/app/data + - wb_logs:/app/logs + +volumes: + wb_data: + wb_logs: YAML docker compose up -d @@ -306,51 +346,95 @@ curl -s -u wangchuanli:TOKEN \ ### 6.1 备份什么 +数据都在命名卷 `workbuddy-portal_wb_data` 里(对应容器内 `/app/data`): + | 文件 | 重要性 | 说明 | |---|---|---| -| `data/usage.sqlite` | ★★★ | **数据正本**,丢了要重新采集,且官网窗口外的数据永久丢失 | -| `data/usage.sqlite-wal` / `-shm` | ★★★ | WAL 模式下未 checkpoint 的数据在这里,**要一起拷** | -| `data/instance.json` | ★★ | 含 `secret_key`,丢了所有人都要重新登录(数据不受影响) | -| `data/exports/*.csv` | ★ | 导出快照,可再生 | -| `logs/` | ☆ | 排错用,可再生 | +| `usage.sqlite` | ★★★ | **数据正本**,丢了要重新采集,且官网窗口外的数据永久丢失 | +| `usage.sqlite-wal` / `-shm` | ★★★ | WAL 模式下未 checkpoint 的数据在这里,**要一起拷** | +| `instance.json` | ★★ | 含 `secret_key`,丢了所有人都要重新登录(数据不受影响) | +| `exports/*.csv` | ★ | 导出快照,可再生 | +| `workbuddy-portal_wb_logs` | ☆ | 排错用,可再生 | `.env` 不在里面——它含密码,**单独用密码管理器保管**。 -### 6.2 备份命令 +> **不要**在容器运行时用宿主机的 `manage.py` 去碰库(见 2.4 与第九节)。 + +### 6.2 备份 + +**方式 A:整体打包命名卷(推荐,最完整)** ```bash -# 方式 A:先 checkpoint 再拷(推荐,最干净) -docker compose exec portal python -c " -from workbuddy_portal import db -c = db.connect(); c.execute('PRAGMA wal_checkpoint(TRUNCATE)') -" -# Windows 宿主机 -copy data\usage.sqlite D:\backup\usage-%DATE%.sqlite -# Linux 宿主机 -cp data/usage.sqlite ~/backup/usage-$(date +%F).sqlite - -# 方式 B:直接整体拷(含 -wal / -shm) docker compose stop portal -tar czf backup-$(date +%F).tar.gz data/ -docker compose start portal -# 方式 C:逻辑导出(跨版本最安全) -docker compose exec portal python manage.py export-csv /app/data/exports +docker run --rm \ + -v workbuddy-portal_wb_data:/data:ro \ + -v "$PWD/backup":/backup \ + alpine:3.20 tar czf /backup/wb-data-$(date +%F).tar.gz -C /data . + +docker compose start portal ``` -建议方式 A 配合计划任务每天跑一次;每季度用方式 C 出一份逻辑快照。 +**方式 B:只导数据库文件(最常用)** + +```bash +# 先 checkpoint,把 WAL 落进主库,再拷——这样单独一个 .sqlite 就是完整的 +docker compose exec portal python -c " +from workbuddy_portal import db +db.connect().execute('PRAGMA wal_checkpoint(TRUNCATE)')" + +docker run --rm \ + -v workbuddy-portal_wb_data:/data:ro \ + -v "$PWD/backup":/backup \ + alpine:3.20 cp /data/usage.sqlite /backup/usage-$(date +%F).sqlite +``` + +**方式 C:逻辑导出(跨版本最安全,可读性最好)** + +```bash +docker compose exec portal python manage.py export-csv /app/data/exports +docker compose cp portal:/app/data/exports/. ./backup/exports/ +``` + +建议方式 B 每天跑、方式 C 每季度跑一次;方式 A 在升级前跑。 ### 6.3 恢复 ```bash docker compose down -cp ~/backup/usage-2026-09-14.sqlite data/usage.sqlite -rm -f data/usage.sqlite-wal data/usage.sqlite-shm # 关键:清掉旧 WAL + +# 把备份灌回命名卷(先清空,避免新旧 WAL 混用损坏数据) +docker volume rm workbuddy-portal_wb_data +docker volume create workbuddy-portal_wb_data +docker run --rm \ + -v workbuddy-portal_wb_data:/to \ + -v "$PWD/backup":/from:ro \ + alpine:3.20 sh -c 'cp -a /from/. /to/' + docker compose up -d docker compose exec portal python manage.py stats # 核对条数 ``` -> **别把旧库和旧 WAL 混着用**。WAL 里记的是相对旧库的增量,配错会损坏数据。 +> **别把旧库和旧 WAL 混着用**:WAL 里记的是相对旧库的增量,配错会损坏数据。 +> 用方式 B 的备份(已 checkpoint)最省心,恢复时目标目录里只放一个 `.sqlite` 即可。 + +### 6.4 迁移:从绑定挂载换到命名卷 + +如果之前用的是 `docker-compose.hostdir.yml`(或旧版把 `./data` 直接挂进去),数据在 +宿主机目录里,搬进命名卷: + +```bash +docker compose down +docker volume create workbuddy-portal_wb_data +docker run --rm \ + -v workbuddy-portal_wb_data:/to \ + -v "$PWD/data":/from:ro \ + alpine:3.20 sh -c 'cp -a /from/. /to/' +docker compose up -d # 不带 -f hostdir 叠加层 +docker compose exec portal python manage.py stats +``` + +> 迁移完成后,宿主机上的 `./data` 就不再被容器使用了,可以留作迁移前的冷备。 --- @@ -434,10 +518,56 @@ python -c "p='docker/entrypoint.sh';d=open(p,'rb').read();open(p,'wb').write(d.r | 报错 | 原因 / 处理 | |---|---| -| `unable to open database file` | 目录属主不对:`sudo chown -R 1000:1000 ./data` | -| `database is locked` | 有另一个写进程(另一个容器?宿主机上的 CLI?);等它跑完 | +| `unable to open database file`(**用了 `hostdir` 叠加层**) | 宿主目录属主与容器内 uid 1000 不一致:`sudo chown -R 1000:1000 ./data` | +| `unable to open database file`(**Windows + Docker Desktop**) | 见下面 [「Windows 绑定挂载的坑」](#windows-绑定挂载的坑容器打不开数据库);根治办法是用默认的命名卷 | +| `database is locked` | 有另一个写进程;等它跑完(文件锁会串行化,但 SQLite 层仍会短暂报错) | | `disk I/O error` | 挂载文件系统不支持 SQLite 的锁语义。改用本地盘或 Docker 命名卷 | +### Windows 绑定挂载的坑:容器打不开数据库 + +**症状**:容器起来时一切正常,跑着跑着 `/` `/records` 全部 `500`,应用日志里是 + +``` +File "/app/workbuddy_portal/db.py", line 33, in connect + conn.execute("PRAGMA journal_mode=WAL") +sqlite3.OperationalError: unable to open database file +``` + +**根因**:Windows + Docker Desktop 的绑定挂载走 **9p**(`mount` 里能看到 +`type 9p (…aname=drvfs;path=C:\…)`)。9p 本身支持 WAL(在上面新建库没问题), +但**当宿主的 Windows 进程打开过这个 WAL 库之后**,容器侧缓存的 `-shm` 映射就失效了, +下一次连接无法重建共享内存文件,于是打不开数据库。 + +**最小复现**(2026-09-14 实测): + +```bash +docker compose -f docker-compose.yml -f docker-compose.hostdir.yml up -d +docker compose exec portal python -c \ + "import sqlite3;print(sqlite3.connect('/app/data/usage.sqlite').execute('select count(*) from usage_records').fetchone())" +# -> (1665,) 容器侧正常 + +python manage.py stats # 宿主侧随手跑一次「纯读」的 CLI +# -> 存档:1665 条 … + +docker compose exec portal python -c \ + "import sqlite3;sqlite3.connect('/app/data/usage.sqlite')" +# -> sqlite3.OperationalError: unable to open database file +``` + +关键点:**纯读也会触发**,而且**宿主进程退出后容器不会自愈**—— +只有 `docker compose restart portal` 才恢复。 + +**处理**: + +1. **根治(推荐)**:不要用 `hostdir` 叠加层,直接用默认的**命名卷** + (`docker-compose.yml`)。容器独占 `/app/data`,宿主侧一律通过 + `docker compose exec portal python manage.py …` 操作。 +2. **临时**:`docker compose restart portal` 立刻恢复,但下一次宿主访问会再坏一次。 +3. **想在宿主侧看数据**:用第六节的方式导出到宿主目录再看,不要直接连库。 + +> Linux 宿主机上不存在这个问题(bind mount 与容器是同一个文件系统), +> 所以 `docker-compose.hostdir.yml` 对 Linux 是安全的。 + ### 采集相关 | 报错 | 处理 | diff --git a/docs/FAQ.md b/docs/FAQ.md index bdf2791..e3b9aec 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -32,13 +32,34 @@ docker compose exec portal python /app/docker/healthcheck.py ### Q:`unable to open database file` -Linux 宿主机上宿主目录属主与容器内 uid 1000 不一致: +分三种情况,先判断你用的是哪种挂载: + +| 场景 | 原因 | 处理 | +|---|---|---| +| 默认(命名卷) | 极少见;多半是卷被误删或磁盘满 | `docker compose exec portal ls -la /app/data`;`docker compose exec portal df -h /app/data` | +| 用了 `docker-compose.hostdir.yml`(**Linux**) | 宿主目录属主与容器内 uid 1000 不一致 | `sudo chown -R 1000:1000 ./data ./logs` | +| 用了 `docker-compose.hostdir.yml`(**Windows / Docker Desktop**) | 9p 挂载的固有缺陷,见下条 | 换成默认的命名卷 | + +### Q:容器一开始好的,跑着跑着页面全 500,日志里 `unable to open database file` + +**这是 Windows + Docker Desktop 用绑定挂载(hostdir 叠加层)时的典型症状。** + +宿主机的 Windows 进程只要访问过这个 WAL 库——**哪怕只是 `manage.py stats` 这种纯读**—— +容器侧下一次打开数据库就会失败,**而且不会自愈**。 ```bash -sudo chown -R 1000:1000 ./data ./logs -docker compose restart +docker compose restart portal # 临时恢复(但宿主再碰一次还会坏) ``` +**根治**:不要用 `hostdir` 叠加层,直接用默认的命名卷;需要跑 CLI 就: + +```bash +docker compose exec portal python manage.py stats +``` + +完整复现步骤与原理见 +[部署与运维指南](DEPLOYMENT.md#windows-绑定挂载的坑容器打不开数据库)。 + ### Q:`database is locked` / `disk I/O error` | 报错 | 原因 | @@ -206,16 +227,28 @@ docker compose exec portal python manage.py passwd admin 新密码 ### Q:备份怎么做最稳 +数据在命名卷 `workbuddy-portal_wb_data` 里。先 checkpoint 再拷出单个 `.sqlite`: + ```bash -# 1) 先 checkpoint,把 WAL 落进主库 docker compose exec portal python -c " from workbuddy_portal import db db.connect().execute('PRAGMA wal_checkpoint(TRUNCATE)')" -# 2) 拷走 -cp data/usage.sqlite ~/backup/usage-$(date +%F).sqlite + +docker run --rm \ + -v workbuddy-portal_wb_data:/data:ro \ + -v "$PWD/backup":/backup \ + alpine:3.20 cp /data/usage.sqlite /backup/usage-$(date +%F).sqlite ``` -或者整体 `tar` 掉 `data/`(含 `-wal` / `-shm`)——**别把新库配旧 WAL 用**,那会损坏数据。 +整体打包(含 `instance.json`、`exports/`): + +```bash +docker run --rm -v workbuddy-portal_wb_data:/data:ro -v "$PWD/backup":/backup \ + alpine:3.20 tar czf /backup/wb-data-$(date +%F).tar.gz -C /data . +``` + +恢复见 [部署指南 6.3](DEPLOYMENT.md#63-恢复)。 +**别把新库配旧 WAL 用**——会损坏数据(用上面已 checkpoint 的单文件备份最省心)。 ### Q:数据库文件越来越大 @@ -228,16 +261,17 @@ docker compose exec portal python manage.py vacuum ### Q:升级会不会丢数据 -不会。数据在宿主机的 `data/`(绑定挂载),`docker compose up -d --build` 只重建容器。 -但**升级前依然要备份**:`schema.sql` 用 `CREATE TABLE IF NOT EXISTS`, +不会。数据在 Docker 命名卷 `workbuddy-portal_wb_data` 里(对应容器内 `/app/data`), +`docker compose up -d --build` 只重建容器,不碰卷。 +但**升级前依然要备份**(见上一条):`schema.sql` 用 `CREATE TABLE IF NOT EXISTS`, 加表加索引安全,**改列需要手工迁移**。 ### Q:日志在哪、怎么滚动 | 位置 | 内容 | |---|---| -| `logs/app.log`(挂载到宿主机) | 应用日志,滚动 2 MB × 3 | -| `docker compose logs` | 容器 stdout(entrypoint + waitress) | +| `docker compose logs portal` | 容器 stdout(entrypoint + waitress) | +| 命名卷 `workbuddy-portal_wb_logs` 里的 `app.log` | 应用日志,滚动 2 MB × 3 | | 页面「日志管理」 | 采集逐行日志 + 应用日志尾部 + 操作审计 | ### Q:想改采集的接口地址(走镜像/代理) diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index ad2000f..0f0108a 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -337,7 +337,7 @@ Web 进程自身的日志(启动、异常栈、调度动作)。默认展示 | 看某条请求的完整 Prompt | 数据明细 → 该行「展开」 | | 给同事开只读账号 | 用户管理 → 新建账号,**不勾**管理员 | | 同事忘记密码 | 用户管理 → 该行「改密码」 | -| 把数据备份走 | 拷 `data/usage.sqlite`(连同 `-wal`/`-shm`),或导出全量 CSV | +| 把数据备份走 | 让运维按 [部署指南 6.2](DEPLOYMENT.md#62-备份) 备份命名卷,或在「配置管理」导出全量 CSV | | 关掉自动采集 | 任务管理 → 关「启用调度」 | | 改采集时刻 | 任务管理 → 每日时刻,如 `08:30,12:30,18:00` → 保存 | | 系统变慢了 | 配置管理 → 整理数据库;再不行看「十二」 | @@ -397,9 +397,11 @@ docker compose exec portal python manage.py passwd admin 新密码 # Docker ### 数据会丢吗 -正本是宿主机上的 `data/usage.sqlite`。`docker compose down` **不会删数据**; -只有显式 `docker compose down -v` 或手动删目录才会。 -定期拷走这个文件(连同 `-wal` / `-shm`)就是完整备份。 +正本是 Docker 命名卷 `workbuddy-portal_wb_data` 里的 `usage.sqlite`(对应容器内 `/app/data`)。 +`docker compose down` **不会删数据**;只有显式 `docker compose down -v` +或手动 `docker volume rm` 才会。备份方法见 +[部署指南 6.2](DEPLOYMENT.md#62-备份)——对使用者的日常来说,更简单的做法是 +「配置管理 → 维护动作 → 导出全量 CSV」留一份快照。 ### 能不能同时开多个采集进程