diff --git a/lps/docs/operations.md b/lps/docs/operations.md index 3e9808f..f57e6c1 100644 --- a/lps/docs/operations.md +++ b/lps/docs/operations.md @@ -132,6 +132,10 @@ SELECT key, until, reason FROM search_negative ORDER BY created_at DESC; | `GET /v1/lps/ops` | 운영 스냅샷: 큐 카운트 + `oldest_pending_sec`(큐 지연) + `dead_1h` + `stuck_running` + `blocks_1h`(최근 차단). 외부 모니터가 스크랩·알림 | | 워커 하트비트 | `/tmp/lps_worker_heartbeat`(mtime) — 컨테이너 HEALTHCHECK 가 신선도<120s 로 행/좀비 워커 감지 | +**실시간 대시보드(로컬)**: `./run_monitor.sh` → http://localhost:9700 — 큐 추이·처리량(개/분)· +코어별 CPU·프로세스 그룹(worker/api/chrome/postgres) 사용률을 2초 간격으로 시각화. +부하테스트/e2e(`N=100 python loadtest.py`) 관측용. 상세는 `loadtest/README.md`. + **임계 알림**(워커 ops-monitor): 초과 시 WARN 로그 + (env 있으면) Slack 호환 웹훅. ``` LPS_ALERT_WEBHOOK=https://hooks.slack.com/... # 있으면 알림 전송 diff --git a/lps/loadtest/README.md b/lps/loadtest/README.md index b628fc3..4a72cb0 100644 --- a/lps/loadtest/README.md +++ b/lps/loadtest/README.md @@ -6,6 +6,9 @@ API 는 요청을 받아 `job` 테이블에 적재만 하고 즉시 응답한다 ## 파일 - `locustfile.py` — enqueue(POST /search, 고유코드 write) + 조회(jobs/stats/ops/readyz) 가중 부하 - `bench_multicore.sh` — **PROCESS_COUNT 1→N 자동 비교**(멀티코어 스케일링 측정, 대화형) +- `monitor.py` — **실시간 관측 대시보드**(:9700, `../run_monitor.sh`) — 큐 추이·처리량(개/분)·코어별 CPU· + 프로세스 그룹(worker/api/chrome/postgres) 사용률. e2e(`loadtest.py` N=100 등)와 같이 띄워 + "코어가 다 도는지 / 병목이 어느 층인지"를 본다. Grafana 대체가 아닌 로컬 경량 도구(외부 인프라 없음). ## 실행 ```bash diff --git a/lps/loadtest/monitor.py b/lps/loadtest/monitor.py new file mode 100644 index 0000000..ab946f0 --- /dev/null +++ b/lps/loadtest/monitor.py @@ -0,0 +1,354 @@ +"""LPS 실시간 모니터 — 부하/e2e 실행 중 큐 진행·CPU·병목을 브라우저에서 본다. + +무엇을 보여주나 (2초 샘플링, 브라우저 :9700) + · 큐 흐름 : /v1/lps/ops 폴링 → PENDING/RUNNING/DONE/DEAD 추이 + 처리량(상품/분) + · CPU 코어 : 코어별 사용률 막대 — 멀티코어가 전부 도는지 + · 프로세스 : worker/api/chrome/postgres 그룹별 CPU·메모리 — 병목이 어느 층인지 + · 병목 판독 : RUNNING=동시성인데 코어가 놀면 I/O 바운드(크롤 대기=정상 병목), + chrome CPU 가 치솟으면 렌더 병목, postgres 가 치솟으면 DB 병목 + +실행 (Grafana 대체가 아니라 로컬 1회 측정용 — 외부 인프라 없음) + ./run_monitor.sh # 대화형 + BASE=http://localhost:9600 MONITOR_PORT=9700 python loadtest/monitor.py +""" + +import asyncio +import os +import time +from collections import deque +from contextlib import asynccontextmanager + +import httpx +import psutil +import uvicorn +from fastapi import FastAPI +from fastapi.responses import HTMLResponse, JSONResponse + +BASE = os.environ.get("BASE", "http://localhost:9600") +PORT = int(os.environ.get("MONITOR_PORT", "9700")) +INTERVAL = float(os.environ.get("MONITOR_INTERVAL", "2")) +MAX_SAMPLES = 1800 # 2s × 1800 = 1시간 링버퍼 + +GROUPS = ("worker", "api", "chrome", "postgres") +SAMPLES: deque = deque(maxlen=MAX_SAMPLES) +_proc_cache: dict[int, psutil.Process] = {} # cpu_percent 는 이전 호출과의 간격으로 계산 → 객체 재사용 필수 + + +def _classify_processes() -> dict[int, str]: + """pid → 그룹. python 은 cmdline 으로 판별. uvicorn 멀티프로세스 자식(spawn, cmdline 에 + 파일명 없음)은 부모가 api 면 api 로. chrome 은 사용자의 브라우저와 섞이지 않게 + **조상 체인에 워커가 있는 것(크롤 Chromium)만** 집계한다.""" + cls: dict[int, str] = {} + api_parents: set[int] = set() + worker_pids: set[int] = set() + pythons: list[tuple[int, int]] = [] # (pid, ppid) — 2차 패스(부모 귀속)용 + chromes: list[int] = [] + parent_of: dict[int, int] = {} + for p in psutil.process_iter(attrs=["pid", "ppid", "name"]): + try: + pid, name = p.info["pid"], (p.info["name"] or "").lower() + parent_of[pid] = p.info["ppid"] or 0 + if "postgres" in name: + cls[pid] = "postgres" + elif "chrom" in name: # chrome / chromium / helpers — 귀속은 2차 패스에서 + chromes.append(pid) + elif "python" in name: + cmd = " ".join(p.cmdline()) + if "worker_main.py" in cmd: + cls[pid] = "worker" + worker_pids.add(pid) + elif "web_main.py" in cmd or "router.router" in cmd: + cls[pid] = "api" + api_parents.add(pid) + elif "monitor.py" not in cmd and "locust" not in cmd: + pythons.append((pid, p.info["ppid"])) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + for pid, ppid in pythons: + if ppid in api_parents: + cls[pid] = "api" + for pid in chromes: # 워커(patchright→node→chromium)의 자손만 크롤 브라우저 + cur, hops = pid, 0 + while cur and hops < 12: + if cur in worker_pids: + cls[pid] = "chrome" + break + cur, hops = parent_of.get(cur, 0), hops + 1 + return cls + + +def _sample_cpu() -> dict: + cores = psutil.cpu_percent(percpu=True) + groups = {g: {"cpu": 0.0, "mem": 0, "n": 0} for g in GROUPS} + cls = _classify_processes() + for pid, g in cls.items(): + try: + proc = _proc_cache.get(pid) + if proc is None: + proc = _proc_cache[pid] = psutil.Process(pid) + groups[g]["cpu"] += proc.cpu_percent(None) # 코어 1개=100 기준(멀티코어면 100 초과 가능) + groups[g]["mem"] += proc.memory_info().rss + groups[g]["n"] += 1 + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + for pid in [pid for pid in _proc_cache if pid not in cls]: # 죽은 pid 정리 + _proc_cache.pop(pid, None) + for g in GROUPS: + groups[g]["cpu"] = round(groups[g]["cpu"], 1) + groups[g]["mem"] = int(groups[g]["mem"] / 1048576) # MB + return {"cores": [round(c, 1) for c in cores], "groups": groups} + + +async def _sampler(): + psutil.cpu_percent(percpu=True) # priming — 첫 유효 샘플부터 의미 있는 값 + async with httpx.AsyncClient(timeout=3) as c: + while True: + snap = {"t": round(time.time(), 1), **_sample_cpu(), "ops": None} + try: + r = await c.get(f"{BASE}/v1/lps/ops") + if r.status_code == 200: + snap["ops"] = r.json() + except Exception: + pass # API 죽어 있어도 CPU 샘플은 계속 + SAMPLES.append(snap) + await asyncio.sleep(INTERVAL) + + +@asynccontextmanager +async def _lifespan(app): + task = asyncio.create_task(_sampler()) + yield + task.cancel() + + +app = FastAPI(lifespan=_lifespan) + + +@app.get("/api/series") +async def series(since: float = 0.0): + return JSONResponse({ + "base": BASE, "interval": INTERVAL, "ncores": psutil.cpu_count(), + "samples": [s for s in SAMPLES if s["t"] > since], + }) + + +@app.get("/") +async def index(): + return HTMLResponse(PAGE) + + +# ────────────────────────────────────────────────────────────────────────────── +# 대시보드(단일 페이지, 외부 의존 없음). 다크 고정 — 로컬 관측 도구. +# 팔레트는 dataviz 검증 통과값(다크 서피스 #1a1a19 기준, 라인 끝 직접 라벨로 보조 인코딩). +PAGE = r""" + + +LPS 모니터 + + +

