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 校验通过
这个提交包含在:
+7
@@ -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
|
||||
|
||||
@@ -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,7 +201,8 @@ workbuddy-portal/
|
||||
├── manage.py 统一 CLI(唯一入口)
|
||||
├── requirements.txt
|
||||
├── Dockerfile 多阶段构建(依赖层 / 运行层)
|
||||
├── docker-compose.yml 单服务编排(数据绑定挂载)
|
||||
├── docker-compose.yml 单服务编排(数据放 Docker 命名卷)
|
||||
├── docker-compose.hostdir.yml 可选叠加层:改用宿主机目录(仅建议 Linux)
|
||||
├── .env.example 环境变量样例
|
||||
├── docker/
|
||||
│ ├── entrypoint.sh 幂等初始化 → exec serve(LF 行尾)
|
||||
|
||||
@@ -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 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:
|
||||
|
||||
+11
-1
@@ -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)、
|
||||
|
||||
+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 是安全的。
|
||||
|
||||
### 采集相关
|
||||
|
||||
| 报错 | 处理 |
|
||||
|
||||
+45
-11
@@ -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:想改采集的接口地址(走镜像/代理)
|
||||
|
||||
+6
-4
@@ -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」留一份快照。
|
||||
|
||||
### 能不能同时开多个采集进程
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户