## 现象
容器跑着跑着页面全部 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 校验通过
320 行
12 KiB
Markdown
320 行
12 KiB
Markdown
# 常见问题(FAQ)
|
||
|
||
按「现象」分类。每条都给出**原因**和**动作**,不用从头排查。
|
||
|
||
---
|
||
|
||
## 一、部署
|
||
|
||
### Q:`docker compose up` 报端口被占用
|
||
|
||
```
|
||
Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:8848
|
||
```
|
||
|
||
改 `.env` 里的 `WB_PORT`,比如 `WB_PORT=18848`,然后 `docker compose up -d`。
|
||
容器内始终监听 8848,只改宿主映射即可。
|
||
|
||
### Q:容器起来了但局域网访问不了
|
||
|
||
1. `.env` 的 `WB_BIND` 是不是被设成了 `127.0.0.1`(那只允许本机);
|
||
2. 服务器防火墙有没有放行该端口;
|
||
3. `docker compose ps` 的 `PORTS` 是不是 `0.0.0.0:8848->8848/tcp`。
|
||
|
||
### Q:容器 `unhealthy` 但 `Up`
|
||
|
||
```bash
|
||
docker compose exec portal python /app/docker/healthcheck.py
|
||
```
|
||
|
||
健康检查打的是 `/login`(唯一免登录页),拿到 200 才算健康。
|
||
若失败,看 `docker compose logs` 有没有 Python traceback。
|
||
|
||
### Q:`unable to open database file`
|
||
|
||
分三种情况,先判断你用的是哪种挂载:
|
||
|
||
| 场景 | 原因 | 处理 |
|
||
|---|---|---|
|
||
| 默认(命名卷) | 极少见;多半是卷被误删或磁盘满 | `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
|
||
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`
|
||
|
||
| 报错 | 原因 |
|
||
|---|---|
|
||
| `database is locked` | 有另一个写者(另一个容器实例?宿主机上同时在跑 `manage.py collect`?)。等它结束——文件锁会串行化,但 SQLite 层面的写冲突仍会短暂报错 |
|
||
| `disk I/O error` | 挂载的文件系统不支持 SQLite 需要的锁语义(某些 NFS / 网络盘)。改用本地盘或 Docker 命名卷 |
|
||
|
||
### Q:`exec format error` 或 `no such file or directory`(关于 entrypoint.sh)
|
||
|
||
`docker/entrypoint.sh` 被 CRLF 污染了。仓库 `.gitattributes` 已强制 `*.sh` 为 LF;
|
||
若手工传过文件:
|
||
|
||
```bash
|
||
python -c "p='docker/entrypoint.sh';d=open(p,'rb').read();open(p,'wb').write(d.replace(b'\r\n',b'\n'))"
|
||
```
|
||
|
||
### Q:能不能跑多个副本做高可用
|
||
|
||
**不要。** 三个理由:SQLite 是单写者;调度线程在 Web 进程内;文件锁只在本机有效。
|
||
真要跑多副本,除第一份外全部设 `WB_DISABLE_SCHEDULER=1`,但写冲突依然存在。
|
||
这个服务的正确扩展方式是「升级到更强的单机」,不是横向加副本。
|
||
|
||
---
|
||
|
||
## 二、采集
|
||
|
||
### Q:采集报 `cookie_expired` / `unauthorized`
|
||
|
||
Cookie 过期。重新获取(见 [用户手册 3.2](USER-GUIDE.md#32-拿-cookie-的两种办法)),
|
||
填进「配置管理 → 凭证」,保存后按区间补采。
|
||
|
||
> Cookie 通常是浏览器会话级,**关掉浏览器可能就失效**。从已登录浏览器复制时勾选「保持登录」。
|
||
|
||
### Q:采集成功但「新增 0 条」
|
||
|
||
大概率正常。看那一次的 `抓取` 条数:
|
||
|
||
| 抓取 | 新增 | 判断 |
|
||
|---|---|---|
|
||
| > 0 | 0 | 云端返回的都是库里已有的(断点回退窗口重叠)——**正常** |
|
||
| 0 | 0 | 该时段云端确实没有记录——**正常** |
|
||
| > 0 | > 0 | 正常采集到新数据 |
|
||
|
||
### Q:返回 409 `busy`
|
||
|
||
已有采集在跑。文件锁 `data/collect.lock` 保证同时只有一个采集。
|
||
到「任务管理 → 运行历史」看它是否还在 `running`,等结束再操作。
|
||
|
||
### Q:TLS / SSLError
|
||
|
||
企业代理或自签证书场景。两种处理:
|
||
|
||
1. **推荐**:把企业根证书装进系统信任链;
|
||
2. **临时**:「配置管理」把 `ssl_verify` 设为 `0`。
|
||
|
||
> Cookie 就是账号凭证,关掉证书校验等于把它暴露在中间人面前。**只在受控内网临时用。**
|
||
|
||
### Q:日志里出现「云端时间比本地早」告警
|
||
|
||
云端记录的 `cloud_ts` 比本地 `ts` 早超过 `drift_tolerance_minutes`(默认 5 分钟)。
|
||
影响不大,但可能意味着:
|
||
|
||
- 服务端时钟不准 → 校时;
|
||
- 云端写入有延迟 → 适当调大 `rewind_minutes`(比如 5)。
|
||
|
||
### Q:想让采集更频繁 / 更稀疏
|
||
|
||
「任务管理 → 每日时刻」改成任意逗号分隔的时刻,如 `08:30,12:30,18:00,22:00`。
|
||
保存即生效,不用重启。时刻按**本地时区**解释。
|
||
|
||
---
|
||
|
||
## 三、数据与日期
|
||
|
||
### Q:日期差一天 / 跨日数据落到相邻日期
|
||
|
||
时区问题。所有日期按服务端本地时区计算。
|
||
|
||
```bash
|
||
docker compose exec portal date # 期望:CST / +0800
|
||
docker compose exec portal python -c "from workbuddy_portal import db; print(db.now_str())"
|
||
```
|
||
|
||
若不是,检查 `.env` 的 `TZ=Asia/Shanghai`,然后 **`docker compose up -d` 重建**
|
||
(`TZ` 是环境变量,`restart` 不生效)。
|
||
|
||
### Q:导出的 CSV 在 Excel 里乱码
|
||
|
||
本系统导出的文件带 **UTF-8 BOM**,双击不会乱码。若乱码,先确认你打开的是
|
||
从「导出 CSV」拿到的文件,而不是用记事本另存过的版本。
|
||
|
||
### Q:大屏的「单笔 TOP」为什么切了日期区间也不变
|
||
|
||
**设计如此。** `top` 是**全局**的:如果跟着窗口变,排名会随筛选跳动,反而看不出长期最贵的那几条。
|
||
想要窗口内的排行,用「数据明细」按积分降序 + 日期筛选。
|
||
|
||
### Q:大屏的 `daily`(日历/趋势)为什么不受区间影响
|
||
|
||
也是设计如此:`daily` 是全量(约 200 B/天),日历与日期轴需要完整日期序列。
|
||
真正跟随窗口的是 `dims` / `totals` / 明细表。
|
||
|
||
### Q:明细表提示「只显示最近 N 条」
|
||
|
||
大屏下发的明细有上限(`recordsCap = 20000`)。超过时只发**最新 N 条**并置
|
||
`recordsTruncated=true`,页面据此提示。完整数据请到「数据明细」页筛选或导出。
|
||
|
||
---
|
||
|
||
## 四、界面
|
||
|
||
### Q:页面能开但图表全白
|
||
|
||
1. `Ctrl+F5` 强刷清缓存;
|
||
2. 开浏览器控制台看有没有资源 404;
|
||
3. 「日志管理 → 应用日志」看有没有异常栈。
|
||
|
||
> 历史上有过 `/dashboard` 下相对路径把 `echarts.min.js` 解析成 `/vendor/...` 导致
|
||
> 整页全白的问题,已在 `tools/smoke.py` 里加了「页面所有 `src`/`href` 资源逐个断言 200」防回归。
|
||
|
||
### Q:某块样式突然失效 / 文字发虚
|
||
|
||
多半是类名撞了全局样式。本项目约定:**新组件用带前缀的独有类名**
|
||
(如 `.calcell .cbar`,而不是含糊的 `.bar`)。
|
||
`smoke.py` 里有「页面 class ∩ `app.css` 选择器」差集断言,跑一遍就能发现异常类名。
|
||
|
||
### Q:登录后跳转目标丢了
|
||
|
||
历史 bug,已修。现在 `next` 参数在 GET/POST 两条路径上都正确回填。
|
||
注意开放重定向防护会拒绝站外目标:`//evil.com`、`/\evil.com`、`https://evil.com`
|
||
一律回落到 `/`——这是**预期行为**。
|
||
|
||
---
|
||
|
||
## 五、账号与权限
|
||
|
||
### Q:忘记管理员密码
|
||
|
||
```bash
|
||
# 裸机
|
||
python manage.py passwd admin 新密码
|
||
|
||
# Docker
|
||
docker compose exec portal python manage.py passwd admin 新密码
|
||
```
|
||
|
||
不传新密码时会用默认的 `admin123`——**别这么干**。
|
||
|
||
### Q:怎么给同事开只读账号
|
||
|
||
「用户管理 → 新建账号」,**不要勾**「管理员」。
|
||
普通用户能看所有页面、能导出、能触发采集,但看不到「用户管理」且访问 `/users` 返回 403。
|
||
|
||
### Q:不小心把自己降级 / 删掉自己了
|
||
|
||
做不到。服务端有三条护栏:不能取消自己的管理员身份、不能删除自己、至少保留一个账号。
|
||
|
||
### Q:所有人被踢下线了
|
||
|
||
`SECRET_KEY` 变了。它存在 `data/instance.json`。这个文件丢了/被删了就会重新生成,
|
||
所有会话失效(**数据不受影响**)。恢复办法:从备份里找回 `instance.json`,或让大家重新登录。
|
||
|
||
---
|
||
|
||
## 六、运维
|
||
|
||
### Q:备份怎么做最稳
|
||
|
||
数据在命名卷 `workbuddy-portal_wb_data` 里。先 checkpoint 再拷出单个 `.sqlite`:
|
||
|
||
```bash
|
||
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
|
||
```
|
||
|
||
整体打包(含 `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:数据库文件越来越大
|
||
|
||
```bash
|
||
docker compose exec portal python manage.py vacuum
|
||
```
|
||
|
||
或在「配置管理 → 维护动作 → 整理数据库」点一下。
|
||
作用是 `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收删除后的空闲页并压缩 WAL。
|
||
|
||
### Q:升级会不会丢数据
|
||
|
||
不会。数据在 Docker 命名卷 `workbuddy-portal_wb_data` 里(对应容器内 `/app/data`),
|
||
`docker compose up -d --build` 只重建容器,不碰卷。
|
||
但**升级前依然要备份**(见上一条):`schema.sql` 用 `CREATE TABLE IF NOT EXISTS`,
|
||
加表加索引安全,**改列需要手工迁移**。
|
||
|
||
### Q:日志在哪、怎么滚动
|
||
|
||
| 位置 | 内容 |
|
||
|---|---|
|
||
| `docker compose logs portal` | 容器 stdout(entrypoint + waitress) |
|
||
| 命名卷 `workbuddy-portal_wb_logs` 里的 `app.log` | 应用日志,滚动 2 MB × 3 |
|
||
| 页面「日志管理」 | 采集逐行日志 + 应用日志尾部 + 操作审计 |
|
||
|
||
### Q:想改采集的接口地址(走镜像/代理)
|
||
|
||
「配置管理」里改 `api_base` 与 `api_path`。改了之后记得同步确认 Cookie 是该域下的有效凭证。
|
||
|
||
---
|
||
|
||
## 七、开发
|
||
|
||
### Q:改完代码怎么验证
|
||
|
||
**五层,前两层必须跑绿**:
|
||
|
||
```bash
|
||
python tools/smoke.py # 离线回归 99 项
|
||
python manage.py serve --port 8849 --no-scheduler # 另开终端
|
||
python tools/check_live.py --base http://127.0.0.1:8849 # 真实 HTTP 56 项
|
||
python tools/shots.py --base http://127.0.0.1:8849 --full # 界面截图 + JS 报错
|
||
```
|
||
|
||
详见 [架构说明 · 验证体系](ARCHITECTURE.md#十验证体系)。
|
||
|
||
### Q:`ModuleNotFoundError: No module named 'flask'`
|
||
|
||
选错解释器了。依赖装在项目的 venv 或托管环境里:
|
||
|
||
```bash
|
||
python -c "import flask, sys; print(sys.executable, flask.__version__)"
|
||
```
|
||
|
||
报这个错说明当前 `python` 不是装了依赖的那个。
|
||
|
||
### Q:改模板后页面没变
|
||
|
||
本项目 `TEMPLATES_AUTO_RELOAD=True`,模板改动通常立即生效。
|
||
若是静态资源(CSS/JS)被浏览器缓存,`Ctrl+F5`。
|
||
|
||
### Q:写了个自定义接口结果 500,但日志只看到异常栈
|
||
|
||
先看是不是**流式响应**(`Response(gen())` / `stream_with_context`):
|
||
Flask 在返回 `app_iter` 之后就关掉了请求上下文里的连接,
|
||
生成器里若复用 `db.get_db()` 会报 `Cannot operate on a closed database`。
|
||
正确做法是在生成器内部 `db.connect()` 自建连接并 `finally` 关闭。
|
||
详见 [架构说明 · 已知坑](ARCHITECTURE.md#九已知坑与红线)。
|