feat(lps): P4 관측·알림·워커 헬스 — readyz/ops + 하트비트/HEALTHCHECK + 임계 알림
프로덕션 운영 가시성. 행/좀비 워커 감지 + 큐/차단 지표 노출 + 임계 알림. - API: /readyz(DB 도달성=readiness, 실패 503; /healthz=liveness와 구분). /v1/lps/ops(플랫 JSON): 큐 카운트 + oldest_pending_sec(큐지연) + dead_1h + stuck_running + blocks_1h. - crud: JobQueue.ops()/ping(), BotDetectionLog.recent_count(). - worker: run_ops_monitor — 하트비트 파일 주기 갱신(Docker HEALTHCHECK 가 신선도로 행 워커 감지) + 임계(DEAD/차단/큐지연/stuck) 초과 시 WARN 로그 + (LPS_ALERT_WEBHOOK 있으면) Slack 호환 웹훅. - Dockerfile.worker: HEALTHCHECK(하트비트 <120s). 임계·웹훅은 env(LPS_ALERT_*). - 테스트: readyz/ops 2종. 검증: 컨테이너 healthy 판정, 하트비트 갱신, ops 스냅샷 정상. 91 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
3a8d669970
commit
d6dd47e652
@ -22,7 +22,12 @@ COPY . .
|
|||||||
ENV APP_ENV=local \
|
ENV APP_ENV=local \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
LPS_CHROME_EXECUTABLE=/usr/bin/chromium \
|
LPS_CHROME_EXECUTABLE=/usr/bin/chromium \
|
||||||
DISPLAY=:99
|
DISPLAY=:99 \
|
||||||
|
LPS_HEARTBEAT_FILE=/tmp/lps_worker_heartbeat
|
||||||
|
|
||||||
|
# 하트비트 신선도(<120s)로 행/좀비 워커 감지. start-period 는 웜업(브라우저 기동) 여유.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=8s --start-period=120s --retries=3 \
|
||||||
|
CMD python -c "import os,time,sys; p=os.environ['LPS_HEARTBEAT_FILE']; sys.exit(0 if os.path.exists(p) and time.time()-os.path.getmtime(p)<120 else 1)"
|
||||||
|
|
||||||
# Xvfb(가상 디스플레이)를 백그라운드로 띄우고 python 을 exec 로 승계 실행.
|
# Xvfb(가상 디스플레이)를 백그라운드로 띄우고 python 을 exec 로 승계 실행.
|
||||||
# → 헤드풀 Chromium 이 :99 에 뜨고, 워커 로그는 그대로 docker logs 로 나온다(xvfb-run 은 로그를 삼킴).
|
# → 헤드풀 Chromium 이 :99 에 뜨고, 워커 로그는 그대로 docker logs 로 나온다(xvfb-run 은 로그를 삼킴).
|
||||||
|
|||||||
@ -26,3 +26,12 @@ class BotDetectionLog:
|
|||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value)
|
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value)
|
||||||
|
|
||||||
|
async def recent_count(self, minutes: int = 60) -> int:
|
||||||
|
"""최근 N분간 봇 감지(차단) 건수 — 차단율 급증 알림·모니터링용."""
|
||||||
|
sql = text("SELECT count(*) FROM bot_detection WHERE created_at > now() - make_interval(mins => :m)")
|
||||||
|
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
|
||||||
|
try:
|
||||||
|
return int((await s.execute(sql, {"m": minutes})).scalar() or 0)
|
||||||
|
finally:
|
||||||
|
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
|
||||||
|
|||||||
@ -200,3 +200,33 @@ class JobQueue:
|
|||||||
return {js.name: by_val.get(js.value, 0) for js in JobStatus}
|
return {js.name: by_val.get(js.value, 0) for js in JobStatus}
|
||||||
|
|
||||||
return await self._tx(run)
|
return await self._tx(run)
|
||||||
|
|
||||||
|
async def ops(self) -> dict:
|
||||||
|
"""운영 스냅샷(모니터링·알림용): 상태별 카운트 + 큐 지연(가장 오래된 PENDING 나이) +
|
||||||
|
최근 1시간 DEAD + lease 만료 stuck(reaper 가 회수 못한 좀비 신호)."""
|
||||||
|
sql = text("""
|
||||||
|
SELECT
|
||||||
|
count(*) FILTER (WHERE status = 1) AS pending,
|
||||||
|
count(*) FILTER (WHERE status = 2) AS running,
|
||||||
|
count(*) FILTER (WHERE status = 3) AS done,
|
||||||
|
count(*) FILTER (WHERE status = 4) AS dead,
|
||||||
|
count(*) FILTER (WHERE status = 4 AND updated_at > now() - interval '1 hour') AS dead_1h,
|
||||||
|
count(*) FILTER (WHERE status = 2 AND lease_until IS NOT NULL AND lease_until < now()) AS stuck_running,
|
||||||
|
COALESCE(EXTRACT(EPOCH FROM (now() - min(created_at) FILTER (WHERE status = 1)))::int, 0) AS oldest_pending_sec
|
||||||
|
FROM job
|
||||||
|
""")
|
||||||
|
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
|
||||||
|
try:
|
||||||
|
row = (await s.execute(sql)).mappings().first()
|
||||||
|
return {k: int(v) for k, v in dict(row).items()}
|
||||||
|
finally:
|
||||||
|
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
|
||||||
|
|
||||||
|
async def ping(self) -> bool:
|
||||||
|
"""DB 도달성 확인(readiness). 실패 시 예외."""
|
||||||
|
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
|
||||||
|
try:
|
||||||
|
await s.execute(text("SELECT 1"))
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
|
||||||
|
|||||||
@ -50,13 +50,29 @@ async def log_time(request: Request, call_next):
|
|||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
path="/healthz",
|
path="/healthz",
|
||||||
summary="헬스체크",
|
summary="헬스체크(liveness)",
|
||||||
description="서버 기동 시각(API_SERVER_START_TIME)을 반환하는 헬스체크 엔드포인트.",
|
description="서버 기동 시각을 반환. 프로세스가 살아있는지만 확인(DB 무관).",
|
||||||
responses={404: {"description": "Not found"}},
|
responses={404: {"description": "Not found"}},
|
||||||
)
|
)
|
||||||
async def healthz():
|
async def healthz():
|
||||||
return API_SERVER_START_TIME
|
return API_SERVER_START_TIME
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
path="/readyz",
|
||||||
|
summary="레디니스(readiness)",
|
||||||
|
description="DB 도달성까지 확인. 오케스트레이터/LB 가 트래픽 라우팅 여부 판단에 사용. 실패 시 503.",
|
||||||
|
)
|
||||||
|
async def readyz():
|
||||||
|
from fastapi import Response
|
||||||
|
from crud.job_crud import JobQueue
|
||||||
|
try:
|
||||||
|
await JobQueue().ping()
|
||||||
|
return {"ready": True}
|
||||||
|
except Exception as ex:
|
||||||
|
return Response(content=f'{{"ready": false, "error": "{type(ex).__name__}"}}',
|
||||||
|
media_type="application/json", status_code=503)
|
||||||
|
|
||||||
|
|
||||||
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
||||||
app.include_router(router.v1.lps.search.router)
|
app.include_router(router.v1.lps.search.router)
|
||||||
|
|||||||
@ -41,6 +41,16 @@ async def queue_stats(service: LpsService = Depends()):
|
|||||||
return RemoveNoneResponse(await service.stats())
|
return RemoveNoneResponse(await service.stats())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
path="/ops",
|
||||||
|
summary="운영 스냅샷(모니터링)",
|
||||||
|
description="큐 카운트 + 큐 지연(oldest_pending_sec) + 최근1h DEAD(dead_1h) + lease만료 stuck + "
|
||||||
|
"최근1h 차단(blocks_1h). 외부 모니터가 스크랩·임계 알림하기 좋은 플랫 JSON.",
|
||||||
|
)
|
||||||
|
async def ops(service: LpsService = Depends()):
|
||||||
|
return await service.ops()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
path="/products/{product_code}/history",
|
path="/products/{product_code}/history",
|
||||||
response_model=Res_PriceHistory,
|
response_model=Res_PriceHistory,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from fastapi import Depends
|
|||||||
from common.enums import ErrorType, JobStatus, JobType
|
from common.enums import ErrorType, JobStatus, JobType
|
||||||
from crud.job_crud import JobQueue
|
from crud.job_crud import JobQueue
|
||||||
from crud.price_history import PriceHistory
|
from crud.price_history import PriceHistory
|
||||||
|
from crud.bot_detection import BotDetectionLog
|
||||||
from router.v1.lps.protocol import (
|
from router.v1.lps.protocol import (
|
||||||
EnqueuedItem,
|
EnqueuedItem,
|
||||||
PricePoint,
|
PricePoint,
|
||||||
@ -26,9 +27,11 @@ _PRIORITY = {"new_product": 1, "manual": 2, "partner": 3, "batch": 4}
|
|||||||
|
|
||||||
|
|
||||||
class LpsService:
|
class LpsService:
|
||||||
def __init__(self, queue: JobQueue = Depends(JobQueue), history: PriceHistory = Depends(PriceHistory)):
|
def __init__(self, queue: JobQueue = Depends(JobQueue), history: PriceHistory = Depends(PriceHistory),
|
||||||
|
bot_log: BotDetectionLog = Depends(BotDetectionLog)):
|
||||||
self.queue = queue
|
self.queue = queue
|
||||||
self.history = history
|
self.history = history
|
||||||
|
self.bot_log = bot_log
|
||||||
|
|
||||||
async def submit_search(self, items: list[SearchItem]) -> Res_Search:
|
async def submit_search(self, items: list[SearchItem]) -> Res_Search:
|
||||||
res = Res_Search()
|
res = Res_Search()
|
||||||
@ -70,6 +73,12 @@ class LpsService:
|
|||||||
res.counts = await self.queue.counts()
|
res.counts = await self.queue.counts()
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
async def ops(self) -> dict:
|
||||||
|
"""운영 스냅샷(모니터링·알림용, 플랫 JSON): 큐 카운트·지연·최근 DEAD·최근 차단 수."""
|
||||||
|
snap = await self.queue.ops()
|
||||||
|
snap["blocks_1h"] = await self.bot_log.recent_count(60)
|
||||||
|
return snap
|
||||||
|
|
||||||
async def price_history(self, product_code: str, limit: int = 100) -> Res_PriceHistory:
|
async def price_history(self, product_code: str, limit: int = 100) -> Res_PriceHistory:
|
||||||
res = Res_PriceHistory(product_code=product_code)
|
res = Res_PriceHistory(product_code=product_code)
|
||||||
rows = await self.history.list_by_product(product_code, limit)
|
rows = await self.history.list_by_product(product_code, limit)
|
||||||
|
|||||||
@ -77,3 +77,18 @@ async def test_queue_stats(client, clean_jobs):
|
|||||||
r = await client.get("/v1/lps/queue/stats")
|
r = await client.get("/v1/lps/queue/stats")
|
||||||
counts = r.json()["counts"]
|
counts = r.json()["counts"]
|
||||||
assert counts["PENDING"] == 2 and counts["DONE"] == 0 and counts["DEAD"] == 0
|
assert counts["PENDING"] == 2 and counts["DONE"] == 0 and counts["DEAD"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_readyz(client):
|
||||||
|
r = await client.get("/readyz") # DB 도달 → ready
|
||||||
|
assert r.status_code == 200 and r.json()["ready"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ops_snapshot(client, clean_jobs):
|
||||||
|
await client.post("/v1/lps/search", json={"data": [{"product_code": "A", "product_name": "x"}]})
|
||||||
|
r = await client.get("/v1/lps/ops")
|
||||||
|
assert r.status_code == 200
|
||||||
|
j = r.json()
|
||||||
|
for k in ("pending", "running", "done", "dead", "dead_1h", "stuck_running", "oldest_pending_sec", "blocks_1h"):
|
||||||
|
assert k in j and isinstance(j[k], int)
|
||||||
|
assert j["pending"] == 1
|
||||||
|
|||||||
@ -7,6 +7,9 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from config.server_configs import web_server_config, openai_config, decodo_config
|
from config.server_configs import web_server_config, openai_config, decodo_config
|
||||||
@ -86,6 +89,50 @@ async def _warmup_worker(worker_adapters, tries: int = 3):
|
|||||||
LOG.w(f"[warmup:{ad.source}] {tries}회 실패(첫 잡에서 재시도): {type(ex).__name__}")
|
LOG.w(f"[warmup:{ad.source}] {tries}회 실패(첫 잡에서 재시도): {type(ex).__name__}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _post_webhook(url: str, text: str, snap: dict):
|
||||||
|
"""Slack 호환 웹훅으로 알림 전송(있을 때만). 실패는 무시."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as c:
|
||||||
|
await c.post(url, json={"text": f":rotating_light: LPS {text}\n```{snap}```"})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def run_ops_monitor(queue, bot_log, stop, interval: float = 30.0):
|
||||||
|
"""워커 헬스 하트비트 + 임계 알림. 주기적으로 (1) 하트비트 파일 갱신(Docker HEALTHCHECK 가
|
||||||
|
행/좀비 워커 감지) (2) 큐/차단 지표 점검 → 임계 초과 시 WARN 로그 + (env 있으면) 웹훅 알림."""
|
||||||
|
hb_path = os.environ.get("LPS_HEARTBEAT_FILE", "/tmp/lps_worker_heartbeat")
|
||||||
|
webhook = os.environ.get("LPS_ALERT_WEBHOOK")
|
||||||
|
th_dead = int(os.environ.get("LPS_ALERT_DEAD_1H", "20"))
|
||||||
|
th_blocks = int(os.environ.get("LPS_ALERT_BLOCKS_1H", "80"))
|
||||||
|
th_lag = int(os.environ.get("LPS_ALERT_QUEUE_LAG_SEC", "300"))
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
with open(hb_path, "w") as f:
|
||||||
|
f.write(str(int(time.time()))) # 하트비트(mtime) — HEALTHCHECK 가 신선도 확인
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
snap = await queue.ops()
|
||||||
|
snap["blocks_1h"] = await bot_log.recent_count(60)
|
||||||
|
alerts = []
|
||||||
|
if snap["dead_1h"] >= th_dead: alerts.append(f"DEAD 1h={snap['dead_1h']}")
|
||||||
|
if snap["blocks_1h"] >= th_blocks: alerts.append(f"차단 1h={snap['blocks_1h']}")
|
||||||
|
if snap["oldest_pending_sec"] >= th_lag: alerts.append(f"큐지연={snap['oldest_pending_sec']}s")
|
||||||
|
if snap["stuck_running"] > 0: alerts.append(f"stuck={snap['stuck_running']}")
|
||||||
|
if alerts:
|
||||||
|
msg = "[ops-alert] " + " · ".join(alerts)
|
||||||
|
LOG.w(msg)
|
||||||
|
if webhook:
|
||||||
|
await _post_webhook(webhook, msg, snap)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(f"[ops-monitor] {type(ex).__name__}: {ex}")
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(stop.wait(), timeout=interval)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def run_browser_reaper(adapters, stop, idle_sec: float = 120.0, interval: float = 30.0):
|
async def run_browser_reaper(adapters, stop, idle_sec: float = 120.0, interval: float = 30.0):
|
||||||
"""유휴 브라우저 정리 루프 — 일정 시간 검색 없는 어댑터의 Chrome 을 닫아 메모리를 회수한다.
|
"""유휴 브라우저 정리 루프 — 일정 시간 검색 없는 어댑터의 Chrome 을 닫아 메모리를 회수한다.
|
||||||
쿠키는 user_data_dir 에 남아, 다음 검색 때 재기동해도 (같은 IP면) 웜 유지."""
|
쿠키는 user_data_dir 에 남아, 다음 검색 때 재기동해도 (같은 IP면) 웜 유지."""
|
||||||
@ -133,7 +180,8 @@ async def main(concurrency: int = 1):
|
|||||||
|
|
||||||
tasks.append(asyncio.create_task(run_reaper(queue, stop)))
|
tasks.append(asyncio.create_task(run_reaper(queue, stop)))
|
||||||
tasks.append(asyncio.create_task(run_browser_reaper(all_adapters, stop))) # 유휴 브라우저 정리
|
tasks.append(asyncio.create_task(run_browser_reaper(all_adapters, stop))) # 유휴 브라우저 정리
|
||||||
LOG.i(f"LPS 워커 {concurrency}개 + reaper + 브라우저정리(유휴 120s) 기동 (워커별 세트 · 상품 {concurrency}개 동시)")
|
tasks.append(asyncio.create_task(run_ops_monitor(queue, BotDetectionLog(), stop))) # 하트비트 + 임계 알림
|
||||||
|
LOG.i(f"LPS 워커 {concurrency}개 + reaper + 브라우저정리 + ops모니터(하트비트/알림) 기동 (워커별 세트 · 상품 {concurrency}개 동시)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(*tasks)
|
await asyncio.gather(*tasks)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user