설정이 .env(compose 주입)·config.toml·코드 곳곳의 os.environ 직독 3계층에 흩어져 관리가 어려웠다. TOML 하나로 통합한다(협의 결정). - 신설 [WorkerConfig](동시성·폴백·프로필·데드라인·유예·Chrome·하트비트), [AlertConfig](웹훅·쿨다운·임계 10종). [WebServerConfig].api_keys(guard), [DecodoConfig].ip_request_budget/port_cooldown_sec 추가 — 흩어져 있던 LPS_* env 20여 개를 섹션으로 흡수. - server_configs 의 env override 계층(DB_*·시크릿·NAVER_KEYS 등) 삭제. 남는 env 는 APP_ENV(부트스트랩)·PROCESS_COUNT/WORKER_CONCURRENCY(실행 스크립트 대화형 입력 전용)·LPS_LIVE(테스트 옵트인)뿐. - Docker: env 주입 → config.docker.toml 마운트 + APP_ENV=docker. 이미지 무시크릿 유지, 마운트 누락 시 FileNotFoundError 즉시 실패. .env.example 삭제, config.docker.toml.example 신설. - negodata 호출부: guard 키를 env 직독에서 [WebServerConfig].lps_api_key (+기존 관례대로 env override)로 이동. - 실행 스크립트: 프로필·폴백·예산 프롬프트 제거(toml 소스 안내), 동시성/프로세스 수만 임시 override 로 유지. - docs 7종·example toml 의 env 표기를 toml 키로 일괄 갱신. - 전체 145 passed + APP_ENV=docker 로딩·API 기동 스모크 확인. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
81 lines
3.9 KiB
Python
81 lines
3.9 KiB
Python
"""임계 알림 관리자 — 쿨다운(스팸 방지)·회복 알림. 워커(ops-monitor)·API(풀 모니터) 공용.
|
|
|
|
기존 방식(임계 초과 시 매 틱 웹훅)은 조건이 지속되면 30초마다 같은 알림이 반복 발송됐다.
|
|
AlertManager 는 룰 키별로 상태를 관리한다:
|
|
발화: 비활성→활성 전환 시 1회 + 이후 쿨다운([AlertConfig].cooldown_min, 기본 30분)마다 리마인드
|
|
회복: 활성→비활성 전환 시 '해소' 알림 1회
|
|
채널: WARN/INFO 로그(항상) + Slack 호환 웹훅([AlertConfig].webhook 있을 때만, 실패 무시).
|
|
sender/clock 주입으로 네트워크·시간 없이 단위 테스트 가능.
|
|
"""
|
|
|
|
import time
|
|
|
|
import httpx
|
|
|
|
from common.logger import LOG
|
|
from config.server_configs import alert_config
|
|
|
|
|
|
class AlertManager:
|
|
def __init__(self, origin: str = "worker", webhook: str | None = None,
|
|
cooldown_sec: float | None = None, sender=None, clock=time.monotonic):
|
|
self.origin = origin # 알림 출처(worker/api) — 메시지에 표기
|
|
self._webhook = webhook if webhook is not None else alert_config.webhook
|
|
self._cooldown = cooldown_sec if cooldown_sec is not None else alert_config.cooldown_min * 60
|
|
self._sender = sender # async def(text: str) — 테스트 주입용(없으면 웹훅)
|
|
self._clock = clock
|
|
self._state: dict[str, dict] = {} # key → {"active": bool, "last_sent": float}
|
|
|
|
async def check(self, key: str, active: bool, message: str, snap: dict | None = None):
|
|
"""룰 1개 평가. active 가 True 로 지속돼도 쿨다운 안에는 재발송하지 않는다."""
|
|
st = self._state.setdefault(key, {"active": False, "last_sent": 0.0})
|
|
now = self._clock()
|
|
if active:
|
|
due = (not st["active"]) or (now - st["last_sent"] >= self._cooldown)
|
|
st["active"] = True
|
|
if due:
|
|
st["last_sent"] = now
|
|
LOG.w(f"[alert:{key}] {message}")
|
|
await self._send(f":rotating_light: LPS({self.origin}) [{key}] {message}", snap)
|
|
elif st["active"]:
|
|
st["active"] = False
|
|
LOG.i(f"[alert:{key}] 해소 — {message}")
|
|
await self._send(f":white_check_mark: LPS({self.origin}) [{key}] 해소 — {message}", snap)
|
|
|
|
def is_active(self, key: str) -> bool:
|
|
return self._state.get(key, {}).get("active", False)
|
|
|
|
async def _send(self, text: str, snap: dict | None):
|
|
if self._sender is not None:
|
|
await self._sender(text)
|
|
return
|
|
if not self._webhook:
|
|
return
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5) as c:
|
|
await c.post(self._webhook, json={"text": text + (f"\n```{snap}```" if snap else "")})
|
|
except Exception:
|
|
pass # 알림 실패가 모니터 루프를 막지 않는다
|
|
|
|
|
|
async def run_pool_monitor(stop, interval: float = 60.0, alerts: AlertManager | None = None):
|
|
"""DB 커넥션 풀 포화 감시 루프 — API 프로세스용 경량 모니터(lifespan 에서 기동).
|
|
대량 폴링으로 풀을 고갈시키는 주범이 API 자신일 수 있어, 워커 ops-monitor 와 별도로 감시한다."""
|
|
import asyncio
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
|
|
alerts = alerts or AlertManager(origin="api")
|
|
threshold = alert_config.pool_pct
|
|
while not stop.is_set():
|
|
try:
|
|
st = DB_SESSION_MNG.pool_status()
|
|
await alerts.check("db_pool", st["pct"] >= threshold,
|
|
f"DB 풀 포화 {st['pct']}% (checked_out {st['checked_out']}/{st['capacity']})", st)
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(f"[pool-monitor] {type(ex).__name__}: {ex}")
|
|
try:
|
|
await asyncio.wait_for(stop.wait(), timeout=interval)
|
|
except asyncio.TimeoutError:
|
|
pass
|