提交 248d6328 authored 作者: 陈泽健's avatar 陈泽健

fix(service-monitor): 修复定时任务并发执行失败 + Docker 容器化方案

定时任务修复:
- 新增 _schedules_lock 防止 schedules.json 并发写入状态丢失
- _add_job() 添加 misfire_grace_time=3600/coalesce/max_instances
- _execute_scheduled_job 改为 threading.Thread 后台执行,不阻塞 APScheduler 线程

Docker 容器化:
- 重写 Dockerfile(python:3.11-slim + 单进程 + CRLF 修复)
- 重写 docker-compose.yml(单容器 + volume + 资源限制 + 健康检查)
- 新增 deploy/docker_deploy.sh 部署脚本
- 更新 .dockerignore 排除规则

搜索索引路径适配:
- utils/paths.py 新增 SEARCH_INDEX_DIR 常量
- search_engine.py 改用统一路径

文档:
- 新增定时任务问题处理 + 计划执行文档
- 新增 Vue 前端迁移任务清单
- 更新 HANDOFF 交接文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 22368d9f
......@@ -22,9 +22,28 @@ HANDOFF.md
# 测试与覆盖率
skill/code/tests
skill/code/.pytest_cache
skill/code/web/service_monitor/tests
.coverage
htmlcov
# 本地数据与日志(挂载方式注入,不入镜像)
logs
cache
# 运行时数据(volume 挂载,不打入镜像)
skill/code/web/service_monitor/data
skill/code/web/cache
skill/code/web/logs
skill/code/web/audit.log
skill/code/web/users.json
# 部署脚本(构建镜像不需要)
deploy/
# IDE
.vscode
.idea
# 环境变量
.env
.env.local
# Docker 自身
Dockerfile
docker-compose.yml
FROM python:3.10-slim
# ============================================================
# Troubleshoot AI Assistant — Docker 镜像
# ============================================================
# 基础镜像:python:3.11-slim(Debian bookworm,含 bash)
# 运行时:Flask 内置 server(单进程,APScheduler 兼容)
# 数据持久化:通过 volume 挂载,不写入镜像层
# ============================================================
# 工作目录设为 /app/web,与 utils/paths.py 的 SCRIPT_DIR 推导一致
# gunicorn 直接加载 server 模块(server.py:107 已有模块级 app = create_app())
WORKDIR /app/web
FROM python:3.11-slim AS base
# 系统依赖:bash(LocalExecutor 执行检测脚本)
# 不装 docker CLI —— 不挂载 docker.sock,避免影响宿主机其他容器
RUN apt-get update && apt-get install -y --no-install-recommends \
bash \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 安装依赖(版本锁定,离线环境复现性保障)
COPY requirements.txt /app/requirements.txt
# ---------- 依赖层(利用 Docker 缓存) ----------
COPY skill/code/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
# 拷贝代码
# ---------- 代码层 ----------
COPY skill/code/web/ /app/web/
# 复制知识库 SKILL.md(问题排查助手 prompt 模板)
COPY skill/code/SKILL.md /app/SKILL.md
# 端口
EXPOSE 8088
# 运行时数据目录(volume 挂载点,容器内不写数据到镜像层)
RUN mkdir -p /app/data \
&& mkdir -p /app/web/service_monitor/data/reports \
&& mkdir -p /app/web/cache \
&& mkdir -p /app/web/logs
# 修复 Windows 开发环境产生的 CRLF 行尾(bash 脚本在 Linux 必须是 LF)
RUN find /app/web/service_monitor/assets -name '*.sh' -o -name '*.template' \
| xargs -r sed -i 's/\r$//'
# 环境变量
ENV PYTHONIOENCODING=utf-8
ENV FLASK_DEBUG=0 \
PYTHONIOENCODING=utf-8 \
TROUBLESHOOT_ROOT=/app \
LANG=C.UTF-8
EXPOSE 8088
WORKDIR /app/web
# 启动:gunicorn 多 worker
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8088", "server:app"]
# 单进程启动(不用 gunicorn 多 worker,避免 APScheduler 多实例冲突)
CMD ["python3", "server.py"]
# HANDOFF — 服务监测模块实施进度
> 最后更新:2026-07-16 | 分支:troubleshoot-ai-assistant | 模块:service-monitor
> 状态:**代码全部完成,218 测试全绿,待部署到 5.60**
> 最后更新:2026-07-27 | 分支:troubleshoot-ai-assistant | 模块:service-monitor
> 状态:**定时任务修复完成 + Docker 容器化方案设计完成 + Vue 前端迁移规划完成,待部署**
---
......@@ -156,3 +156,99 @@ templates/service_monitor/{index,targets,run,report}.html
### 已删除
- `skill/code/web/routes/service_monitor.py`(旧占位路由)
- `skill/code/web/templates/service_monitor.html`(旧占位模板)
---
## 8. 2026-07-27 会话进度追加
### 8.1 完成的工作
#### 定时任务并发执行失败修复 ✅
**问题**:5.44 服务器定时任务(工作日 8:40)不执行,5.202 的 9:00 正常。
**根因**
- APScheduler `misfire_grace_time` 默认 1 秒,调度线程稍有延迟就跳过任务
- `_execute_scheduled_job` 同步阻塞 APScheduler 线程
- `schedules.json` 并发写入无锁保护
**修复**`schedule_service.py` 5 处):
1. 新增 `_schedules_lock`
2. `_save_schedules()` 加锁
3. `update_run_status()` 读-改-写整体加锁
4. `_add_job()` 添加 `misfire_grace_time=3600``coalesce=True``max_instances=1`
5. `_execute_scheduled_job` 改为 `threading.Thread` 后台执行
**验证**:218 测试全绿 ✅
**文档**
- `Docs/需求文档/服务监测/PRD_问题处理_定时任务并发执行失败.md`
- `Docs/需求文档/服务监测/PRD_计划执行_定时任务并发执行失败修复.md`
#### Docker 容器化方案设计 ✅
**用户需求**
- 部署目录:`/data/third_party/monitor-platform/`
- 单容器部署(nginx + Flask + SQLite)
- 前端更新不需要容器重启,后端更新需要容器重启
- 数据库采用轻量级 SQLite
- 5.60 上有其他服务容器,必须隔离不互相影响
**已完成文件**
- `Dockerfile` — python:3.11-slim,单进程,CRLF 自动修复
- `.dockerignore` — 排除测试/运行时数据/文档
- `docker-compose.yml` — 单容器 + volume + 资源限制(1G/1CPU)
- `deploy/docker_deploy.sh` — 5.60 部署脚本
**隔离保障**
- 不挂载 docker.sock
- 容器名 `troubleshoot`,端口 8088
- volume 路径 `/opt/troubleshoot/` 独占前缀
#### 搜索引擎路径适配 ✅
- `utils/paths.py` 新增 `SEARCH_INDEX_DIR` 常量
- `search_engine.py` 改用统一路径
#### Vue 前端迁移规划 ✅
**文档**`Docs/需求文档/Flask模板迁移Vue前端任务清单.md`
**范围**:三大模块共 18 个页面
- 问题排查助手:3 个页面
- 服务监测:10 个页面
- 服务管理:4 个页面
**技术选型**:Vue 3 + Vite + Element Plus + Pinia + TypeScript + ECharts
**预估工时**:17-21 天(约 3-4 周)
**建议执行顺序**:先容器化部署落地,再并行推进 Vue 重构
### 8.2 待执行任务
| 优先级 | 任务 | 说明 |
|--------|------|------|
| **P0** | 部署容器化版本到 5.60 | 需先确认 Docker 已安装,执行 `docker_deploy.sh` |
| **P0** | 更新 5.44 定时任务 end_date | 当前 end_date=2026-07-22 已过期 |
| P1 | 修复 APScheduler day_of_week 约定 bug | `from_crontab('1-5')` 实际是周二到周六 |
| P2 | SQLite 迁移 | 替换 JSON 文件存储 |
| P2 | Vue 前端开发 | 按任务清单执行 |
### 8.3 遗留问题
1. **5.44 定时任务 end_date 已过期**(2026-07-22),需通过 API 更新
2. **APScheduler day_of_week 约定 bug**`CronTrigger.from_crontab('1-5')` 按 APScheduler 约定是周二到周六,非周一到周五,需改为 `CronTrigger(day_of_week='0-4')`
3. **容器化尚未部署**:需在 5.60 上执行部署脚本验证
### 8.4 本次新增文件
| 文件 | 说明 |
|------|------|
| `Dockerfile` | 重写:python:3.11-slim + 单进程 |
| `docker-compose.yml` | 重写:单容器 + volume + 资源限制 |
| `.dockerignore` | 更新:排除 tests/data 等 |
| `deploy/docker_deploy.sh` | 新增:5.60 部署脚本 |
| `Docs/需求文档/服务监测/PRD_问题处理_定时任务并发执行失败.md` | 问题处理文档 |
| `Docs/需求文档/服务监测/PRD_计划执行_定时任务并发执行失败修复.md` | 计划执行文档 |
| `Docs/需求文档/Flask模板迁移Vue前端任务清单.md` | Vue 迁移任务清单 |
# 计划执行 — 定时任务并发执行失败修复
> 版本:1.0 | 日期:2026-07-27 | 作者:czj
> 关联问题文档:`PRD_问题处理_定时任务并发执行失败.md`
---
## 修复目标
1. 修复 5.44 定时任务不执行问题(P0)
2. 修复 schedules.json 并发写入状态丢失问题(P1)
3. 修复 APScheduler 线程阻塞问题(P1)
---
## 改动文件
| 文件 | 改动内容 |
|------|---------|
| `skill/code/web/service_monitor/services/schedule_service.py` | 三处修复(见下文) |
---
## 修复步骤
### 步骤 1:添加 schedules.json 读写锁
**位置**`schedule_service.py` 模块级变量区(第 28 行附近)
**改动**:新增 `_schedules_lock = threading.Lock()`
**涉及函数加锁**
- `_save_schedules()` — 写操作加锁
- `_update_current_status()` — 读-改-写加锁
- `update_run_status()` — 读-改-写加锁
**注意**`_load_schedules()` 是纯读操作,不需要加锁(JSON 文件读取是原子的)。但读-改-写模式(先 load 再 save)必须整体加锁。
```python
# 新增模块级锁
_schedules_lock = threading.Lock()
# _save_schedules 加锁
def _save_schedules(schedules: list) -> None:
with _schedules_lock:
ensure_dirs()
SCHEDULES_FILE.write_text(
json.dumps(schedules, ensure_ascii=False, indent=2),
encoding="utf-8",
)
# _update_current_status 加锁
def _update_current_status(schedule_id: str, status: str) -> None:
with _schedules_lock:
schedules = _load_schedules()
for i, s in enumerate(schedules):
if s.get("id") == schedule_id:
schedules[i]["current_status"] = status
_save_schedules(schedules)
return
raise ValueError("定时任务不存在")
# update_run_status 加锁
def update_run_status(schedule_id: str, status: str, report_id: str, run_at: str) -> dict:
with _schedules_lock:
schedules = _load_schedules()
for i, s in enumerate(schedules):
if s.get("id") == schedule_id:
schedules[i]["last_run_at"] = run_at
schedules[i]["last_run_status"] = status
schedules[i]["last_report_id"] = report_id
schedules[i]["next_run_at"] = _calc_next_run(s["cron"])
_save_schedules(schedules)
return schedules[i]
raise ValueError("定时任务不存在")
```
---
### 步骤 2:`_add_job()` 添加关键参数
**位置**`schedule_service.py` 第 451-457 行
**改动**:添加 `misfire_grace_time``coalesce``max_instances` 参数
```python
_scheduler.add_job(
func=_execute_scheduled_job,
trigger=trigger,
id=job_id,
args=[sched["id"]],
replace_existing=True,
misfire_grace_time=3600, # 允许 1 小时内的错过执行(默认 1 秒太短)
coalesce=True, # 错过多次只执行一次
max_instances=1, # 同一 job 不并发(显式声明)
)
```
**参数说明**
- `misfire_grace_time=3600`:如果任务错过触发时间,1 小时内仍会执行。解决因 GIL 竞争、线程繁忙等导致的短暂延迟跳过问题
- `coalesce=True`:如果任务连续错过多次触发(如服务重启期间),只执行一次,不堆积
- `max_instances=1`:同一 job 同一时间只允许一个实例运行,防止并发重复执行
---
### 步骤 3:`_execute_scheduled_job` 改为后台线程执行
**位置**`schedule_service.py` 第 512-521 行
**改动**:将同步调用改为 `threading.Thread` 后台执行,与 `run_now()` 保持一致
```python
def _execute_scheduled_job(schedule_id: str) -> None:
"""定时任务执行入口(APScheduler 线程中调用),含 enabled 检查。"""
logger.info("定时任务触发: %s", schedule_id)
sched = get_schedule(schedule_id)
if not sched or not sched.get("enabled"):
logger.warning("定时任务不存在或已禁用: %s", schedule_id)
return
# 检查是否已在执行中(防止定时触发与手动触发并发)
if sched.get("current_status") == "running":
logger.warning("定时任务已在执行中,跳过: %s", schedule_id)
return
# 使用后台线程执行,不阻塞 APScheduler 调度线程
t = threading.Thread(target=_run_job_body, args=(schedule_id,), daemon=True)
t.start()
```
**改动说明**
- 新增 `current_status == "running"` 检查,防止定时触发与手动触发并发执行同一任务
- 使用 `threading.Thread` 后台执行,APScheduler 线程立即释放,可以继续调度其他任务
- `daemon=True` 确保主进程退出时线程自动终止
---
## 验证步骤
### 本地验证
1. 启动服务,创建两个定时任务(间隔 1 分钟)
2. 确认两个任务都能正常触发执行
3. 手动触发一个正在执行的任务,确认被拒绝("已在执行中")
4. 检查 `schedules.json` 状态更新正确
### 部署后验证
1. 部署到 5.60
2. 检查健康状态:`curl -s http://192.168.5.60:8088/api/health`
3. 检查 scheduler 状态:`curl -s http://192.168.5.60:8088/api/service-monitor/scheduler/status`
4. 确认 5.44 和 5.202 两个定时任务都已注册
5. 等待下一个工作日 8:40,确认 5.44 任务正常执行
6. 等待 9:00,确认 5.202 任务正常执行
7. 检查 `schedules.json` 中两个任务的状态更新均正确
### 单元测试
```bash
cd skill/code && python -m pytest -v
```
确认 218 个测试全绿,无回归。
---
## 回滚方案
如果修复后出现问题,回滚步骤:
1. 恢复 `schedule_service.py` 到修复前版本
2. 重新部署
3. 手动检查 `schedules.json` 中任务状态是否正确
---
## 文档信息
- 创建时间:2026-07-27
- 创建人:Claude
- 关联问题文档:`PRD_问题处理_定时任务并发执行失败.md`
# 问题处理 — 定时任务并发执行失败
> 版本:1.0 | 日期:2026-07-27 | 作者:czj
---
## 问题描述
| # | 问题 | 严重度 |
|---|------|--------|
| 1 | 5.44 服务器定时任务(工作日 8:40)不执行,5.202 服务器定时任务(工作日 9:00)正常执行 | P0 功能 |
| 2 | `schedules.json` 并发读写无锁保护,多任务同时执行时可能丢失状态更新 | P1 数据安全 |
| 3 | `_execute_scheduled_job` 在 APScheduler 线程中同步阻塞执行,长时间巡检占用调度线程 | P1 性能 |
---
## 问题 1 根因分析(P0)
### 现象
- 5.44 定时任务配置"工作日 08:40 执行",到了时间不执行,无报告生成
- 5.202 定时任务配置"工作日 09:00 执行",正常执行,报告正常生成
- 服务器健康检查 status: ok, scheduler: running
### 根因
**根因 1(主因):`_add_job()` 未设置 `misfire_grace_time`,APScheduler 默认 1 秒**
`schedule_service.py` 第 451-457 行:
```python
_scheduler.add_job(
func=_execute_scheduled_job,
trigger=trigger,
id=job_id,
args=[sched["id"]],
replace_existing=True,
# ❌ 缺少 misfire_grace_time、coalesce、max_instances
)
```
APScheduler 的 `BackgroundScheduler` 默认 `misfire_grace_time = 1` 秒。含义:如果调度器在预定时间后超过 1 秒才检查到该任务需要执行,该任务就被标记为 misfire 并**跳过不执行**
**触发场景**
- 5.44 任务设定 8:40 触发
- 8:40 时 APScheduler 的执行线程正忙于其他操作(GIL 竞争、垃圾回收、或其他任务正在同步执行巡检)
- 调度器延迟 1 秒以上才检查到 8:40 的任务
- 该任务被判定为 misfire,跳过不执行
- 5.202 的 9:00 任务正常,因为 9:00 时调度器线程空闲
**根因 2(次因):`_execute_scheduled_job` 在 APScheduler 线程中同步阻塞执行**
`schedule_service.py` 第 512-521 行:
```python
def _execute_scheduled_job(schedule_id: str) -> None:
sched = get_schedule(schedule_id)
if not sched or not sched.get("enabled"):
return
_run_job_body(schedule_id) # ❌ 同步阻塞!
```
对比 `run_now()`(手动触发)使用了 `threading.Thread` 后台执行(第 539 行),而定时触发直接在 APScheduler 线程中同步调用 `_run_job_body`
`run_inspection_sync()` 执行全量巡检可能耗时数分钟(每个模块 90 秒超时 × 多个模块),在此期间 APScheduler 的执行线程被阻塞,无法触发其他定时任务,加剧 misfire 问题。
### 影响范围
- **所有定时任务**都可能受影响,不仅限于 5.44
- 任务执行时间越长,后续任务越容易 misfire
- 多个定时任务配置在相近时间段时问题更明显
---
## 问题 2 根因分析(P1)
### 现象
两个定时任务同时执行时,`schedules.json` 中的状态可能不正确(如一个任务完成后的状态更新被另一个任务的写入覆盖)。
### 根因
`_update_current_status()`(第 567-575 行)和 `update_run_status()`(第 357-368 行)都采用"读-改-写"模式操作 `schedules.json`,但**没有任何锁保护**
```python
def _update_current_status(schedule_id: str, status: str) -> None:
schedules = _load_schedules() # 1. 读
for i, s in enumerate(schedules):
if s.get("id") == schedule_id:
schedules[i]["current_status"] = status # 2. 改
_save_schedules(schedules) # 3. 写
return
```
**竞态场景**
1. 5.44 任务完成,线程 A 调用 `update_run_status("sched_5.44", "success", ...)`
2. 同时 5.202 任务执行中,线程 B 调用 `_update_current_status("sched_5.202", "running")`
3. 线程 A 先 `_load_schedules()` 读到旧数据
4. 线程 B 也 `_load_schedules()` 读到同一份旧数据
5. 线程 A 写入,更新了 5.44 的状态
6. 线程 B 写入,基于旧数据更新 5.202 的状态,**覆盖了线程 A 对 5.44 状态的更新**
7. 结果:5.44 的 `last_run_status` 被回退,看起来像从未执行
### 影响范围
- 多个定时任务同时执行时,状态更新可能丢失
- 手动触发 + 定时触发同时进行时同样受影响
- `current_status` 可能永远停留在 "running",导致手动触发被拒绝
---
## 问题 3 根因分析(P1)
### 现象
定时任务执行期间,APScheduler 调度线程被阻塞,影响其他任务触发。
### 根因
见问题 1 根因 2。`_execute_scheduled_job` 在 APScheduler 的工作线程中同步执行 `run_inspection_sync()`,该函数执行全量巡检可能耗时数分钟。
APScheduler 默认线程池大小为 10,如果多个定时任务同时占用线程,新的触发请求可能因线程池耗尽而延迟或失败。
---
## 修复方案
| 问题 | 修复方案 | 改动文件 |
|------|---------|---------|
| P0:misfire_grace_time 过短 | `_add_job()` 添加 `misfire_grace_time=3600`(1 小时容错)、`coalesce=True``max_instances=1` | `schedule_service.py` |
| P1:APScheduler 线程阻塞 | `_execute_scheduled_job` 改为 `threading.Thread` 后台执行,与 `run_now()` 保持一致 | `schedule_service.py` |
| P1:schedules.json 并发写入 | 添加模块级 `threading.Lock()``_update_current_status` / `update_run_status` / `_save_schedules` 等函数加锁 | `schedule_service.py` |
---
## 风险评估
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| misfire_grace_time 过大导致任务堆积 | 低频任务不会堆积;高频任务应设 max_instances=1 | 已设 max_instances=1 |
| 后台线程异常无法捕获 | 线程内已有 try/except 兜底 | 日志记录完整 |
| 锁粒度过大影响性能 | schedules.json 操作是毫秒级,锁持有时间极短 | 可接受 |
---
## 文档信息
- 创建时间:2026-07-27
- 创建人:Claude
- 关联问题:定时任务时区问题、定时任务与报告优化
- 关联提交:待修复
此差异已折叠。
#!/bin/bash
# ============================================================
# Troubleshoot AI Assistant — Docker 部署脚本
# 在 5.60 服务器上执行
# ============================================================
# 前置条件:
# 1. 服务器已安装 Docker
# 2. 项目代码已上传到服务器(或从构建机 scp 镜像)
# 3. /opt/troubleshoot/.env 已配置(SECRET_KEY 等)
#
# 使用方式:
# ./docker_deploy.sh # 构建并启动
# ./docker_deploy.sh --build-only # 仅构建镜像
# ./docker_deploy.sh --stop # 停止容器
# ./docker_deploy.sh --status # 查看状态
# ============================================================
set -euo pipefail
# 配置
CONTAINER_NAME="troubleshoot"
IMAGE_NAME="troubleshoot:latest"
DATA_BASE="/opt/troubleshoot"
ENV_FILE="${DATA_BASE}/.env"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
# ---------- 前置检查 ----------
check_prerequisites() {
info "检查前置条件..."
# Docker 是否安装
if ! command -v docker &> /dev/null; then
error "Docker 未安装,请先安装: sudo apt install docker.io"
fi
info "Docker: $(docker --version)"
# 当前用户是否有 Docker 权限
if ! docker ps &> /dev/null; then
error "当前用户无 Docker 权限,请将用户加入 docker 组: sudo usermod -aG docker $USER"
fi
# .env 文件是否存在
if [ ! -f "${ENV_FILE}" ]; then
warn ".env 文件不存在: ${ENV_FILE}"
warn "请创建并配置 SECRET_KEY 等环境变量"
mkdir -p "${DATA_BASE}"
cat > "${ENV_FILE}" << 'EOF'
# 必填
SECRET_KEY=your_secret_key_here
# Claude API(离线模式可留空)
CLAUDE_API_BASE=
CLAUDE_API_KEY=
# 离线模式(true=不调 Claude API)
OFFLINE_MODE=true
# 服务监测加密密钥(可选,不设则从 SECRET_KEY 派生)
MONITOR_ENC_KEY=
EOF
warn "已创建模板 ${ENV_FILE},请编辑后重新运行"
exit 1
fi
info ".env 文件: ${ENV_FILE}"
}
# ---------- 准备数据目录 ----------
prepare_dirs() {
info "准备数据目录..."
# 监测数据目录
mkdir -p "${DATA_BASE}/monitor-data/reports"
# 用户数据(首次部署时初始化空文件)
if [ ! -f "${DATA_BASE}/data/users.json" ]; then
mkdir -p "${DATA_BASE}/data"
echo '{"users":[]}' > "${DATA_BASE}/data/users.json"
info "已初始化空 users.json"
fi
# 日志目录
mkdir -p "${DATA_BASE}/logs"
# 搜索索引(如果存在旧部署的索引,复制过来)
if [ -f "${DATA_BASE}/搜索索引.json" ] && [ ! -f "${DATA_BASE}/data/搜索索引.json" ]; then
cp "${DATA_BASE}/搜索索引.json" "${DATA_BASE}/data/搜索索引.json"
info "已复制搜索索引到 data/"
fi
# 搜索向量(可选)
if [ -f "${DATA_BASE}/搜索向量.json" ] && [ ! -f "${DATA_BASE}/data/搜索向量.json" ]; then
cp "${DATA_BASE}/搜索向量.json" "${DATA_BASE}/data/搜索向量.json"
info "已复制搜索向量到 data/"
fi
info "数据目录准备完成"
}
# ---------- 构建镜像 ----------
build_image() {
info "构建 Docker 镜像..."
docker build -t "${IMAGE_NAME}" .
info "镜像构建完成: ${IMAGE_NAME}"
}
# ---------- 启动容器 ----------
start_container() {
# 检查是否有同名容器在运行
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
info "停止并移除旧容器..."
docker stop "${CONTAINER_NAME}" 2>/dev/null || true
docker rm "${CONTAINER_NAME}" 2>/dev/null || true
fi
info "启动容器..."
docker run -d \
--name "${CONTAINER_NAME}" \
--restart unless-stopped \
-p 8088:8088 \
-v "${DATA_BASE}/monitor-data:/app/web/service_monitor/data" \
-v "${DATA_BASE}/data/users.json:/app/web/users.json" \
-v "${DATA_BASE}/data/搜索索引.json:/app/data/搜索索引.json:ro" \
-v "${DATA_BASE}/data/搜索向量.json:/app/data/搜索向量.json:ro" \
-v "${DATA_BASE}/logs:/app/web/logs" \
--env-file "${ENV_FILE}" \
-e FLASK_DEBUG=0 \
-e TROUBLESHOOT_ROOT=/app \
-e PYTHONIOENCODING=utf-8 \
--memory=1g \
--cpus=1.0 \
"${IMAGE_NAME}"
info "容器已启动: ${CONTAINER_NAME}"
}
# ---------- 健康检查 ----------
health_check() {
info "等待服务启动..."
local max_retries=15
local count=0
while [ $count -lt $max_retries ]; do
if curl -sf http://localhost:8088/api/health > /dev/null 2>&1; then
info "服务健康检查通过!"
echo ""
curl -s http://localhost:8088/api/health | python3 -m json.tool 2>/dev/null || \
curl -s http://localhost:8088/api/health
echo ""
info "访问地址: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo '192.168.5.60'):8088"
return 0
fi
count=$((count + 1))
echo " 等待中... ($count/$max_retries)"
sleep 2
done
error "健康检查失败,请查看日志: docker logs ${CONTAINER_NAME}"
}
# ---------- 查看状态 ----------
show_status() {
info "容器状态:"
docker ps -f "name=${CONTAINER_NAME}" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || \
warn "容器 ${CONTAINER_NAME} 不存在"
echo ""
info "服务健康:"
curl -sf http://localhost:8088/api/health | python3 -m json.tool 2>/dev/null || \
warn "服务未响应"
}
# ---------- 停止容器 ----------
stop_container() {
info "停止容器 ${CONTAINER_NAME}..."
docker stop "${CONTAINER_NAME}" 2>/dev/null || warn "容器未在运行"
docker rm "${CONTAINER_NAME}" 2>/dev/null || true
info "容器已停止并移除"
}
# ---------- 主流程 ----------
main() {
local action="${1:-deploy}"
case "${action}" in
--build-only)
check_prerequisites
build_image
;;
--stop)
stop_container
;;
--status)
show_status
;;
deploy|"")
check_prerequisites
prepare_dirs
build_image
start_container
health_check
;;
*)
echo "用法: $0 [--build-only|--stop|--status]"
exit 1
;;
esac
}
main "$@"
version: '3.8'
# Troubleshoot AI Assistant — Docker Compose
#
# 使用方式:
# docker compose up -d # 启动
# docker compose logs -f # 查看日志
# docker compose down # 停止
# docker compose up -d --build # 重新构建并启动
#
# 注意:
# - 单容器部署,不用 gunicorn 多 worker(APScheduler 不兼容多进程)
# - 不挂载 docker.sock(避免影响宿主机其他容器)
# - 资源限制防止单容器抢占宿主机资源
# ============================================================
services:
troubleshoot:
build: .
build:
context: .
dockerfile: Dockerfile
image: troubleshoot:latest
container_name: troubleshoot
restart: unless-stopped
ports:
- "8088:8088"
restart: always
volumes:
# 配置与用户数据只读挂载(便于现场修改无需重建镜像)
- ./config.json:/app/web/config.json:ro
- ./users.json:/app/web/users.json:ro
- ./records:/app/web/records:ro
# 日志持久化到宿主机
- ./logs:/app/logs
# 服务监测数据(targets/schedules/reports/notifications)
- monitor-data:/app/web/service_monitor/data
# 用户数据
- /opt/troubleshoot/data/users.json:/app/web/users.json
# 知识库索引(只读)
- /opt/troubleshoot/data/搜索索引.json:/app/data/搜索索引.json:ro
- /opt/troubleshoot/data/搜索向量.json:/app/data/搜索向量.json:ro
# 缓存目录
- cache-data:/app/web/cache
# 日志
- /opt/troubleshoot/logs:/app/web/logs
environment:
- FLASK_DEBUG=0
- PYTHONIOENCODING=utf-8
- SECRET_KEY=${SECRET_KEY}
# 离线模式开关:true=跳过 Claude API(离线 Q&A),false=在线调 API
- TROUBLESHOOT_ROOT=/app
- SECRET_KEY=${SECRET_KEY:?SECRET_KEY is required}
- CLAUDE_API_BASE=${CLAUDE_API_BASE:-}
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
- OFFLINE_MODE=${OFFLINE_MODE:-true}
- MONITOR_ENC_KEY=${MONITOR_ENC_KEY:-}
# 资源限制(5.60 有其他服务容器,防止抢占资源)
deploy:
resources:
limits:
memory: 1G
cpus: '1.0'
reservations:
memory: 256M
cpus: '0.25'
# 健康检查
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8088/api/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
monitor-data:
driver: local
driver_opts:
type: none
o: bind
device: /opt/troubleshoot/monitor-data
cache-data:
......@@ -27,6 +27,7 @@ except ImportError:
HAS_NUMPY = False
from utils.logger import get_logger
from utils.paths import DATA_DIR, SEARCH_INDEX_DIR, VECTOR_INDEX_FILENAME
logger = get_logger(__name__)
......@@ -35,19 +36,20 @@ logger = get_logger(__name__)
# ============================================================
SCRIPT_DIR = Path(__file__).resolve().parent # .../web
DATA_DIR = SCRIPT_DIR.parent # 部署时即 /opt/troubleshoot
# 搜索索引路径(按优先级查找:部署环境 → 开发环境)
# 搜索索引路径(按优先级查找:统一路径 → 部署环境 → 开发环境)
SEARCH_INDEX_PATHS = [
DATA_DIR / "搜索索引.json", # 部署环境(/opt/troubleshoot/)
SEARCH_INDEX_DIR / "搜索索引.json", # 统一路径(容器/裸机)
DATA_DIR / "搜索索引.json", # 裸机部署(/opt/troubleshoot/)
Path("E:/github/ubains-module-test/develop/Docs/PRD/问题知识库/搜索索引.json"), # 开发环境绝对路径
SCRIPT_DIR.parent.parent.parent.parent / "Docs" / "PRD" / "问题知识库" / "搜索索引.json", # 相对路径
]
# 向量索引路径(与搜索索引同目录查找)
VECTOR_INDEX_PATHS = [
DATA_DIR / "搜索向量.json", # 部署环境
SCRIPT_DIR.parent.parent.parent.parent / "Docs" / "PRD" / "问题知识库" / "搜索向量.json", # 相对路径
SEARCH_INDEX_DIR / VECTOR_INDEX_FILENAME, # 统一路径(容器/裸机)
DATA_DIR / VECTOR_INDEX_FILENAME, # 裸机部署
SCRIPT_DIR.parent.parent.parent.parent / "Docs" / "PRD" / "问题知识库" / VECTOR_INDEX_FILENAME, # 相对路径
]
# query 向量缓存上限
......
......@@ -27,6 +27,9 @@ SHANGHAI_TZ = _pytz_timezone("Asia/Shanghai")
_scheduler = None
_scheduler_lock = threading.Lock()
# schedules.json 读写锁(防止多线程并发读-改-写导致状态丢失)
_schedules_lock = threading.Lock()
# 星期映射:数字 → 中文名
WEEKDAY_NAMES = {1: "一", 2: "二", 3: "三", 4: "四", 5: "五", 6: "六", 7: "日"}
......@@ -134,11 +137,12 @@ def _load_schedules() -> list:
def _save_schedules(schedules: list) -> None:
"""保存定时任务列表。"""
ensure_dirs()
SCHEDULES_FILE.write_text(
json.dumps(schedules, ensure_ascii=False, indent=2),
encoding="utf-8",
)
with _schedules_lock:
ensure_dirs()
SCHEDULES_FILE.write_text(
json.dumps(schedules, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def _now() -> str:
......@@ -356,15 +360,16 @@ def toggle_enabled(schedule_id: str, enabled: Optional[bool] = None) -> dict:
def update_run_status(schedule_id: str, status: str, report_id: str, run_at: str) -> dict:
"""更新定时任务执行状态。"""
schedules = _load_schedules()
for i, s in enumerate(schedules):
if s.get("id") == schedule_id:
schedules[i]["last_run_at"] = run_at
schedules[i]["last_run_status"] = status
schedules[i]["last_report_id"] = report_id
schedules[i]["next_run_at"] = _calc_next_run(s["cron"])
_save_schedules(schedules)
return schedules[i]
with _schedules_lock:
schedules = _load_schedules()
for i, s in enumerate(schedules):
if s.get("id") == schedule_id:
schedules[i]["last_run_at"] = run_at
schedules[i]["last_run_status"] = status
schedules[i]["last_report_id"] = report_id
schedules[i]["next_run_at"] = _calc_next_run(s["cron"])
_save_schedules(schedules)
return schedules[i]
raise ValueError("定时任务不存在")
......@@ -454,6 +459,9 @@ def _add_job(sched: dict) -> None:
id=job_id,
args=[sched["id"]],
replace_existing=True,
misfire_grace_time=3600, # 允许 1 小时内的错过执行(默认 1 秒太短)
coalesce=True, # 错过多次只执行一次
max_instances=1, # 同一 job 不并发(显式声明)
)
logger.info("定时任务已注册: %s (%s) [Asia/Shanghai]", sched["name"], sched["cron"])
except Exception as e:
......@@ -518,7 +526,14 @@ def _execute_scheduled_job(schedule_id: str) -> None:
logger.warning("定时任务不存在或已禁用: %s", schedule_id)
return
_run_job_body(schedule_id)
# 检查是否已在执行中(防止定时触发与手动触发并发)
if sched.get("current_status") == "running":
logger.warning("定时任务已在执行中,跳过: %s", schedule_id)
return
# 使用后台线程执行,不阻塞 APScheduler 调度线程
t = threading.Thread(target=_run_job_body, args=(schedule_id,), daemon=True)
t.start()
def run_now(schedule_id: str) -> dict:
......@@ -566,10 +581,11 @@ def get_scheduler_status() -> dict:
def _update_current_status(schedule_id: str, status: str) -> None:
"""更新定时任务当前执行状态(running/success/failed)。"""
schedules = _load_schedules()
for i, s in enumerate(schedules):
if s.get("id") == schedule_id:
schedules[i]["current_status"] = status
_save_schedules(schedules)
return
with _schedules_lock:
schedules = _load_schedules()
for i, s in enumerate(schedules):
if s.get("id") == schedule_id:
schedules[i]["current_status"] = status
_save_schedules(schedules)
return
raise ValueError("定时任务不存在")
......@@ -58,3 +58,18 @@ AUDIT_LOG_FILE = SCRIPT_DIR / "audit.log"
# 向量索引文件名(与搜索索引同目录部署)
VECTOR_INDEX_FILENAME = "搜索向量.json"
# 搜索索引目录(优先使用环境变量,否则自动检测)
# 容器化部署时 TROUBLESHOOT_ROOT=/app,索引文件在 /app/data/ 下
SEARCH_INDEX_DIR = os.environ.get('SEARCH_INDEX_DIR')
if SEARCH_INDEX_DIR:
SEARCH_INDEX_DIR = Path(SEARCH_INDEX_DIR)
elif (DATA_DIR / "data" / "搜索索引.json").exists():
# 容器化部署:/app/data/搜索索引.json
SEARCH_INDEX_DIR = DATA_DIR / "data"
elif (DATA_DIR / "搜索索引.json").exists():
# 裸机部署:/opt/troubleshoot/搜索索引.json
SEARCH_INDEX_DIR = DATA_DIR
else:
# 默认:与 DATA_DIR 同级
SEARCH_INDEX_DIR = DATA_DIR
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论