LPS 실시간 모니터

+
대상 · 2초 샘플링 · 최근 1시간 유지
+ +
+ +
+
+

큐 추이

/v1/lps/ops — 잡 상태별 개수. DONE 이 계단처럼 오르면 정상 소화 중
+
+
+
+
+

프로세스 그룹 CPU

코어 1개=100% 기준(멀티코어면 100 초과). 어느 층이 바쁜지 = 병목 후보
+
+
+
+
+

코어별 사용률 (현재)

막대가 고르게 차면 멀티코어 활용 중, 1~2개만 차면 단일 코어 병목
+
+
+
+

현재 스냅샷 (표)

그래프와 같은 데이터의 수치 뷰
+
+
+
+ + + +""" + +if __name__ == "__main__": + print(f"■ LPS 모니터: http://localhost:{PORT} (대상 API {BASE}, {INTERVAL:.0f}s 샘플링)") + uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="warning") diff --git a/lps/requirements.txt b/lps/requirements.txt index ea624a8..21f7a73 100644 --- a/lps/requirements.txt +++ b/lps/requirements.txt @@ -14,3 +14,6 @@ patchright # 스텔스 Playwright 포크. 쿠팡 Akamai JS 챌린지 # ※ nodriver 는 Python 3.14 소스인코딩 버그로 미채택 → Patchright 로 대체 # ※ 실행엔 시스템 Google Chrome 필요(로컬) / 배포 이미지엔 chromium 설치 필요 openai # AI 유사도 판정(같은 상품 매칭) — [OpenAIConfig].api_key(config.local.toml). structured output 사용 + +# --- 로컬 관측 도구(프로덕션 미사용) --- +psutil # loadtest/monitor.py — 코어별 CPU·프로세스 그룹(worker/api/chrome/postgres) 사용률 샘플링 diff --git a/lps/run_monitor.sh b/lps/run_monitor.sh new file mode 100755 index 0000000..680cf4c --- /dev/null +++ b/lps/run_monitor.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# +# LPS 실시간 모니터 실행 (대화형) — 부하/e2e 실행 중 큐 진행·코어별 CPU·병목을 브라우저에서 관측. +# loadtest.py(e2e 100건 등)와 같이 띄워두고 보는 용도. Grafana 대체가 아닌 로컬 경량 도구. +# +set -euo pipefail +cd "$(dirname "$0")" # lps/ + +PY=".venv/bin/python" +[[ -x "$PY" ]] || { echo "[error] .venv 가 없습니다. ./run_local_server.sh 를 먼저 한 번 실행하세요."; exit 1; } + +# psutil 보장(관측 도구 전용 의존성) +"$PY" -c "import psutil" 2>/dev/null || { echo "[setup] psutil 설치..."; "$PY" -m pip install -q psutil; } + +echo "── 모니터 설정 ──" +read -rp "모니터 포트 [9700]: " port +port="${port:-9700}" +read -rp "대상 API [http://localhost:9600] (엔터=유지): " base +base="${base:-http://localhost:9600}" + +if lsof -ti:"$port" >/dev/null 2>&1; then + echo "[info] 포트 $port 사용 중 → 기존 프로세스 종료" + lsof -ti:"$port" | xargs kill 2>/dev/null || true + sleep 1 +fi + +echo +echo "[run] 모니터 → http://localhost:$port (대상 $base)" +echo " 워커·API·크롬·postgres 의 CPU 와 큐 추이가 2초마다 갱신됩니다. 중단: Ctrl+C" +echo +exec env BASE="$base" MONITOR_PORT="$port" "$PY" loadtest/monitor.py