From 7d882352b8d0079d9bc148889974fbe685862ba9 Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 29 Jun 2026 13:12:05 +0900 Subject: [PATCH] =?UTF-8?q?fix(mineru):=20=EB=B3=80=ED=99=98/=EC=9B=8C?= =?UTF-8?q?=EB=B0=8D=20self-timeout=20+=20OOM=C2=B7=ED=96=89=20=EC=8B=9C?= =?UTF-8?q?=20=EC=97=94=EC=A7=84=20=EC=9E=AC=EC=9B=8C=EB=B0=8D=20escalate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aio_do_parse 에 자체 타임아웃이 없어 vLLM 행 시 _engine_lock 을 영구 점유 → markdown 변환 전체 마비(컨테이너 재시작 전까지). 클라이언트(marker_worker)는 300s 로 포기하나 서버측 inflight 는 자동 취소 안 됨. - _run_mineru 를 asyncio.wait_for(convert 600s / warmup 1200s)로 감싸 lock 점유 상한. - 타임아웃·OOM/CUDA 류 실패 시 _warmup_done 리셋 → 다음 요청 재워밍. 재워밍도 실패하면 _warmup_error → /ready 503 → healthcheck 재시작으로 escalate(영구 degradation 차단). Co-Authored-By: Claude Opus 4.8 (1M context) --- services/mineru/server.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/services/mineru/server.py b/services/mineru/server.py index dd86cde..37147aa 100644 --- a/services/mineru/server.py +++ b/services/mineru/server.py @@ -59,6 +59,11 @@ MAX_IMAGES_PER_DOC = int(os.getenv("MINERU_MAX_IMAGES_PER_DOC", "200")) MAX_BYTES_PER_IMAGE = int(os.getenv("MINERU_MAX_BYTES_PER_IMAGE", str(10 * 1024 * 1024))) MAX_PAGES_HARD = int(os.getenv("MINERU_MAX_PAGES_HARD", "200")) # 1-shot max_pages 안전장치 +# self-timeout — 변환/워밍이 vLLM 행으로 _engine_lock 을 영구 점유해 서비스가 wedge 되는 것을 차단. +# (클라이언트 marker_worker 는 300s 로 포기하나 서버측 inflight 는 자동 취소 안 됨 → 서버 자체 상한 필요.) +PARSE_TIMEOUT_S = float(os.getenv("MINERU_PARSE_TIMEOUT_S", "600")) +WARMUP_TIMEOUT_S = float(os.getenv("MINERU_WARMUP_TIMEOUT_S", "1200")) # 최초 모델 다운로드(~2.4GB) 여유 + _PRELOAD = os.getenv("MINERU_PRELOAD", "1") != "0" # ---- 엔진 상태 --------------------------------------------------------------- @@ -68,6 +73,15 @@ _warmup_error: str | None = None _engine_lock = asyncio.Lock() +def _is_engine_fatal(exc: BaseException) -> bool: + """OOM/CUDA 류 = 엔진 상태 오염 가능 → 재워밍 강제 대상(타임아웃은 호출측에서 별도 판정).""" + s = f"{type(exc).__name__} {exc}".lower() + return any( + k in s + for k in ("out of memory", "oom", "cuda", "cublas", "device-side", "illegal memory") + ) + + async def _run_mineru(pdf_bytes: bytes, lang: str) -> tuple[str, list[dict]]: """슬라이스된 PDF bytes → (markdown, 이미지 dict 리스트). **async 엔진 경로.** @@ -148,7 +162,7 @@ async def _ensure_warmup() -> None: page.insert_text((72, 72), "MinerU warmup.") warmup_bytes = doc.tobytes() doc.close() - await _run_mineru(warmup_bytes, MINERU_LANG) + await asyncio.wait_for(_run_mineru(warmup_bytes, MINERU_LANG), timeout=WARMUP_TIMEOUT_S) _warmup_done = True _warmup_error = None logger.info(f"[mineru-service] warmup done engine_version={_engine_version}") @@ -274,6 +288,7 @@ def _serialize_images(images: list[dict], src_path: str) -> tuple[list[ConvertIm @app.post("/convert", response_model=ConvertResponse) async def convert(req: ConvertRequest): + global _warmup_done p = _resolve_path(req.file_path) if p is None or not p.is_file(): raise HTTPException(404, detail={"code": "file_not_found", "message": req.file_path}) @@ -288,10 +303,18 @@ async def convert(req: ConvertRequest): async with _engine_lock: # 실제 변환 직렬화(단일 GPU) start = time.monotonic() try: - md_text, raw_images = await _run_mineru(pdf_bytes, MINERU_LANG) + md_text, raw_images = await asyncio.wait_for( + _run_mineru(pdf_bytes, MINERU_LANG), timeout=PARSE_TIMEOUT_S + ) except HTTPException: raise except Exception as exc: + # 타임아웃(엔진 행) 또는 OOM/CUDA 류면 엔진 오염 가능 → 다음 요청이 재워밍하도록 리셋. + # 재워밍까지 실패하면 _ensure_warmup 이 _warmup_error 설정 → /ready 503 → healthcheck + # 재시작으로 escalate(영구 degradation 차단). 일시 OOM 이면 재워밍 성공 후 정상화. + if isinstance(exc, (asyncio.TimeoutError, TimeoutError)) or _is_engine_fatal(exc): + _warmup_done = False + logger.error("[mineru-service] engine reset (timeout/fatal) path=%s: %s", p, exc) logger.exception(f"[mineru-service] conversion failed path={p}: {exc}") raise HTTPException(422, detail={"code": "conversion_failed", "message": f"{type(exc).__name__}: {exc}"}) from exc