fix(docker): 数据改用 Docker 命名卷,修容器打不开数据库的问题

## 现象
容器跑着跑着页面全部 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 校验通过
这个提交包含在:
2026-09-14 15:09:24 +08:00
父节点 dcd420e9c0
当前提交 10db94c162
共修改 8 个文件,包含 281 行新增和 63 行删除
+168 -38
查看文件
@@ -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 是安全的。
### 采集相关
| 报错 | 处理 |