diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8684f02..6328288 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,10 +1,12 @@ # ============================================================================= # MinerU ROCm Docker Compose 配置 # 原生 Linux + AMD GPU 环境 +# +# 架构:Gradio / API → nginx (8000) → worker0 (8001) / worker1 (8002) # ============================================================================= services: - # --- WebUI 前端(纯前端,不本地处理,通过 Router → Worker 处理)--- + # --- WebUI 前端(纯前端,通过 nginx 分发到 worker)--- gradio: image: mineru-rocm:7.2.1 profiles: ["gradio"] @@ -16,7 +18,7 @@ services: - "10002:7860" environment: - GRADIO_SERVER_NAME=0.0.0.0 - - MINERU_API_BASE=http://mineru-router:8000 + - MINERU_API_BASE=http://mineru-nginx:8000 - MINERU_MODEL_SOURCE=${MINERU_MODEL_SOURCE:-huggingface} - HF_HUB_CACHE=${HF_HUB_CACHE:-/opt/models/huggingface} - MODELSCOPE_CACHE=${MODELSCOPE_CACHE:-/opt/models/modelscope} @@ -30,9 +32,9 @@ services: "/opt/mineru_venv/bin/python", "/opt/scripts/gradio_client.py", ] depends_on: - - router + - nginx - # --- 双卡 Worker(GPU 算力,无 profile,始终可用)--- + # --- 双卡 Worker(GPU 算力)--- worker0: image: mineru-rocm:7.2.1 build: @@ -94,32 +96,14 @@ services: - ./scripts:/opt/scripts:ro command: ["mineru-api", "--host", "0.0.0.0", "--port", "8002", "--allow-public-http-client"] - router: - image: mineru-rocm:7.2.1 - container_name: mineru-router - stdin_open: true - tty: true - ipc: host + # --- Nginx 负载均衡(替换有 Bug 的 mineru-router)--- + nginx: + image: nginx:alpine + container_name: mineru-nginx ports: - "8000:8000" - environment: - - MINERU_MODEL_SOURCE=${MINERU_MODEL_SOURCE:-huggingface} - - HF_HUB_CACHE=${HF_HUB_CACHE:-/opt/models/huggingface} - - MODELSCOPE_CACHE=${MODELSCOPE_CACHE:-/opt/models/modelscope} volumes: - - ${INPUT_DIR:-./data/input}:/data/input:ro - - ${OUTPUT_DIR:-./data/output}:/data/output - - ${MODEL_DIR:-./data/models}:/opt/models - - ./scripts:/opt/scripts:ro - command: - [ - "mineru-router", - "--api-urls", - "http://mineru-worker0:8001,http://mineru-worker1:8002", - "--host", "0.0.0.0", - "--port", "8000", - "--allow-public-http-client", - ] + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - worker0 - worker1 diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..99aac75 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,22 @@ +upstream mineru_workers { + # 轮询分发 + server mineru-worker0:8001; + server mineru-worker1:8002; +} + +server { + listen 8000; + server_name _; + + # 文件上传可能很大,调大限制 + client_max_body_size 500m; + + location / { + proxy_pass http://mineru_workers; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 600s; # 长任务需要 + proxy_send_timeout 600s; + } +} diff --git a/docker/scripts/gradio_client.py b/docker/scripts/gradio_client.py index c654ec2..ef02183 100644 --- a/docker/scripts/gradio_client.py +++ b/docker/scripts/gradio_client.py @@ -17,44 +17,40 @@ from pathlib import Path import gradio as gr import httpx -API_BASE = os.environ.get("MINERU_API_BASE", "http://mineru-router:8000") -DEFAULT_BACKEND = os.environ.get("MINERU_BACKEND", "hybrid-auto-engine") +API_BASE = os.environ.get("MINERU_API_BASE", "http://mineru-nginx:8000") +DEFAULT_BACKEND = os.environ.get("MINERU_BACKEND", "hybrid-engine") DEFAULT_LANG = os.environ.get("MINERU_LANG", "ch") POLL_INTERVAL = float(os.environ.get("MINERU_POLL_INTERVAL", "1.0")) MAX_WAIT = float(os.environ.get("MINERU_MAX_WAIT", "600")) async def discover_api(client: httpx.AsyncClient) -> str: - """探测实际可用的 API 前缀: /api/v1/tasks 或 /tasks""" + """探测实际可用的 API 前缀: /file_parse 端点""" candidates = [ - f"{API_BASE}/api/v1/health", - f"{API_BASE}/health", + f"{API_BASE}/openapi.json", f"{API_BASE}/docs", ] for url in candidates: try: r = await client.get(url, timeout=5) - if r.status_code < 500: - # FastAPI docs → /api/v1 ; plain health → / - if "/api/v1" in str(r.url): - return "/api/v1/tasks" - return "/tasks" + if r.status_code == 200: + return "" # worker uses root-level endpoints like /file_parse except Exception: continue - # 默认新版 API - return "/api/v1/tasks" + return "" async def submit_task(client: httpx.AsyncClient, api_prefix: str, file_path: str, file_name: str, backend: str, lang: str) -> dict: - """提交解析任务""" - submit_url = f"{API_BASE}{api_prefix}/submit" + """提交解析任务到 worker 的 /file_parse 端点""" + submit_url = f"{API_BASE}/file_parse" with open(file_path, "rb") as f: - files = {"file": (file_name, f, "application/pdf")} + files = {"files": (file_name, f, "application/pdf")} data = { "backend": backend, - "lang": lang, + "lang_list": lang, + "parse_method": "auto", } r = await client.post(submit_url, files=files, data=data, timeout=30) r.raise_for_status() @@ -64,7 +60,7 @@ async def submit_task(client: httpx.AsyncClient, api_prefix: str, async def get_task_status(client: httpx.AsyncClient, api_prefix: str, task_id: str) -> dict: """查询任务状态""" - url = f"{API_BASE}{api_prefix}/{task_id}" + url = f"{API_BASE}/tasks/{task_id}" r = await client.get(url, timeout=10) r.raise_for_status() return r.json() @@ -72,24 +68,14 @@ async def get_task_status(client: httpx.AsyncClient, api_prefix: str, async def download_result(client: httpx.AsyncClient, api_prefix: str, task_id: str, output_dir: str) -> str: - """下载任务结果。尝试多种 endpoint 格式""" - candidates = [ - f"{api_prefix}/{task_id}/data", - f"{api_prefix}/{task_id}/result", - ] - for suffix in candidates: - url = f"{API_BASE}{suffix}" - try: - r = await client.get(url, timeout=60, follow_redirects=True) - if r.status_code == 200: - # 下载 zip 文件 - zip_path = os.path.join(output_dir, f"{task_id}.zip") - with open(zip_path, "wb") as f: - f.write(r.content) - return zip_path - except Exception: - continue - raise RuntimeError(f"Failed to download result for task {task_id}") + """下载任务结果""" + url = f"{API_BASE}/tasks/{task_id}/result" + r = await client.get(url, timeout=60, follow_redirects=True) + r.raise_for_status() + zip_path = os.path.join(output_dir, f"{task_id}.zip") + with open(zip_path, "wb") as f: + f.write(r.content) + return zip_path def extract_readme(zip_path: str, output_dir: str) -> str: @@ -127,18 +113,8 @@ async def process_pdf(file_obj, backend, lang, progress=gr.Progress()): progress(0.15, desc="Submitting task...") try: submit_resp = await submit_task(client, api_prefix, file_path, file_name, backend, lang) - except httpx.HTTPStatusError as e: - # 尝试旧版 endpoint(不带 /submit 后缀) - alt_data = {"backend": backend, "lang": lang} - try: - alt_url = f"{API_BASE}{api_prefix}" - with open(file_path, "rb") as f: - files = {"file": (file_name, f, "application/pdf")} - r = await client.post(alt_url, files=files, data=alt_data, timeout=30) - r.raise_for_status() - submit_resp = r.json() - except Exception as e2: - return f"Task submission failed: {e}\nAlt attempt: {e2}", "", "" + except Exception as e: + return f"Task submission failed: {e}", "", "" task_id = submit_resp.get("task_id") or submit_resp.get("data", {}).get("task_id") if not task_id: