"""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")