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 校验通过
这个提交包含在:
+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 是安全的。
|
||||
|
||||
### 采集相关
|
||||
|
||||
| 报错 | 处理 |
|
||||
|
||||
在新工单中引用
屏蔽一个用户