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 行删除
+7
查看文件
@@ -27,3 +27,10 @@ WB_IMPORT_XLSX=
# ---------- 镜像名(推送 Gitea 注册表时用)---------- # ---------- 镜像名(推送 Gitea 注册表时用)----------
# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:latest # WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:latest
# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:1.1.0 # 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
+8 -3
查看文件
@@ -84,8 +84,12 @@ docker compose logs -f # Ctrl-C 退出日志跟踪,容器继续跑
打开 `http://<本机IP>:8848` → 用 `.env` 里设的账号登录 → 去「配置管理」粘贴 Cookie。 打开 `http://<本机IP>:8848` → 用 `.env` 里设的账号登录 → 去「配置管理」粘贴 Cookie。
> 数据落在宿主机 `./data/`、日志落在 `./logs/`,`docker compose down` 不会删数据。 > 数据与日志放在 Docker **命名卷**(`workbuddy-portal_wb_data` / `_wb_logs`)里,
> **Linux 宿主机**上首次运行可能要 `sudo chown -R 1000:1000 ./data ./logs`(容器内以 uid 1000 运行)。 > `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 ### 方式二:裸机 Python
@@ -197,7 +201,8 @@ workbuddy-portal/
├── manage.py 统一 CLI(唯一入口) ├── manage.py 统一 CLI(唯一入口)
├── requirements.txt ├── requirements.txt
├── Dockerfile 多阶段构建(依赖层 / 运行层) ├── Dockerfile 多阶段构建(依赖层 / 运行层)
├── docker-compose.yml 单服务编排(数据绑定挂载) ├── docker-compose.yml 单服务编排(数据放 Docker 命名卷)
├── docker-compose.hostdir.yml 可选叠加层:改用宿主机目录(仅建议 Linux)
├── .env.example 环境变量样例 ├── .env.example 环境变量样例
├── docker/ ├── docker/
│ ├── entrypoint.sh 幂等初始化 → exec serve(LF 行尾) │ ├── entrypoint.sh 幂等初始化 → exec serve(LF 行尾)
+21
查看文件
@@ -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
+14 -5
查看文件
@@ -3,13 +3,18 @@
# #
# docker compose up -d --build 本机构建并启动 # docker compose up -d --build 本机构建并启动
# docker compose logs -f 跟踪日志 # docker compose logs -f 跟踪日志
# docker compose down 停止(数据留在 ./data,不会丢) # docker compose down 停止(数据在命名卷里,不会丢)
# #
# 设计取舍: # 设计取舍:
# * 刻意只有**一个**服务:SQLite 是单写者,调度线程也在 Web 进程内, # * 刻意只有**一个**服务: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 name: workbuddy-portal
@@ -35,8 +40,8 @@ services:
WB_IMPORT_CREDS: ${WB_IMPORT_CREDS:-0} WB_IMPORT_CREDS: ${WB_IMPORT_CREDS:-0}
WB_IMPORT_XLSX: ${WB_IMPORT_XLSX:-} WB_IMPORT_XLSX: ${WB_IMPORT_XLSX:-}
volumes: volumes:
- ./data:/app/data # 数据正本 + 导出 + secret_key - wb_data:/app/data # 数据正本 + 导出 + secret_key
- ./logs:/app/logs # 应用日志(滚动 2 MB × 3) - wb_logs:/app/logs # 应用日志(滚动 2 MB × 3)
# 可选:把编辑器配置挂进来,配合 WB_IMPORT_CREDS=1 自动接管 cookie # 可选:把编辑器配置挂进来,配合 WB_IMPORT_CREDS=1 自动接管 cookie
# - ${WB_EDITOR_SETTINGS:-./nonexistent.json}:/mnt/editor-settings.json:ro # - ${WB_EDITOR_SETTINGS:-./nonexistent.json}:/mnt/editor-settings.json:ro
healthcheck: healthcheck:
@@ -50,3 +55,7 @@ services:
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
volumes:
wb_data:
wb_logs:
+11 -1
查看文件
@@ -15,7 +15,8 @@
### 新增 ### 新增
- **Docker 化**:多阶段 `Dockerfile`(依赖层与运行层分离,改业务代码不触发重装依赖)、 - **Docker 化**:多阶段 `Dockerfile`(依赖层与运行层分离,改业务代码不触发重装依赖)、
`docker-compose.yml`(单服务、数据绑定挂载、健康检查、日志轮转)、 `docker-compose.yml`(单服务、**数据放 Docker 命名卷**、健康检查、日志轮转)、
`docker-compose.hostdir.yml`(可选叠加层:把数据/日志放到宿主机目录,仅建议 Linux)、
`docker/entrypoint.sh`(幂等初始化 → exec 交接)、`docker/healthcheck.py`(纯标准库)、 `docker/entrypoint.sh`(幂等初始化 → exec 交接)、`docker/healthcheck.py`(纯标准库)、
`.dockerignore`、`.env.example` `.dockerignore`、`.env.example`
- **容器环境变量**:`WB_HOST` `WB_PORT` `WB_DATA_DIR` `WB_LOG_DIR` `WB_DB` - **容器环境变量**:`WB_HOST` `WB_PORT` `WB_DATA_DIR` `WB_LOG_DIR` `WB_DB`
@@ -58,6 +59,15 @@
> 真实 HTTP 只看状态码),是加上 Playwright 截图后才发现的。 > 真实 HTTP 只看状态码),是加上 Playwright 截图后才发现的。
> 据此给 `tools/smoke.py` 补了「页面所有 `src`/`href` 资源引用逐个断言 200」一节。 > 据此给 `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)、 - 新增 `security.safe_next()`:登录跳转的 `next` 拒绝 `//evil.com`(协议相对 URL)、
+168 -38
查看文件
@@ -65,17 +65,51 @@ docker compose logs -f
| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(只跑手动采集) | | `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(只跑手动采集) |
| `WB_IMPORT_CREDS` | `0` | `1` = 启动时尝试从挂载的编辑器配置导入 Cookie | | `WB_IMPORT_CREDS` | `0` | `1` = 启动时尝试从挂载的编辑器配置导入 Cookie |
### 2.4 数据落点 ### 2.4 数据落点:用命名卷,不用绑定挂载
| 容器内 | 宿主机 | 内容 | | 容器内 | 存放位置 | 内容 |
|---|---|---| |---|---|---|
| `/app/data` | `./data` | `usage.sqlite`(正本)、`instance.json`(secret_key)、`exports/` | | `/app/data` | Docker 命名卷 `workbuddy-portal_wb_data` | `usage.sqlite`(正本)、`instance.json`(secret_key)、`exports/` |
| `/app/logs` | `./logs` | `app.log`(滚动 2 MB × 3) | | `/app/logs` | Docker 命名卷 `workbuddy-portal_wb_logs` | `app.log`(滚动 2 MB × 3) |
**绑定挂载**而非命名卷,是为了:备份就是拷目录;宿主机上的 `manage.py` 能直接读同一份数据。 **为什么是命名卷而不是脚本目录里的 `./data`**(这不是随手选的):
> **Linux 宿主机首次运行**若报 `unable to open database file`, > Windows + Docker Desktop 的绑定挂载走 **9p**(`aname=drvfs;path=C:\`)。
> 是宿主目录属主与容器内 uid 1000 不一致: > 宿主的 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 > ```bash
> sudo chown -R 1000:1000 ./data ./logs > 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 compose exec portal python manage.py collect # 手动采集一次
``` ```
> **本机调试**(Docker Desktop on Windows)已验证: > **本机调试**(Docker Desktop on Windows)已实测:命名卷上的 SQLite(WAL)读写正常,
> 绑定挂载上的 SQLite(WAL)读写正常,调度补跑、采集、导出、CSV 流式下载都可用。 > 启动补采、采集、导出、CSV 流式下载、健康检查全部可用;
> 且**宿主侧再跑 `manage.py` / `tools/smoke.py` 都不会影响容器**(这正是改用命名卷的原因)。
--- ---
@@ -283,9 +318,14 @@ services:
ports: ["8848:8848"] ports: ["8848:8848"]
environment: environment:
TZ: Asia/Shanghai TZ: Asia/Shanghai
WB_ADMIN_PASSWORD: "改成你的强密码"
volumes: volumes:
- ./data:/app/data - wb_data:/app/data
- ./logs:/app/logs - wb_logs:/app/logs
volumes:
wb_data:
wb_logs:
YAML YAML
docker compose up -d docker compose up -d
@@ -306,51 +346,95 @@ curl -s -u wangchuanli:TOKEN \
### 6.1 备份什么 ### 6.1 备份什么
数据都在命名卷 `workbuddy-portal_wb_data` 里(对应容器内 `/app/data`):
| 文件 | 重要性 | 说明 | | 文件 | 重要性 | 说明 |
|---|---|---| |---|---|---|
| `data/usage.sqlite` | ★★★ | **数据正本**,丢了要重新采集,且官网窗口外的数据永久丢失 | | `usage.sqlite` | ★★★ | **数据正本**,丢了要重新采集,且官网窗口外的数据永久丢失 |
| `data/usage.sqlite-wal` / `-shm` | ★★★ | WAL 模式下未 checkpoint 的数据在这里,**要一起拷** | | `usage.sqlite-wal` / `-shm` | ★★★ | WAL 模式下未 checkpoint 的数据在这里,**要一起拷** |
| `data/instance.json` | ★★ | 含 `secret_key`,丢了所有人都要重新登录(数据不受影响) | | `instance.json` | ★★ | 含 `secret_key`,丢了所有人都要重新登录(数据不受影响) |
| `data/exports/*.csv` | ★ | 导出快照,可再生 | | `exports/*.csv` | ★ | 导出快照,可再生 |
| `logs/` | ☆ | 排错用,可再生 | | `workbuddy-portal_wb_logs` | ☆ | 排错用,可再生 |
`.env` 不在里面——它含密码,**单独用密码管理器保管**。 `.env` 不在里面——它含密码,**单独用密码管理器保管**。
### 6.2 备份命令 > **不要**在容器运行时用宿主机的 `manage.py` 去碰库(见 2.4 与第九节)。
### 6.2 备份
**方式 A:整体打包命名卷(推荐,最完整)**
```bash ```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 docker compose stop portal
tar czf backup-$(date +%F).tar.gz data/
docker compose start portal
# 方式 C:逻辑导出(跨版本最安全) docker run --rm \
docker compose exec portal python manage.py export-csv /app/data/exports -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 恢复 ### 6.3 恢复
```bash ```bash
docker compose down 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 up -d
docker compose exec portal python manage.py stats # 核对条数 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` | | `unable to open database file`(**用了 `hostdir` 叠加层**) | 宿主目录属主与容器内 uid 1000 不一致:`sudo chown -R 1000:1000 ./data` |
| `database is locked` | 有另一个写进程(另一个容器?宿主机上的 CLI?);等它跑完 | | `unable to open database file`(**Windows + Docker Desktop**) | 见下面 [「Windows 绑定挂载的坑」](#windows-绑定挂载的坑容器打不开数据库);根治办法是用默认的命名卷 |
| `database is locked` | 有另一个写进程;等它跑完(文件锁会串行化,但 SQLite 层仍会短暂报错) |
| `disk I/O error` | 挂载文件系统不支持 SQLite 的锁语义。改用本地盘或 Docker 命名卷 | | `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 是安全的。
### 采集相关 ### 采集相关
| 报错 | 处理 | | 报错 | 处理 |
+45 -11
查看文件
@@ -32,13 +32,34 @@ docker compose exec portal python /app/docker/healthcheck.py
### Q:`unable to open database file` ### 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 ```bash
sudo chown -R 1000:1000 ./data ./logs docker compose restart portal # 临时恢复(但宿主再碰一次还会坏)
docker compose restart
``` ```
**根治**:不要用 `hostdir` 叠加层,直接用默认的命名卷;需要跑 CLI 就:
```bash
docker compose exec portal python manage.py stats
```
完整复现步骤与原理见
[部署与运维指南](DEPLOYMENT.md#windows-绑定挂载的坑容器打不开数据库)。
### Q:`database is locked` / `disk I/O error` ### Q:`database is locked` / `disk I/O error`
| 报错 | 原因 | | 报错 | 原因 |
@@ -206,16 +227,28 @@ docker compose exec portal python manage.py passwd admin 新密码
### Q:备份怎么做最稳 ### Q:备份怎么做最稳
数据在命名卷 `workbuddy-portal_wb_data` 里。先 checkpoint 再拷出单个 `.sqlite`:
```bash ```bash
# 1) 先 checkpoint,把 WAL 落进主库
docker compose exec portal python -c " docker compose exec portal python -c "
from workbuddy_portal import db from workbuddy_portal import db
db.connect().execute('PRAGMA wal_checkpoint(TRUNCATE)')" 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:数据库文件越来越大 ### Q:数据库文件越来越大
@@ -228,16 +261,17 @@ docker compose exec portal python manage.py vacuum
### Q:升级会不会丢数据 ### Q:升级会不会丢数据
不会。数据在宿主机的 `data/`(绑定挂载),`docker compose up -d --build` 只重建容器。 不会。数据在 Docker 命名卷 `workbuddy-portal_wb_data` 里(对应容器内 `/app/data`),
但**升级前依然要备份**:`schema.sql` 用 `CREATE TABLE IF NOT EXISTS`, `docker compose up -d --build` 只重建容器,不碰卷。
但**升级前依然要备份**(见上一条):`schema.sql` 用 `CREATE TABLE IF NOT EXISTS`,
加表加索引安全,**改列需要手工迁移**。 加表加索引安全,**改列需要手工迁移**。
### Q:日志在哪、怎么滚动 ### Q:日志在哪、怎么滚动
| 位置 | 内容 | | 位置 | 内容 |
|---|---| |---|---|
| `logs/app.log`(挂载到宿主机) | 应用日志,滚动 2 MB × 3 | | `docker compose logs portal` | 容器 stdout(entrypoint + waitress) |
| `docker compose logs` | 容器 stdout(entrypoint + waitress) | | 命名卷 `workbuddy-portal_wb_logs` 里的 `app.log` | 应用日志,滚动 2 MB × 3 |
| 页面「日志管理」 | 采集逐行日志 + 应用日志尾部 + 操作审计 | | 页面「日志管理」 | 采集逐行日志 + 应用日志尾部 + 操作审计 |
### Q:想改采集的接口地址(走镜像/代理) ### Q:想改采集的接口地址(走镜像/代理)
+6 -4
查看文件
@@ -337,7 +337,7 @@ Web 进程自身的日志(启动、异常栈、调度动作)。默认展示
| 看某条请求的完整 Prompt | 数据明细 → 该行「展开」 | | 看某条请求的完整 Prompt | 数据明细 → 该行「展开」 |
| 给同事开只读账号 | 用户管理 → 新建账号,**不勾**管理员 | | 给同事开只读账号 | 用户管理 → 新建账号,**不勾**管理员 |
| 同事忘记密码 | 用户管理 → 该行「改密码」 | | 同事忘记密码 | 用户管理 → 该行「改密码」 |
| 把数据备份走 | 拷 `data/usage.sqlite`(连同 `-wal`/`-shm`),或导出全量 CSV | | 把数据备份走 | 让运维按 [部署指南 6.2](DEPLOYMENT.md#62-备份) 备份命名卷,或在「配置管理」导出全量 CSV |
| 关掉自动采集 | 任务管理 → 关「启用调度」 | | 关掉自动采集 | 任务管理 → 关「启用调度」 |
| 改采集时刻 | 任务管理 → 每日时刻,如 `08:30,12:30,18:00` → 保存 | | 改采集时刻 | 任务管理 → 每日时刻,如 `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 命名卷 `workbuddy-portal_wb_data` 里的 `usage.sqlite`(对应容器内 `/app/data`)。
只有显式 `docker compose down -v` 或手动删目录才会。 `docker compose down` **不会删数据**;只有显式 `docker compose down -v`
定期拷走这个文件(连同 `-wal` / `-shm`)就是完整备份。 或手动 `docker volume rm` 才会。备份方法见
[部署指南 6.2](DEPLOYMENT.md#62-备份)——对使用者的日常来说,更简单的做法是
「配置管理 → 维护动作 → 导出全量 CSV」留一份快照。
### 能不能同时开多个采集进程 ### 能不能同时开多个采集进程