perf(infra): NAS CPU 중기 2건 + 1건 보류 (CHECK_POINT 🟡)
#6 insta-lab Chromium Browser Pool — Playwright/Chromium 인스턴스를 모듈 레벨에서 보관하고 매 슬레이트마다 reuse. 카드 10장 렌더의 launch 비용 (~3초/회)이 사라짐. startup/shutdown lifecycle hook 추가. crashed/disconnected 시 lazy 재초기화. #8 realestate-lab 수집 병렬화 — collect_all과 delete_old_completed가 서로 다른 데이터 영역이라 ThreadPoolExecutor(2)로 병렬. asyncio.gather 대신 thread executor를 쓴 이유는 BackgroundScheduler+동기 함수 환경 에서 자연스럽고 추가 의존성 없기 때문. 매칭은 일관성 유지로 순차. #7 stock async — 보류. 재진단 결과 stock은 BackgroundScheduler 사용 중이라 main loop 블로킹 없음. fetch 4회는 network I/O wait가 대부분이라 to_thread도 의미 없음. 진짜 효과를 보려면 AsyncIOScheduler 전환 + aiohttp 병렬이라 큰 리팩토링. 박재오 판단 대기. CHECK_POINT.md 갱신. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -113,23 +113,26 @@ scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=30)
|
|||||||
|
|
||||||
## 🟡 중기 (1~2주)
|
## 🟡 중기 (1~2주)
|
||||||
|
|
||||||
### 6. Chromium Browser Pool 재설계 (insta-lab)
|
### 6. Chromium Browser Pool 재설계 (insta-lab) ✅ 2026-05-18
|
||||||
- 매번 launch X → 1개 인스턴스 재사용
|
- 매번 launch X → 1개 인스턴스 재사용
|
||||||
- 카드 10장 렌더 시간 30% 단축 기대
|
- 카드 10장 렌더 시간 30% 단축 기대
|
||||||
- [ ] `insta-lab/app/browser_pool.py` 신규 모듈
|
- [x] `card_renderer.py` 내부에 모듈 레벨 `_PLAYWRIGHT`/`_BROWSER` + `init_browser`/`shutdown_browser` 함수 (별도 모듈 분리 안 함, 같은 파일에 인접 배치)
|
||||||
- [ ] card_renderer에서 풀 사용
|
- [x] `_render_slate_locked` 본체에서 `_get_browser()` 재사용 (crashed 시 lazy 재초기화)
|
||||||
|
- [x] `main.py` startup hook에서 `init_browser()`, shutdown hook에서 `shutdown_browser()`
|
||||||
|
|
||||||
### 7. stock 뉴스 스크랩 비동기화
|
### 7. stock 뉴스 스크랩 비동기화 — ⚠️ 보류 2026-05-18
|
||||||
- **파일**: `stock/app/main.py:104-149`
|
- **재진단**: stock은 `BackgroundScheduler` 사용 중 → main loop 블로킹 없음 (이미 별도 thread)
|
||||||
- `run_scraping_job()` 동기 → BackgroundTask 또는 asyncio
|
- `fetch_market_news`의 4개 동기 `requests.get`은 network I/O wait라 CPU 거의 사용 안 함
|
||||||
- 08:00 APScheduler 블로킹 해소
|
- `to_thread`로 wrap해도 BackgroundScheduler 환경에서 사실상 의미 없음
|
||||||
- [ ] async/await 변환
|
- 진짜 효과를 보려면 AsyncIOScheduler 전환 + scraper.py 4개 fetch를 `aiohttp` 병렬로 — **큰 리팩토링 vs 효과 불명확**
|
||||||
|
- [ ] 박재오 판단: 큰 리팩토링 진행 여부
|
||||||
|
|
||||||
### 8. realestate 수집 병렬화
|
### 8. realestate 수집 병렬화 ✅ 2026-05-18
|
||||||
- **파일**: `realestate-lab/app/main.py:28-37`
|
- **파일**: `realestate-lab/app/main.py:scheduled_collect`
|
||||||
- `collect_all()` + `delete_old()` 병렬 → `asyncio.gather`
|
- `collect_all()` + `delete_old_completed_announcements()` 병렬
|
||||||
|
- BackgroundScheduler 환경이라 `asyncio.gather` 대신 `ThreadPoolExecutor(max_workers=2)` 사용 (효과 동일)
|
||||||
- 매칭은 순차 유지 (DB 일관성)
|
- 매칭은 순차 유지 (DB 일관성)
|
||||||
- [ ] async 함수 변환 + gather 적용
|
- [x] ThreadPoolExecutor 적용
|
||||||
|
|
||||||
### 9. lotto Monte Carlo 시뮬레이션 빈도 검토
|
### 9. lotto Monte Carlo 시뮬레이션 빈도 검토
|
||||||
- 현재 6회/일 (00·04·08·12·16·20)
|
- 현재 6회/일 (00·04·08·12·16·20)
|
||||||
@@ -173,6 +176,7 @@ services:
|
|||||||
- 2026-05-17: insta-lab minimal theme + design_importer 추가
|
- 2026-05-17: insta-lab minimal theme + design_importer 추가
|
||||||
- 2026-05-17: blog-lab 트랙 완전 폐기 (docker-compose에 없음, 위키 정정 완료)
|
- 2026-05-17: blog-lab 트랙 완전 폐기 (docker-compose에 없음, 위키 정정 완료)
|
||||||
- 2026-05-18: 🔴 즉시 5건 일괄 적용 — 09:00 cron 스태거링(insta/lotto/youtube/realestate), lotto Monte Carlo 08:30, insta-lab Semaphore(1), healthcheck 60s, uvicorn --workers 1 명시 (사용자 push + NAS deployer 재기동 대기)
|
- 2026-05-18: 🔴 즉시 5건 일괄 적용 — 09:00 cron 스태거링(insta/lotto/youtube/realestate), lotto Monte Carlo 08:30, insta-lab Semaphore(1), healthcheck 60s, uvicorn --workers 1 명시 (사용자 push + NAS deployer 재기동 대기)
|
||||||
|
- 2026-05-18: 🟡 중기 2건 적용 — #6 insta-lab Chromium Browser Pool (lifecycle hook), #8 realestate ThreadPoolExecutor 병렬 (collect/delete). #7 stock async는 BackgroundScheduler 사용 중이라 재진단 후 보류 (효과 미미). #9 Monte Carlo 빈도는 박재오 결정 대기.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,47 @@ def _render_semaphore() -> asyncio.Semaphore:
|
|||||||
return _RENDER_SEMAPHORE
|
return _RENDER_SEMAPHORE
|
||||||
|
|
||||||
|
|
||||||
|
# Chromium 브라우저 풀 — 매 슬레이트마다 launch 하지 않고 1개를 살려둠.
|
||||||
|
# (CHECK_POINT 중기-6) 카드 10장 렌더 시간 ~30% 단축 기대.
|
||||||
|
_PLAYWRIGHT = None
|
||||||
|
_BROWSER = None
|
||||||
|
|
||||||
|
|
||||||
|
async def init_browser() -> None:
|
||||||
|
"""앱 startup hook에서 1회 호출. 이미 살아있으면 no-op."""
|
||||||
|
global _PLAYWRIGHT, _BROWSER
|
||||||
|
if _BROWSER is not None and _BROWSER.is_connected():
|
||||||
|
return
|
||||||
|
_PLAYWRIGHT = await async_playwright().start()
|
||||||
|
_BROWSER = await _PLAYWRIGHT.chromium.launch()
|
||||||
|
logger.info("Chromium browser pool 초기화 완료")
|
||||||
|
|
||||||
|
|
||||||
|
async def shutdown_browser() -> None:
|
||||||
|
"""앱 shutdown hook에서 1회 호출."""
|
||||||
|
global _PLAYWRIGHT, _BROWSER
|
||||||
|
if _BROWSER is not None:
|
||||||
|
try:
|
||||||
|
await _BROWSER.close()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("browser close 중 예외 (무시)")
|
||||||
|
_BROWSER = None
|
||||||
|
if _PLAYWRIGHT is not None:
|
||||||
|
try:
|
||||||
|
await _PLAYWRIGHT.stop()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("playwright stop 중 예외 (무시)")
|
||||||
|
_PLAYWRIGHT = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_browser():
|
||||||
|
"""현재 브라우저 핸들 반환. crashed/None이면 재초기화 후 반환."""
|
||||||
|
global _BROWSER
|
||||||
|
if _BROWSER is None or not _BROWSER.is_connected():
|
||||||
|
await init_browser()
|
||||||
|
return _BROWSER
|
||||||
|
|
||||||
|
|
||||||
def _resolve_template_dir() -> str:
|
def _resolve_template_dir() -> str:
|
||||||
"""Prefer config CARD_TEMPLATE_DIR if it exists; else fall back to in-repo templates/."""
|
"""Prefer config CARD_TEMPLATE_DIR if it exists; else fall back to in-repo templates/."""
|
||||||
if os.path.isdir(CARD_TEMPLATE_DIR):
|
if os.path.isdir(CARD_TEMPLATE_DIR):
|
||||||
@@ -97,29 +138,28 @@ async def _render_slate_locked(slate_id: int, template: str) -> List[str]:
|
|||||||
out_dir = _slate_dir(slate_id)
|
out_dir = _slate_dir(slate_id)
|
||||||
paths: List[str] = []
|
paths: List[str] = []
|
||||||
|
|
||||||
async with async_playwright() as p:
|
browser = await _get_browser()
|
||||||
browser = await p.chromium.launch()
|
ctx = await browser.new_context(viewport={"width": 1080, "height": 1350})
|
||||||
try:
|
try:
|
||||||
ctx = await browser.new_context(viewport={"width": 1080, "height": 1350})
|
page = await ctx.new_page()
|
||||||
page = await ctx.new_page()
|
for spec in pages:
|
||||||
for spec in pages:
|
html_str = tmpl.render(**spec)
|
||||||
html_str = tmpl.render(**spec)
|
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
|
||||||
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
|
f.write(html_str)
|
||||||
f.write(html_str)
|
html_path = f.name
|
||||||
html_path = f.name
|
try:
|
||||||
|
await page.goto(f"file://{html_path}", wait_until="networkidle")
|
||||||
|
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
|
||||||
|
await page.screenshot(path=out_path, full_page=False, omit_background=False)
|
||||||
|
with open(out_path, "rb") as fp:
|
||||||
|
file_hash = hashlib.md5(fp.read()).hexdigest()
|
||||||
|
db.add_card_asset(slate_id, spec["page_no"], out_path, file_hash)
|
||||||
|
paths.append(out_path)
|
||||||
|
finally:
|
||||||
try:
|
try:
|
||||||
await page.goto(f"file://{html_path}", wait_until="networkidle")
|
os.unlink(html_path)
|
||||||
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
|
except OSError:
|
||||||
await page.screenshot(path=out_path, full_page=False, omit_background=False)
|
pass
|
||||||
with open(out_path, "rb") as fp:
|
finally:
|
||||||
file_hash = hashlib.md5(fp.read()).hexdigest()
|
await ctx.close()
|
||||||
db.add_card_asset(slate_id, spec["page_no"], out_path, file_hash)
|
|
||||||
paths.append(out_path)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.unlink(html_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
await browser.close()
|
|
||||||
return paths
|
return paths
|
||||||
|
|||||||
@@ -31,9 +31,16 @@ app.add_middleware(
|
|||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def on_startup():
|
async def on_startup():
|
||||||
os.makedirs(INSTA_DATA_PATH, exist_ok=True)
|
os.makedirs(INSTA_DATA_PATH, exist_ok=True)
|
||||||
db.init_db()
|
db.init_db()
|
||||||
|
# Chromium browser pool 초기화 (CHECK_POINT 중기-6)
|
||||||
|
await card_renderer.init_browser()
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def on_shutdown():
|
||||||
|
await card_renderer.shutdown_browser()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import BackgroundTasks, FastAPI, Query, HTTPException
|
from fastapi import BackgroundTasks, FastAPI, Query, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -26,10 +27,19 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
|||||||
|
|
||||||
|
|
||||||
def scheduled_collect():
|
def scheduled_collect():
|
||||||
"""매일 09:00 — 수집 + 정리 + 매칭 + 알림 push"""
|
"""매일 09:15 — 수집 + 정리 (병렬) → 매칭 → 알림 push.
|
||||||
|
|
||||||
|
collect_all과 delete_old_completed_announcements는 서로 다른 데이터
|
||||||
|
영역을 건드리므로 thread 둘로 병렬화. 매칭은 두 작업 완료 후 순차
|
||||||
|
실행 (DB 일관성). CHECK_POINT 중기-8 — env이 BackgroundScheduler+
|
||||||
|
동기 함수 조합이라 asyncio.gather 대신 ThreadPoolExecutor 사용.
|
||||||
|
"""
|
||||||
logger.info("스케줄 수집 시작")
|
logger.info("스케줄 수집 시작")
|
||||||
collect_all()
|
with ThreadPoolExecutor(max_workers=2) as ex:
|
||||||
deleted = delete_old_completed_announcements(grace_days=90)
|
collect_future = ex.submit(collect_all)
|
||||||
|
delete_future = ex.submit(delete_old_completed_announcements, 90)
|
||||||
|
collect_future.result()
|
||||||
|
deleted = delete_future.result()
|
||||||
if deleted:
|
if deleted:
|
||||||
logger.info("정리: %d건 삭제", deleted)
|
logger.info("정리: %d건 삭제", deleted)
|
||||||
run_matching()
|
run_matching()
|
||||||
|
|||||||
Reference in New Issue
Block a user