From c81df5bd883d5a86d90bd53eb44abc58b6c16678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Mon, 13 Jul 2026 21:11:31 +0900 Subject: [PATCH] =?UTF-8?q?refactor(lps):=20=EC=84=A4=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?TOML=20=EB=8B=A8=EC=9D=BC=20=EC=86=8C=EC=8A=A4=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=E2=80=94=20env/.env=20=EC=9D=B4=EC=A4=91?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정이 .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 --- .env.example | 24 ------ docker-compose.yml | 47 +++-------- lps/README.md | 4 +- lps/common/alerts.py | 13 ++- lps/config/config.docker.toml.example | 80 +++++++++++++++++++ lps/config/config.local.toml.example | 51 +++++++++--- lps/config/config_models.py | 37 +++++++++ lps/config/server_configs.py | 80 ++++--------------- lps/docs/api.md | 6 +- lps/docs/architecture.md | 10 +-- lps/docs/database.md | 2 +- lps/docs/decision-openmarket-crawler.md | 4 +- lps/docs/operations.md | 68 ++++++++-------- lps/loadtest/README.md | 2 +- lps/router/router.py | 4 +- lps/router/v1/validator/auth.py | 15 ++-- lps/run_local_server.sh | 14 ++-- lps/run_local_worker.sh | 22 ++--- lps/services/search/browser_base.py | 16 ++-- lps/services/search/proxy.py | 8 +- lps/tests/test_api_guard.py | 12 +-- lps/worker_main.py | 56 ++++++------- negodata/backend/config/config_models.py | 1 + negodata/backend/config/server_configs.py | 3 + negodata/backend/services/lps_sync_service.py | 5 +- 25 files changed, 308 insertions(+), 276 deletions(-) delete mode 100644 .env.example create mode 100644 lps/config/config.docker.toml.example diff --git a/.env.example b/.env.example deleted file mode 100644 index 8db4749..0000000 --- a/.env.example +++ /dev/null @@ -1,24 +0,0 @@ -# docker compose 용 환경변수 템플릿 — 복사해서 사용: cp .env.example .env -# 실제 값(.env)은 커밋하지 않는다(.gitignore). 이미지에는 시크릿이 없으므로(lps 는 example -# config 로 빌드) 아래 값이 없으면 해당 기능이 꺼진 채 뜬다(주석 참고). - -# ── LPS DB (미설정 시 postgres/postgres) ── -LPS_DB_USER=postgres -LPS_DB_PASSWORD=postgres - -# ── LPS API guard (개발은 빈값=개방 모드, prod 만 키 주입 — lps-api 검증·negodata 헤더 첨부 공용) ── -LPS_API_KEY= # 콤마 구분 복수 허용(무중단 키 교체). 생성 예: openssl rand -hex 32 - -# ── LPS API 스케일 (미설정 시 1 / 40 — 단일 프로세스로도 ~1,100 RPS) ── -LPS_API_PROCESS_COUNT=1 # uvicorn 프로세스 수(=사용 코어 수). 커넥션 풀은 예산에서 자동 역산 -LPS_DB_CONNECTION_BUDGET=40 # lps API 커넥션 총예산. 공유 PG=40, 전용 PG(max_conn≈100)=90 - -# ── LPS 워커 시크릿 ── -OPENAI_API_KEY= # 비면 AI 유사도 판정 OFF -NAVER_KEYS= # "id1:secret1,id2:secret2" — 네이버 쇼핑 오픈API 키(여러 개면 로테이션) -DECODO_HOST= # 예: gate.decodo.com — DECODO 4종이 비면 프록시 미사용(직접 연결) -DECODO_USERNAME= -DECODO_PASSWORD= -DECODO_PORT_START=0 # 예: 10001 -DECODO_PORT_END=0 # 예: 10010 -DECODO_COST_PER_GB=0 # 요금($/GB) — 검색 원가 계측용(예: 3.0) diff --git a/docker-compose.yml b/docker-compose.yml index 839bc0f..272dca7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,7 +41,7 @@ services: # ── LPS(인터넷 최저가) 연동 — 미설정이면 연동 비활성으로 조용히 동작 ── LPS_DB_HOST: host.docker.internal # lps_db 읽기전용(수집 배치·조회 API) LPS_BASE_URL: http://host.docker.internal:9600 # 검색요청 enqueue. lps-api 컨테이너 사용 시 http://lps-api:9600 - LPS_API_KEY: ${LPS_API_KEY:-} # LPS API guard 키 — 개발은 빈값(개방), prod 만 주입 + # LPS API guard 키는 negodata 의 config.local.toml [WebServerConfig].lps_api_key 로 관리(개발은 빈값=개방) volumes: - ./negodata/backend:/app # 호스트 소스 = 컨테이너 코드. 이게 있어야 수정이 즉시 반영됨 ports: @@ -121,25 +121,20 @@ services: # ── LPS (인터넷 최저가 검색) ────────────────────────────────── # API(요청 접수, lean) + 워커(크롤, 헤드풀 Chromium+Xvfb). DB 는 외부(host.docker.internal). # 이미지엔 시크릿이 없다(example config 로 빌드) — 실값은 아래 env 로 주입. - # 시크릿 값은 리포 루트 .env 파일에 채운다(.env.example 참고, .env 는 미커밋). + # ── 설정은 TOML 하나(2026-07-13 협의 — .env/env 주입 제거) ── + # ./lps/config/config.docker.toml(미커밋, example 복사 후 값 채움)을 마운트하고 APP_ENV=docker 로 읽는다. + # 이미지에는 시크릿이 없고, 마운트를 잊으면 기동 시 FileNotFoundError 로 즉시 실패한다. + # 값 변경 = toml 수정 + docker compose restart (스케일·알림·guard 키 전부 toml 섹션에서). lps-api: build: context: ./lps dockerfile: Dockerfile container_name: lps-api environment: - APP_ENV: local - DB_HOST: host.docker.internal # 컨테이너→호스트 DB (example toml 의 127.0.0.1 override) - DB_USER: ${LPS_DB_USER:-postgres} - DB_PASSWORD: ${LPS_DB_PASSWORD:-postgres} + APP_ENV: docker PYTHONUNBUFFERED: "1" - # 멀티코어: PROCESS_COUNT(uvicorn 워커=코어수)를 올리면 커넥션 풀은 자동 산정된다. - # (pool+overflow)×2엔진×PROCESS_COUNT ≤ DB_CONNECTION_BUDGET 를 config 가 스스로 보장. - # 값은 .env 에서 서버별로 조정(compose 수정 불필요). API 병목은 드묾 — 기본 1이면 충분, - # 부하테스트/대량 폴링 대비 시에만 코어 수만큼 상향(예: 4). - PROCESS_COUNT: ${LPS_API_PROCESS_COUNT:-1} - DB_CONNECTION_BUDGET: ${LPS_DB_CONNECTION_BUDGET:-40} # 전용 PG(max_connections≈100)면 90 근처로 상향 - LPS_API_KEY: ${LPS_API_KEY:-} # API guard — 빈값=개방 모드(개발), prod 는 키 주입 + 포트 비공개(operations.md) + volumes: + - ./lps/config/config.docker.toml:/app/config/config.docker.toml:ro ports: - "9600:9600" extra_hosts: @@ -157,33 +152,15 @@ services: dockerfile: Dockerfile.worker # Chromium + Xvfb (headless 는 안티봇에 탐지됨) container_name: lps-worker environment: - APP_ENV: local - DB_HOST: host.docker.internal - DB_USER: ${LPS_DB_USER:-postgres} - DB_PASSWORD: ${LPS_DB_PASSWORD:-postgres} + APP_ENV: docker # → 마운트된 config.docker.toml 사용(동시성·폴백·예산·알림 전부 toml) PYTHONUNBUFFERED: "1" - WORKER_CONCURRENCY: "1" # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 4×N) - LPS_PROFILE_DIR: /profiles # Chrome 프로필을 영속 볼륨에 → 재시작해도 cf_clearance 유지(재웜업 회피) - # LPS_FALLBACKS: "gmarket,auction,st11" # 오픈마켓 폴백(기본 OFF — 켜기 전 라이브 스모크로 셀렉터 점검) - # LPS_JOB_DEADLINE_SEC: "300" # 잡 1건 처리 상한(행 방어) — 기본 300s - # LPS_IP_REQUEST_BUDGET: "3" # IP당 요청 예산 — 도달 시 차단 전 선제 회전(0=비활성). 기본 3 - # LPS_PORT_COOLDOWN_SEC: "1800" # 차단 감지된 프록시 포트 격리 시간 — 기본 max(sticky, 30분) - # LPS_ALERT_WEBHOOK: "" # Slack 호환 웹훅 — 있으면 임계 알림 전송(룰·임계는 lps/docs/operations.md) - # ── 시크릿 주입(이미지엔 없음 — 필수). 리포 루트 .env 에 값 채움(.env.example 참고) ── - OPENAI_API_KEY: ${OPENAI_API_KEY:-} # 비면 AI 판정 OFF - NAVER_KEYS: ${NAVER_KEYS:-} # "id1:secret1,id2:secret2" — 비면 네이버 검색 실패 - DECODO_HOST: ${DECODO_HOST:-} # DECODO 4종 비면 프록시 미사용(직접 연결) - DECODO_USERNAME: ${DECODO_USERNAME:-} - DECODO_PASSWORD: ${DECODO_PASSWORD:-} - DECODO_PORT_START: ${DECODO_PORT_START:-0} - DECODO_PORT_END: ${DECODO_PORT_END:-0} - DECODO_COST_PER_GB: ${DECODO_COST_PER_GB:-0} volumes: - - lps-profiles:/profiles # Chrome 프로필(쿠키) 영속 + - ./lps/config/config.docker.toml:/app/config/config.docker.toml:ro + - lps-profiles:/profiles # Chrome 프로필(쿠키) 영속 — [WorkerConfig].profile_dir=/profiles extra_hosts: - "host.docker.internal:host-gateway" shm_size: "1gb" # Chrome 는 /dev/shm 을 많이 씀 — 부족하면 탭 크래시 - stop_grace_period: 75s # graceful 종료 유예(LPS_SHUTDOWN_GRACE_SEC=60 + 정리 여유) — 기본 10s 면 하던 잡 마무리 전에 SIGKILL + stop_grace_period: 75s # graceful 종료 유예([WorkerConfig].shutdown_grace_sec=60 + 정리 여유) — 기본 10s 면 하던 잡 마무리 전에 SIGKILL labels: autoheal: "true" # 하트비트 HEALTHCHECK 실패(행/좀비) 시 autoheal 이 재시작 restart: unless-stopped diff --git a/lps/README.md b/lps/README.md index 0af9c14..3b2f979 100644 --- a/lps/README.md +++ b/lps/README.md @@ -49,7 +49,7 @@ | 기능 | 설명 | |------|------| | 멀티 소스 검색 | 네이버 쇼핑 API + 쿠팡(Akamai 우회) 동시 검색·병합 | -| 오픈마켓 폴백 크롤 | 네이버가 못 덮은 몰만 G마켓·옥션(Cloudflare Turnstile 우회)·11번가 크롤 → 몰별 가격. **기본 비활성**(`LPS_FALLBACKS`, [배경](docs/decision-openmarket-crawler.md)) | +| 오픈마켓 폴백 크롤 | 네이버가 못 덮은 몰만 G마켓·옥션(Cloudflare Turnstile 우회)·11번가 크롤 → 몰별 가격. **기본 비활성**(`[WorkerConfig].fallbacks`, [배경](docs/decision-openmarket-crawler.md)) | | AI 같은 상품 판정 | "진짜 그 상품"만 선별 (액세서리·다른 규격 제외) | | 검색어 자동 정제 | 0건이면 정밀/광역 검색어로 재시도 | | 최저가 이력 그래프 | 조회 시점마다 네이버/쿠팡/최종 + 몰별(by_mall) 최저가를 시계열로 기록 | @@ -58,7 +58,7 @@ | 안정적 큐 처리 | 작업 유실 없이 순서대로, 실패 시 자동 재시도 | | 프록시 IP 선제 회전 | 요청 예산(기본 3회) 도달 시 **차단 전 선제 교체** + 불탄 포트 쿨다운 + 봇 감지·전송오류 즉시 순환 + 시작 웜업(DECODO). 예산 튜닝용 `ip_session` 관측 로그 | | 임계 알림 | 큐·차단·DB풀·소스별 장기실패·비용 등 10룰 — 쿨다운(스팸 방지)·해소 알림, Slack 웹훅([룰 표](docs/operations.md)) | -| API guard | `LPS_API_KEY` 설정 시 `/v1` 전체 X-API-Key 검증(개발은 미설정=개방 모드) | +| API guard | `[WebServerConfig].api_keys` 설정 시 `/v1` 전체 X-API-Key 검증(개발은 빈값=개방 모드) | --- diff --git a/lps/common/alerts.py b/lps/common/alerts.py index 6df38fa..ed04375 100644 --- a/lps/common/alerts.py +++ b/lps/common/alerts.py @@ -2,27 +2,26 @@ 기존 방식(임계 초과 시 매 틱 웹훅)은 조건이 지속되면 30초마다 같은 알림이 반복 발송됐다. AlertManager 는 룰 키별로 상태를 관리한다: - 발화: 비활성→활성 전환 시 1회 + 이후 쿨다운(LPS_ALERT_COOLDOWN_MIN, 기본 30분)마다 리마인드 + 발화: 비활성→활성 전환 시 1회 + 이후 쿨다운([AlertConfig].cooldown_min, 기본 30분)마다 리마인드 회복: 활성→비활성 전환 시 '해소' 알림 1회 -채널: WARN/INFO 로그(항상) + Slack 호환 웹훅(LPS_ALERT_WEBHOOK 있을 때만, 실패 무시). +채널: WARN/INFO 로그(항상) + Slack 호환 웹훅([AlertConfig].webhook 있을 때만, 실패 무시). sender/clock 주입으로 네트워크·시간 없이 단위 테스트 가능. """ -import os 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 os.environ.get("LPS_ALERT_WEBHOOK") - self._cooldown = cooldown_sec if cooldown_sec is not None \ - else int(os.environ.get("LPS_ALERT_COOLDOWN_MIN", "30")) * 60 + 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} @@ -67,7 +66,7 @@ async def run_pool_monitor(stop, interval: float = 60.0, alerts: AlertManager | from common.database.db_session_manager import DB_SESSION_MNG alerts = alerts or AlertManager(origin="api") - threshold = int(os.environ.get("LPS_ALERT_POOL_PCT", "90")) + threshold = alert_config.pool_pct while not stop.is_set(): try: st = DB_SESSION_MNG.pool_status() diff --git a/lps/config/config.docker.toml.example b/lps/config/config.docker.toml.example new file mode 100644 index 0000000..86e0d86 --- /dev/null +++ b/lps/config/config.docker.toml.example @@ -0,0 +1,80 @@ +# Docker(컨테이너) 실행용 설정 — 복사해서 사용: cp config.docker.toml.example config.docker.toml +# 실제 config.docker.toml 은 시크릿 포함이라 커밋하지 않는다(.gitignore: *.toml). +# +# 사용법: docker-compose 가 이 파일을 컨테이너에 마운트하고 APP_ENV=docker 로 띄운다. +# ./lps/config/config.docker.toml → /app/config/config.docker.toml (ro) +# 이미지에는 시크릿이 없다 — 마운트를 잊으면 기동 시 FileNotFoundError 로 즉시 실패(조용한 오동작 없음). +# local 과의 차이만 주석으로 표시 — 나머지 의미는 config.local.toml.example 참고. + +[WebServerConfig] +server_name = "LpsServer" +port = 9600 +process_count = 1 # API 병목은 드묾(단일로 ~1,100 RPS 실측). 부하 대비 시에만 코어 수만큼 +is_ssl = false +is_test = false +cors_origins = [] +api_keys = [] # prod 는 반드시 채운다(openssl rand -hex 32) + lps-api 포트 비공개 + +[LogConfig] +print_console = true +log_level = "info" + +[MainDBConfig] +db_type = "postgresql" +name = "lps_db" +write_host = "host.docker.internal" # 컨테이너 → 호스트 DB (전용 DB 서버면 그 호스트로) +write_port = 5432 +write_id = "" +write_pw = "" +read_host = "host.docker.internal" +read_port = 5432 +read_id = "" +read_pw = "" +show_log = false +pool_size = 10 +max_overflow = 20 +connection_budget = 40 # 전용 PG(max_connections≈100)면 90 근처로 상향 +sslmode = "" + +[WorkerConfig] +concurrency = 1 +fallbacks = [] +profile_dir = "/profiles" # compose 의 영속 볼륨(lps-profiles) — 재시작에도 cf_clearance 유지 +job_deadline_sec = 300 +shutdown_grace_sec = 60 +chrome_channel = "chrome" +chrome_executable = "/usr/bin/chromium" # 컨테이너는 시스템 chromium + Xvfb(headful) +heartbeat_file = "/tmp/lps_worker_heartbeat" + +[AlertConfig] +webhook = "" # Slack 호환 웹훅 — 채우면 임계 알림 전송(docs/operations.md) +cooldown_min = 30 +dead_1h = 20 +blocks_1h = 80 +queue_lag_sec = 300 +pool_pct = 90 +source_fail_30m = 5 +deadline_1h = 5 +cost_1h_usd = 1.0 +ports_low_pct = 30 +block_sessions_6h = 1 + +[NaverConfig] +[[NaverConfig.keys]] +id = "" +secret = "" + +[OpenAIConfig] +api_key = "" # 비면 AI 판정 OFF +model = "gpt-4o-mini" + +[DecodoConfig] +host = "" # 4종(호스트·계정·포트범위) 비면 프록시 미사용(직접 연결) +username = "" +password = "" +port_start = 0 +port_end = 0 +session_minutes = 10 +cost_per_gb = 0.0 +ip_request_budget = 3 +port_cooldown_sec = 0 diff --git a/lps/config/config.local.toml.example b/lps/config/config.local.toml.example index 469db4d..78dce20 100644 --- a/lps/config/config.local.toml.example +++ b/lps/config/config.local.toml.example @@ -1,14 +1,10 @@ # 복사해서 사용: cp config.local.toml.example config.local.toml # 실제 config.local.toml 은 시크릿 포함이라 커밋하지 않는다(.gitignore: *.toml). -# 모든 서버는 APP_ENV=local 로 띄우며 이 파일을 읽는다. # -# ── 프로덕션: 시크릿을 이미지에 굽지 말고 env 로 주입(server_configs 가 override) ── -# DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAME -# OPENAI_API_KEY / OPENAI_MODEL -# DECODO_HOST / DECODO_USERNAME / DECODO_PASSWORD / DECODO_COST_PER_GB -# NAVER_KEYS="id1:secret1,id2:secret2" -# LPS_PROFILE_DIR=/profiles (Chrome 프로필 영속 볼륨), LPS_CHROME_EXECUTABLE=/usr/bin/chromium -# → 배포 시엔 아래 시크릿 값을 비워두고 위 env 로 채우면 이미지에 시크릿이 안 남는다. +# ── 설정 소스는 TOML 하나다(2026-07-13 협의 — env/.env 이중 관리 제거) ── +# 호스트 실행: APP_ENV=local(기본) → 이 파일 +# Docker : APP_ENV=docker → config.docker.toml 을 컨테이너에 마운트(config.docker.toml.example 참고) +# env 는 APP_ENV·PROCESS_COUNT/WORKER_CONCURRENCY(실행 스크립트 대화형 입력)·LPS_LIVE(테스트)만 남는다. [WebServerConfig] server_name = "LpsServer" port = 9600 @@ -17,13 +13,15 @@ is_ssl = false is_test = true # CORS 허용 오리진(프론트). 비우면 [] (CORS 미적용). 5173=vite dev. cors_origins = ["http://localhost:5173", "http://127.0.0.1:5173"] +# API guard 키 — 비우면 개방 모드(개발). prod 는 채운다(복수 등록 = 무중단 키 교체). +# 생성 예: openssl rand -hex 32. 호출자(negodata)도 같은 키를 설정해야 한다. +api_keys = [] [LogConfig] print_console = true log_level = "debug" -# DB Read/Write 분리. 도커 실행 시 host 는 docker-compose 의 DB_HOST 로 override. -# 관리형 DB(RDS/Aurora/Azure)는 host 에 엔드포인트, sslmode="require". +# DB Read/Write 분리. 관리형 DB(RDS/Aurora/Azure)는 host 에 엔드포인트, sslmode="require". # LPS 도메인 로직/테이블이 생기기 전까지는 접속하지 않으므로(엔진 lazy) placeholder 여도 부팅된다. [MainDBConfig] db_type = "postgresql" @@ -43,10 +41,35 @@ max_overflow = 20 # 〃 # (pool+overflow) × 2엔진 × process_count ≤ connection_budget. # 'lps API 가 쓸 총 커넥션 상한' — 공유 PG(max_connections)·동거 서비스(worker 등)를 고려한 값. # 예) 전용 PG(max_connections=100)면 90 근처, 공유 PG면 40 권장. 0 이면 자동 끔(위 pool 값 사용). -connection_budget = 40 # env DB_CONNECTION_BUDGET 로 override +connection_budget = 40 sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"verify-full" -# ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). 배포는 이 파일 마운트 권장. ── +# 워커 런타임 (worker_main.py). 동시성만 실행 시 WORKER_CONCURRENCY env 로 임시 override 가능. +[WorkerConfig] +concurrency = 1 # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 최대 4×N). 로컬 권장 2~3 +fallbacks = [] # 오픈마켓 폴백(기본 OFF). 예: ["gmarket", "auction", "st11"] — 켜기 전 라이브 스모크 +profile_dir = ".profiles" # Chrome 프로필 베이스. 영속 경로면 재시작에도 cf_clearance 유지(재웜업 회피) +job_deadline_sec = 300 # 잡 1건 처리 상한(크롤 행 방어). 0=무제한(테스트용) +shutdown_grace_sec = 60 # graceful 종료 유예 — docker stop_grace_period 를 이보다 길게 +chrome_channel = "chrome" # 로컬: 실제 Chrome +chrome_executable = "" # 컨테이너: "/usr/bin/chromium" (설정 시 channel 무시) +heartbeat_file = "/tmp/lps_worker_heartbeat" # Docker HEALTHCHECK 가 신선도 확인 + +# 임계 알림 (AlertManager — 룰 의미는 docs/operations.md 표) +[AlertConfig] +webhook = "" # Slack 호환 웹훅 URL. 비우면 로그로만 알림 +cooldown_min = 30 # 같은 룰 재발송 억제(분). 해소 알림은 즉시 +dead_1h = 20 # 최근 1h DEAD 잡 수 +blocks_1h = 80 # 최근 1h 봇 감지 수 +queue_lag_sec = 300 # 가장 오래된 PENDING 대기 초 +pool_pct = 90 # DB 커넥션 풀 포화율(%) +source_fail_30m = 5 # 소스별 30분 내 시도 N회 이상 & 성공 0건 +deadline_1h = 5 # 최근 1h 잡 데드라인 강제종료 수 +cost_1h_usd = 1.0 # 최근 1h 검색원가 합($) +ports_low_pct = 30 # 가용 프록시 포트 비율(%) +block_sessions_6h = 1 # 최근 6h '예산 회전에도 차단된' IP 세션 수 + +# ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). ── # 네이버 쇼핑 오픈API (https://developers.naver.com/apps). 여러 개면 429/403 로테이션 자동 포함. [NaverConfig] @@ -69,6 +92,8 @@ host = "" # 예: gate.decodo.com username = "" # 대시보드 USERNAME (예: sppd6a3ze3) password = "" # 대시보드 PASSWORD port_start = 0 # 예: 10001 -port_end = 0 # 예: 10010 +port_end = 0 # 예: 10010 — 포트를 늘리면(계약 변경) 이 범위만 넓히면 됨(코드 무변경) session_minutes = 10 # 대시보드 Sticky 지속시간(분)과 일치 cost_per_gb = 0.0 # DECODO 요금($/GB) — 검색 원가의 대역폭 비용 산정용(플랜에 맞게, 예 3.0) +ip_request_budget = 3 # IP당 요청 예산 — 도달 시 차단 전 선제 회전(0=비활성). 튜닝은 docs/database.md +port_cooldown_sec = 0 # 차단 감지 포트 격리 초. 0=자동 max(sticky, 30분) diff --git a/lps/config/config_models.py b/lps/config/config_models.py index a8b913c..d4eb292 100644 --- a/lps/config/config_models.py +++ b/lps/config/config_models.py @@ -11,6 +11,9 @@ class WebServerConfig(ConfigModel): is_test: bool = False # CORS 허용 오리진(프론트). 비우면 CORS 미적용. 예: ["http://localhost:5173"] cors_origins: list[str] = [] + # API guard 키. 비우면 개방 모드(개발). 채우면 /v1 전체에 X-API-Key 검증(prod). + # 여러 개 등록 가능 — 무중단 키 교체(새 키 추가 → 호출자 전환 → 옛 키 제거). + api_keys: list[str] = [] class LogConfig(ConfigModel): @@ -76,3 +79,37 @@ class DecodoConfig(ConfigModel): port_end: int = 0 session_minutes: int = 10 cost_per_gb: float = 0.0 # DECODO residential 요금($/GB) — 검색 원가의 대역폭 비용 산정용(플랜에 맞게 설정) + # IP(포트 세션)당 요청 예산 — 도달 시 차단당하기 전에 선제 회전(평판 보존). 0=비활성. + # 실측상 5회 부근 차단 이력 → 보수적 3. 튜닝은 ip_session 분석(docs/database.md). + ip_request_budget: int = 3 + # 차단 감지된 포트 격리 시간(초). 0=자동(max(sticky, 30분)) — sticky 만료 후 복귀라 사실상 새 IP. + port_cooldown_sec: int = 0 + + +class WorkerConfig(ConfigModel): + """워커 런타임 설정. (동시성만 실행 시 WORKER_CONCURRENCY env 로 임시 override 가능 — 대화형 스크립트용)""" + + concurrency: int = 1 # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 최대 4×N). 로컬 권장 2~3 + fallbacks: list[str] = [] # 오픈마켓 폴백(기본 비활성). 예: ["gmarket", "auction", "st11"] — 켜기 전 라이브 스모크 + profile_dir: str = "/tmp" # Chrome 프로필 베이스 경로. 영속 볼륨이면 재시작에도 cf_clearance 유지(재웜업 회피) + job_deadline_sec: float = 300 # 잡 1건 처리 상한(크롤 행 방어). 0=무제한(테스트용) + shutdown_grace_sec: float = 60 # graceful 종료 유예 — docker stop_grace_period 를 이보다 길게 + chrome_channel: str = "chrome" # 로컬: 실제 Chrome 채널 + chrome_executable: str = "" # 컨테이너: 시스템 chromium 경로(설정 시 channel 무시, --no-sandbox 적용) + heartbeat_file: str = "/tmp/lps_worker_heartbeat" # 하트비트 파일(Docker HEALTHCHECK 신선도 확인) + + +class AlertConfig(ConfigModel): + """임계 알림(AlertManager) 설정 — 룰 의미는 docs/operations.md 표 참고.""" + + webhook: str = "" # Slack 호환 웹훅 URL. 비우면 로그로만 알림 + cooldown_min: int = 30 # 같은 룰 재발송 억제 시간(분). 해소 알림은 즉시 + dead_1h: int = 20 # 최근 1h DEAD 잡 수 임계 + blocks_1h: int = 80 # 최근 1h 봇 감지 수 임계 + queue_lag_sec: int = 300 # 가장 오래된 PENDING 대기 초 임계 + pool_pct: int = 90 # DB 커넥션 풀 포화율(%) 임계 + source_fail_30m: int = 5 # 소스별 30분 내 시도 N회 이상 & 성공 0건 + deadline_1h: int = 5 # 최근 1h 잡 데드라인 강제종료 수 임계 + cost_1h_usd: float = 1.0 # 최근 1h 검색원가 합($) 임계 + ports_low_pct: int = 30 # 가용 프록시 포트 비율(%) 임계 + block_sessions_6h: int = 1 # 최근 6h '예산 회전에도 차단된' IP 세션 수 임계 diff --git a/lps/config/server_configs.py b/lps/config/server_configs.py index 2e31bb7..600b952 100644 --- a/lps/config/server_configs.py +++ b/lps/config/server_configs.py @@ -2,46 +2,39 @@ import os from config.config_loader import Configs from config.config_models import ( - WebServerConfig, LogConfig, MainDBConfig, NaverConfig, NaverKey, OpenAIConfig, DecodoConfig, + WebServerConfig, LogConfig, MainDBConfig, NaverConfig, OpenAIConfig, DecodoConfig, + WorkerConfig, AlertConfig, ) -# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경. +# ── 설정 소스는 TOML 하나다(2026-07-13 협의 — env/.env 이중 관리 제거). ── +# env 는 부트스트랩·실행 입력만 남는다: +# APP_ENV : 어떤 toml 을 읽을지(local=호스트 실행 / docker=컨테이너에 마운트된 config.docker.toml) +# PROCESS_COUNT : 실행 스크립트·부하벤치의 대화형 입력(uvicorn 워커 수 임시 override) +# WORKER_CONCURRENCY : 워커 실행 스크립트의 대화형 입력(worker_main 이 읽음) +# LPS_LIVE : 라이브 스모크 테스트 옵트인(설정이 아니라 실행 스위치) +# 시크릿 포함 실값은 환경별 config..toml(미커밋)에 두고, Docker 는 파일을 마운트한다. APP_ENV = os.environ.get("APP_ENV", "local") _config_dir = os.path.dirname(__file__) _config_file = os.path.join(_config_dir, f"config.{APP_ENV}.toml") -# 운영 전제: 항상 APP_ENV=local 로 띄운다 → config.local.toml 사용 (test/docker 도 local 로 실행). if not os.path.exists(_config_file): - raise FileNotFoundError(f"설정 파일이 없습니다: {_config_file} (APP_ENV={APP_ENV}). APP_ENV=local 로 실행하세요.") + raise FileNotFoundError( + f"설정 파일이 없습니다: {_config_file} (APP_ENV={APP_ENV}). " + "호스트 실행은 config.local.toml 준비, 컨테이너는 config.docker.toml 마운트를 확인하세요." + ) configs = Configs(_config_file) web_server_config: WebServerConfig = configs.get(WebServerConfig) log_config: LogConfig = configs.get(LogConfig) main_db_config: MainDBConfig = configs.get(MainDBConfig) -# 시크릿 포함 설정도 TOML 로 통합. 섹션이 없으면 기본값(빈/비활성). +# 섹션이 없으면 기본값(빈/비활성)으로 동작. naver_config: NaverConfig = configs.get(NaverConfig) or NaverConfig() openai_config: OpenAIConfig = configs.get(OpenAIConfig) or OpenAIConfig() decodo_config: DecodoConfig = configs.get(DecodoConfig) or DecodoConfig() - - -# DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로. -def _apply_db_env_override(cfg: MainDBConfig): - h = os.environ.get("DB_HOST") - if h: - cfg.write_host = cfg.read_host = h - if os.environ.get("DB_PORT"): - cfg.write_port = cfg.read_port = int(os.environ["DB_PORT"]) - if os.environ.get("DB_USER"): - cfg.write_id = cfg.read_id = os.environ["DB_USER"] - if os.environ.get("DB_PASSWORD"): - cfg.write_pw = cfg.read_pw = os.environ["DB_PASSWORD"] - if os.environ.get("DB_NAME"): - cfg.name = os.environ["DB_NAME"] - # 커넥션 예산 override (자동 산정용). env DB_CONNECTION_BUDGET. - if os.environ.get("DB_CONNECTION_BUDGET"): - cfg.connection_budget = int(os.environ["DB_CONNECTION_BUDGET"]) +worker_config: WorkerConfig = configs.get(WorkerConfig) or WorkerConfig() +alert_config: AlertConfig = configs.get(AlertConfig) or AlertConfig() def _autosize_pool(cfg: MainDBConfig, process_count: int): @@ -66,49 +59,10 @@ def _autosize_pool(cfg: MainDBConfig, process_count: int): return cfg.pool_size, cfg.max_overflow -def _apply_pool_env_override(cfg: MainDBConfig): - """명시적 풀 override — 자동 산정보다 우선(테스트·특수 배포용).""" - if os.environ.get("DB_POOL_SIZE"): - cfg.pool_size = int(os.environ["DB_POOL_SIZE"]) - if os.environ.get("DB_MAX_OVERFLOW"): - cfg.max_overflow = int(os.environ["DB_MAX_OVERFLOW"]) - - -# 시크릿 env override — 프로덕션에선 API 키를 이미지에 굽지 않고 env(또는 시크릿매니저)로 주입한다. -# 로컬은 env 미설정 → config.local.toml 값 그대로. (배포 시 toml 의 시크릿은 비워두고 아래 env 로 주입 권장) -def _apply_secret_env_override(): - if os.environ.get("OPENAI_API_KEY"): - openai_config.api_key = os.environ["OPENAI_API_KEY"] - if os.environ.get("OPENAI_MODEL"): - openai_config.model = os.environ["OPENAI_MODEL"] - for k in ("host", "username", "password"): - v = os.environ.get(f"DECODO_{k.upper()}") - if v: - setattr(decodo_config, k, v) - # 포트 범위도 시크릿과 함께 env 주입 — example(플레이스홀더 0) 기반 이미지에서 이게 없으면 - # 자격증명을 넣어도 enabled=False(포트 0)로 프록시가 조용히 꺼진다. - for k in ("port_start", "port_end", "session_minutes"): - v = os.environ.get(f"DECODO_{k.upper()}") - if v: - setattr(decodo_config, k, int(v)) - if os.environ.get("DECODO_COST_PER_GB"): - decodo_config.cost_per_gb = float(os.environ["DECODO_COST_PER_GB"]) - # NAVER_KEYS="id1:secret1,id2:secret2" 형식으로 키 로테이션 주입 - nk = os.environ.get("NAVER_KEYS") - if nk: - naver_config.keys = [NaverKey(id=i, secret=s) - for i, s in (p.split(":", 1) for p in nk.split(",") if ":" in p)] - - -_apply_db_env_override(main_db_config) -_apply_secret_env_override() - -# uvicorn 워커 수(멀티코어) env override — 부하테스트에서 1↔N 비교용(코드/toml 수정 없이). +# uvicorn 워커 수 — 실행 스크립트/부하벤치의 대화형 입력만 env 로 임시 override(설정은 toml 이 소스). if os.environ.get("PROCESS_COUNT"): web_server_config.process_count = int(os.environ["PROCESS_COUNT"]) # 커넥션 풀 자동 산정: process_count(위에서 확정) 기준으로 예산 안에 맞춘다. # → 멀티워커 배포 시 풀 오버서브스크립션(→커넥션 고갈)을 config 가 스스로 방지. _pool_autosized = _autosize_pool(main_db_config, web_server_config.process_count) -# 명시적 DB_POOL_SIZE/DB_MAX_OVERFLOW 는 자동 산정보다 우선(최종 override). -_apply_pool_env_override(main_db_config) diff --git a/lps/docs/api.md b/lps/docs/api.md index cf1921e..accc3a8 100644 --- a/lps/docs/api.md +++ b/lps/docs/api.md @@ -9,9 +9,9 @@ "result": { "success": true, "code": 0, "desc": "SUCCESS" } ``` (실패 시 `success:false`, `code`/`desc`에 오류 코드) -- **인증(guard)**: 서버에 `LPS_API_KEY` 가 설정된 환경(prod)에서는 모든 `/v1/*` 요청에 - `X-API-Key` 헤더가 필요합니다(불일치 시 `401`). 개발(local/dev)은 env 를 비워 **개방 모드**로 - 동작합니다. `/healthz`·`/readyz` 는 항상 개방(LB 프로브). 키는 콤마 구분 복수 등록 가능 +- **인증(guard)**: 서버 toml 의 `[WebServerConfig].api_keys` 가 채워진 환경(prod)에서는 모든 `/v1/*` 요청에 + `X-API-Key` 헤더가 필요합니다(불일치 시 `401`). 개발(local/dev)은 키를 비워 **개방 모드**로 + 동작합니다. `/healthz`·`/readyz` 는 항상 개방(LB 프로브). 키는 리스트로 복수 등록 가능 (무중단 키 교체). 호출 예: `curl -H "X-API-Key: <키>" http://.../v1/lps/queue/stats` --- diff --git a/lps/docs/architecture.md b/lps/docs/architecture.md index 6fed3e1..fc79bdc 100644 --- a/lps/docs/architecture.md +++ b/lps/docs/architecture.md @@ -10,11 +10,11 @@ | **큐(대기줄)** | `crud/job_crud.py` + `job` 테이블 | 할 일을 순서대로 안전하게 보관 (PostgreSQL 사용) | | **워커(일꾼)** | `worker_main.py`, `worker/` | 큐에서 하나씩 꺼내 **실제 검색·판정·저장** 수행 | | **소스 어댑터** | `services/search/` | 네이버·쿠팡에서 상품 수집 (소스별 방식 캡슐화) | -| **오픈마켓 폴백** | `services/search/{esm,st11}/` | G마켓·옥션·11번가 크롤 — 네이버가 그 몰을 커버 못 했을 때만 (BrowserSearchAdapter 공유). **기본 비활성**(`LPS_FALLBACKS`) | +| **오픈마켓 폴백** | `services/search/{esm,st11}/` | G마켓·옥션·11번가 크롤 — 네이버가 그 몰을 커버 못 했을 때만 (BrowserSearchAdapter 공유). **기본 비활성**(`[WorkerConfig].fallbacks`) | | **파이프라인** | `services/pipeline/` | 수집 결과를 필터·이상치 제거·최저가 정렬 | | **AI** | `services/ai/` | "같은 상품" 판정 + 검색어 생성 (OpenAI) | | **관측·알림** | `common/alerts.py` + 워커 ops-monitor | 큐·차단·DB풀·비용 등 10룰 임계 알림(쿨다운·해소 알림, Slack 웹훅) + 하트비트. API 도 자기 풀을 자체 감시. [룰 표](operations.md) | -| **API guard** | `router/v1/validator/auth.py` | `LPS_API_KEY` 설정 시 `/v1` 전체 X-API-Key 검증(개발은 미설정=개방) | +| **API guard** | `router/v1/validator/auth.py` | `[WebServerConfig].api_keys` 설정 시 `/v1` 전체 X-API-Key 검증(개발은 빈값=개방) | > **API와 워커를 분리**한 이유: 요청 접수는 즉시(가벼움), 실제 검색은 무거움(브라우저·AI). 분리하면 요청이 밀리지 않고, 워커만 따로 늘릴 수 있습니다. @@ -43,7 +43,7 @@ → 매칭 0건 + 소스 정상: 다음 라운드로 → 매칭 0건 + 소스 차단: 작업 실패 처리(뒤에서 재시도) -⑤-1 오픈마켓 폴백 (매칭 성공 시 · **기본 비활성 — LPS_FALLBACKS 로 켬**) +⑤-1 오픈마켓 폴백 (매칭 성공 시 · **기본 비활성 — [WorkerConfig].fallbacks 로 켬**) 네이버가 커버 못 한 몰(G마켓·옥션·11번가)만 실사이트 크롤 → 같은 상품 판정 → 병합 ("네이버로 그 몰 값 확보 성공 → 그 값, 실패(몰 없음) → 크롤". 크롤 실패는 격리) ※ 2026-07-10 협의: 최종 최저가 기여 0회·시간/비용 과다로 로직에서 제외(코드 유지). @@ -80,7 +80,7 @@ **핵심 메커니즘** - **IP 회전(DECODO)**: 같은 IP로 계속 두드리면 차단 → 시간창 기반 sticky + 봇감지/전송오류 시 즉시 회전. 감지 이력(`bot_detection`)을 기록해 패턴 분석. **프록시 전송오류(407/터널)** 도 사이트 차단과 구분해 회전. -- **선제 회전(요청 예산)**: IP당 요청 수가 예산(`LPS_IP_REQUEST_BUDGET`, 기본 3 — 실측상 5회 부근 차단)에 닿으면 **차단당하기 전에** 회전. 선제 교체된 포트는 평판이 깨끗해 로테이션 복귀 시 재사용됩니다. 반면 **차단 감지된 포트는 쿨다운**(`LPS_PORT_COOLDOWN_SEC`, 기본 max(sticky, 30분)) 동안 격리 — sticky 만료 후 복귀라 사실상 새 IP. 세션마다 `ip_session`(요청 수·종료 사유)을 남겨 예산 상한을 데이터로 튜닝합니다(쿼리는 database.md). +- **선제 회전(요청 예산)**: IP당 요청 수가 예산(`[DecodoConfig].ip_request_budget`, 기본 3 — 실측상 5회 부근 차단)에 닿으면 **차단당하기 전에** 회전. 선제 교체된 포트는 평판이 깨끗해 로테이션 복귀 시 재사용됩니다. 반면 **차단 감지된 포트는 쿨다운**(`[DecodoConfig].port_cooldown_sec`, 기본 max(sticky, 30분)) 동안 격리 — sticky 만료 후 복귀라 사실상 새 IP. 세션마다 `ip_session`(요청 수·종료 사유)을 남겨 예산 상한을 데이터로 튜닝합니다(쿼리는 database.md). - **시작 프리플라이트 + 웜업**: 기동 시 살아있는 프록시 포트를 선점(egress IP 로그)하고, 챌린지 소스를 미리 1회 풀어 **쿠키를 선점**(나쁜 IP는 회전 재시도) → 실 작업은 웜(빠름). - **동적 리소스 차단**: 이미지·폰트 등을 차단해 대역폭↓. 단 **Turnstile은 리소스 차단을 봇 신호로 감지**하므로, ESM은 챌린지 solving 중(콜드)엔 차단을 풀고 **cf_clearance 확보 후(웜)에만 차단**합니다. - **폴백 데드라인**: 오픈마켓 크롤은 '보강'이라 각 크롤에 시간 상한(기본 15초)을 둬, 한 몰이 안 풀려도 전체 지연이 늘지 않게 합니다. @@ -107,7 +107,7 @@ - **API 서버는 asyncio(스레드 1개) = 1 프로세스 1 코어**. 처리량을 코어만큼 올리려면 `process_count`(uvicorn 워커 수)를 늘린다. - **함정**: 프로세스마다 독립 커넥션 풀을 열어 `(pool_size + max_overflow) × 2엔진(R/W) × process_count` 만큼 커넥션을 요구 → PG `max_connections`(기본 100)를 넘으면 **커넥션 고갈로 요청 실패 폭증**(부하테스트로 실증: 풀 10/20 · 4프로세스 = 240 요구 → 실패 1만+). - **해결(자동)**: `MainDBConfig.connection_budget`(기본 40)를 두면 기동 시 `process_count`에 맞춰 `pool_size/max_overflow`를 **역산**해 `(pool+overflow)×2×process_count ≤ budget`을 스스로 보장(`server_configs._autosize_pool`). 워커를 늘려도 예산을 넘지 않는다. 기동 로그 `DB Pool : … = N conns (budget=…)`로 실효값 확인. -- **예산 가이드**: 공유 PG=40(API+worker+타 서비스 공존, 안정 우선) / 전용 PG(`max_connections≈100`)=90(처리량 우선). env `DB_CONNECTION_BUDGET`. 더 큰 처리량은 예산↑ + PG `max_connections`↑ 또는 pgbouncer. +- **예산 가이드**: 공유 PG=40(API+worker+타 서비스 공존, 안정 우선) / 전용 PG(`max_connections≈100`)=90(처리량 우선). `[MainDBConfig].connection_budget`. 더 큰 처리량은 예산↑ + PG `max_connections`↑ 또는 pgbouncer. - 상세·벤치 결과: [`../loadtest/README.md`](../loadtest/README.md). ### 6-2. 왜 브라우저는 워커당 1세트인가 (더 띄우면 안 되나?) diff --git a/lps/docs/database.md b/lps/docs/database.md index e6c87ba..7769a7b 100644 --- a/lps/docs/database.md +++ b/lps/docs/database.md @@ -106,7 +106,7 @@ SELECT avg(ip_request_no), count(*) FROM bot_detection; ## 5. `ip_session` — IP(프록시 포트) 세션 종료 이력 브라우저(=IP 세션)가 끝날 때마다 기록. `bot_detection`은 **차단된** 세션만 남지만, -여기엔 **무사 종료**(예산 선제 회전·시간창 만료 등)도 남아 요청 예산(`LPS_IP_REQUEST_BUDGET`) +여기엔 **무사 종료**(예산 선제 회전·시간창 만료 등)도 남아 요청 예산(`[DecodoConfig].ip_request_budget`) 상한 튜닝의 원천 데이터가 됩니다. | 컬럼 | 뜻 | diff --git a/lps/docs/decision-openmarket-crawler.md b/lps/docs/decision-openmarket-crawler.md index 69975ad..a6ec100 100644 --- a/lps/docs/decision-openmarket-crawler.md +++ b/lps/docs/decision-openmarket-crawler.md @@ -60,9 +60,9 @@ LPS는 상품별 최저가를 찾는다. 소스는 2계층: - [x] **결정: B. 게이트/OFF** — 검색은 **네이버+쿠팡만**. 오픈마켓 폴백 3종은 **코드·테스트 유지, 로직에서 제외(기본 비활성)**. - 근거: 크롤 몰의 최종 최저가 기여 0회 + 검색당 최대 15s(폴백 데드라인) + 비용의 ~87%(DECODO)가 이 경로. -- [x] 구현: 주석처리가 아닌 **env 토글** — `LPS_FALLBACKS`(기본 빈값=OFF, 예: `gmarket,auction,st11`, 일부만도 가능). +- [x] 구현: 주석처리가 아닌 **설정 토글** — `[WorkerConfig].fallbacks`(기본 []=OFF, 예: `["gmarket","auction","st11"]`, 일부만도 가능. 구현 당시 env `LPS_FALLBACKS`, 2026-07-13 toml 단일화로 이관). - `worker_main.py` 가 이 값으로만 폴백 어댑터를 생성. 핸들러는 빈 폴백을 원래 정상 처리(`worker/handlers.py`)라 로직 변경 없음. - 폴백 로직·파서 테스트는 fake 주입이라 **비활성 상태에서도 계속 돈다**(코드 부패 방지). - by_mall 은 네이버 노출 몰 + 쿠팡으로만 채워짐 → 소비처(프론트) 연동 시 공유할 것. - [x] 재개 트리거: by_mall 소비 화면이 생기거나, 특정 몰이 네이버 커버리지에서 빠져 가격 검증이 필요해질 때. - - **재가동 절차**: ① 라이브 스모크로 셀렉터 드리프트 점검(`LPS_LIVE=1 pytest tests/test_browser_base.py::test_live_smoke` + 대상 몰 1회 검색) → ② `LPS_FALLBACKS` 설정(로컬은 `run_local_worker.sh` 질문, 배포는 compose env) → ③ 웜업/차단 로그 확인. 미사용 기간 동안 셀렉터는 낡는다고 가정할 것. + - **재가동 절차**: ① 라이브 스모크로 셀렉터 드리프트 점검(`LPS_LIVE=1 pytest tests/test_browser_base.py::test_live_smoke` + 대상 몰 1회 검색) → ② `[WorkerConfig].fallbacks` 설정(로컬은 config.local.toml, 배포는 config.docker.toml) → ③ 웜업/차단 로그 확인. 미사용 기간 동안 셀렉터는 낡는다고 가정할 것. diff --git a/lps/docs/operations.md b/lps/docs/operations.md index 0d4bde1..8e736eb 100644 --- a/lps/docs/operations.md +++ b/lps/docs/operations.md @@ -18,10 +18,11 @@ cp config/config.local.toml.example config/config.local.toml | `[OpenAIConfig]` | `api_key` (AI 판정·검색어 생성용) | | `[DecodoConfig]` | 프록시 정보(비워두면 프록시 미사용) | -> **한 파일에 설정+시크릿 통합** 관리(로컬). **Docker 이미지에는 이 파일이 들어가지 않는다** — -> 빌드 시 `.dockerignore` 로 제외되고 example(플레이스홀더)이 대신 들어가며, 실값은 compose 의 -> env 로 주입한다(리포 루트 `.env`, 템플릿 `.env.example`). `server_configs` 의 env override 가 -> DB 접속·`OPENAI_API_KEY`·`DECODO_*`(포트 포함)·`NAVER_KEYS` 를 모두 덮는다. +> **설정 소스는 TOML 하나다**(2026-07-13 협의 — env/.env 이중 관리 제거). 호스트 실행은 +> `config.local.toml`, Docker 는 `config.docker.toml`(example 복사)을 컨테이너에 마운트하고 +> `APP_ENV=docker` 로 읽는다. 이미지에는 시크릿이 없고(빌드 시 `.dockerignore` 제외), 마운트를 +> 잊으면 기동 시 FileNotFoundError 로 즉시 실패한다. env 는 `APP_ENV`·실행 스크립트의 대화형 +> 입력(`PROCESS_COUNT`/`WORKER_CONCURRENCY`)·`LPS_LIVE`(테스트)만 남는다. **DB 준비**: `lps_db` 생성 후 최초 실행 시 테이블 자동 생성. ```bash @@ -40,16 +41,16 @@ psql -h 127.0.0.1 -U postgres -d lps_db -c "CREATE EXTENSION IF NOT EXISTS pgcry ```bash ./run_local_worker.sh # 대화형: 동시성(WORKER_CONCURRENCY)·Chrome 프로필·폴백 선택 # 또는 직접: -PYTHONUNBUFFERED=1 python worker_main.py # 로그 실시간 -WORKER_CONCURRENCY=3 python worker_main.py # 동시성 2~3(로컬). Chrome 최대 4×N개 -LPS_FALLBACKS=gmarket,auction,st11 python worker_main.py # 오픈마켓 폴백 재가동(기본 OFF — decision 문서 참고) +PYTHONUNBUFFERED=1 python worker_main.py # 로그 실시간. 동시성·폴백 등은 config.local.toml [WorkerConfig] +WORKER_CONCURRENCY=3 python worker_main.py # 동시성만 실행 시 임시 override 가능(권장 2~3, Chrome 최대 4×N개) +# 오픈마켓 폴백 재가동: [WorkerConfig].fallbacks = ["gmarket","auction","st11"] (기본 OFF — decision 문서 참고) ``` > 워커 실행 시 쿠팡 크롤링용 **Chrome 창이 뜹니다**(정상). 기동 로그에 `DECODO 프리플라이트 OK — egress IP ...`, `AI: ON/OFF`가 표시됩니다. > 동시성 N이면 상품 N개가 진짜 병렬 처리됩니다(각 워커가 자기 프로필·프록시 IP 사용). **워커 종료 (graceful)** - `Ctrl+C`(SIGINT) 또는 `docker stop`(SIGTERM) 1회 → **새 잡은 안 받고, 하던 잡을 마무리한 뒤** 리스너·브라우저를 정리하고 종료합니다(`LPS 워커 종료 완료` 로그, 트레이스백 없음). -- 유예시간 `LPS_SHUTDOWN_GRACE_SEC`(기본 60s) 안에 안 끝나면 강제 취소되고, 그 잡은 lease 만료(120s) 후 reaper 가 재큐합니다. **한 번 더 신호를 보내면 즉시 강제 종료**입니다. +- 유예시간 `[WorkerConfig].shutdown_grace_sec`(기본 60s) 안에 안 끝나면 강제 취소되고, 그 잡은 lease 만료(120s) 후 reaper 가 재큐합니다. **한 번 더 신호를 보내면 즉시 강제 종료**입니다. - Docker 는 compose 의 `stop_grace_period: 75s`(유예 60s + 정리 여유)가 SIGKILL 을 그만큼 미뤄줍니다 — 유예를 늘리면 이 값도 같이 늘리세요. **부하 테스트** @@ -72,8 +73,8 @@ config 가 보장: 위 값 ≤ connection_budget (기본 40) - `process_count` 를 올리면 `pool_size/max_overflow` 가 **자동으로 축소**되어 예산을 넘지 않습니다. (수동 튜닝 불필요 — 예전엔 이걸 안 맞춰서 워커↑ 시 커넥션 고갈→요청 실패가 났음) - 기동 로그에서 실효값 확인: `DB Pool : pool_size=.. max_overflow=.. × 2engine × Nworkers = M conns (budget=..)` -- **예산 조정**: 공유 PG 는 40 유지, 전용 PG(`max_connections≈100`)면 `DB_CONNECTION_BUDGET=90` 으로 상향. -- env 로 조절(코드/toml 수정 없이): `PROCESS_COUNT`, `DB_CONNECTION_BUDGET`, (특수 시)`DB_POOL_SIZE`/`DB_MAX_OVERFLOW`. +- **예산 조정**: 공유 PG 는 40 유지, 전용 PG(`max_connections≈100`)면 `[MainDBConfig].connection_budget = 90` 으로 상향. +- `PROCESS_COUNT` env 는 실행 스크립트·부하벤치의 대화형 입력 전용 임시 override(설정은 toml 이 소스). - 부하 한계 측정은 [`loadtest/README.md`](../loadtest/README.md) 참고(Locust 멀티코어 벤치). ## 3. 로그 보는 법 (워커 터미널) @@ -146,22 +147,23 @@ SELECT key, until, reason FROM search_negative ORDER BY created_at DESC; 발화 시 1회 + 쿨다운(기본 30분)마다 리마인드, **조건 해소 시 '해소' 알림 1회**를 보낸다 (과거처럼 조건 지속 중 30초마다 반복 발송되지 않음). WARN/INFO 로그는 항상, 웹훅은 env 있을 때만. -| 룰 키 | 조건 | 임계 env(기본) | +| 룰 키 | 조건 | 임계 [AlertConfig] 키(기본) | |------|------|----------------| -| `dead` | 최근 1h DEAD 잡 수 | `LPS_ALERT_DEAD_1H`(20) | -| `blocks` | 최근 1h 봇 감지 수 | `LPS_ALERT_BLOCKS_1H`(80) | -| `queue_lag` | 가장 오래된 PENDING 대기 초 | `LPS_ALERT_QUEUE_LAG_SEC`(300) | +| `dead` | 최근 1h DEAD 잡 수 | `dead_1h`(20) | +| `blocks` | 최근 1h 봇 감지 수 | `blocks_1h`(80) | +| `queue_lag` | 가장 오래된 PENDING 대기 초 | `queue_lag_sec`(300) | | `stuck` | lease 만료 RUNNING 잔존 | (0 초과 시) | -| `db_pool` | DB 커넥션 풀 포화율(%) — 워커·API 각자 자기 풀 감시 | `LPS_ALERT_POOL_PCT`(90) | -| `source_fail:` | 소스별 최근 30분 시도 N회 이상 & 성공 0건(쿼터 소진·셀렉터 드리프트·전면 차단 신호) | `LPS_ALERT_SOURCE_FAIL_30M`(5) | -| `deadline` | 최근 1h 잡 데드라인 강제종료 수(크롤 행 반복 신호 — 재시도로 살아나면 dead 엔 안 잡힘) | `LPS_ALERT_DEADLINE_1H`(5) | -| `cost` | 최근 1h 완료 잡 검색원가 합($) — 비용 폭주(리소스차단 풀림·재시도 루프) 감시 | `LPS_ALERT_COST_1H_USD`(1.0) | -| `proxy_ports_low` | 가용 프록시 포트 비율(%) — 쿨다운 격리 누적, blocks 보다 먼저 우는 대규모 차단 조기 신호 | `LPS_ALERT_PORTS_LOW_PCT`(30) | -| `budget_leak` | 최근 6h '예산 회전에도 차단된' IP 세션 수 — 현재 요청 예산이 안전하지 않다는 신호(예산 하향 검토) | `LPS_ALERT_BLOCK_SESSIONS_6H`(1) | +| `db_pool` | DB 커넥션 풀 포화율(%) — 워커·API 각자 자기 풀 감시 | `pool_pct`(90) | +| `source_fail:` | 소스별 최근 30분 시도 N회 이상 & 성공 0건(쿼터 소진·셀렉터 드리프트·전면 차단 신호) | `source_fail_30m`(5) | +| `deadline` | 최근 1h 잡 데드라인 강제종료 수(크롤 행 반복 신호 — 재시도로 살아나면 dead 엔 안 잡힘) | `deadline_1h`(5) | +| `cost` | 최근 1h 완료 잡 검색원가 합($) — 비용 폭주(리소스차단 풀림·재시도 루프) 감시 | `cost_1h_usd`(1.0) | +| `proxy_ports_low` | 가용 프록시 포트 비율(%) — 쿨다운 격리 누적, blocks 보다 먼저 우는 대규모 차단 조기 신호 | `ports_low_pct`(30) | +| `budget_leak` | 최근 6h '예산 회전에도 차단된' IP 세션 수 — 현재 요청 예산이 안전하지 않다는 신호(예산 하향 검토) | `block_sessions_6h`(1) | -``` -LPS_ALERT_WEBHOOK=https://hooks.slack.com/... # 있으면 웹훅 알림 전송(워커·API 공통) -LPS_ALERT_COOLDOWN_MIN=30 # 같은 룰 재발송 억제 시간 +```toml +[AlertConfig] +webhook = "https://hooks.slack.com/..." # 있으면 웹훅 알림 전송(워커·API 공통) +cooldown_min = 30 # 같은 룰 재발송 억제 시간(분) ``` 지표는 알림 없이도 `GET /v1/lps/ops` 로 노출된다(`pool_pct`·`deadline_1h`·`cost_1h_usd` 포함) — 외부 모니터 스크랩용. (`proxy_ports_avail`·`block_sessions_6h` 는 워커 웹훅 스냅샷에만 포함 — 프록시 상태는 워커 프로세스에만 있음) @@ -204,20 +206,20 @@ docker ps # lps-worker "(healthy)" 확인 - **워커 = 헤드풀 Chromium + Xvfb**(`Dockerfile.worker`): **headless 는 Akamai·Cloudflare Turnstile 에 탐지됨**(실측). Xvfb 가상 디스플레이로 headful 실행. - **API = lean**(`Dockerfile`, 브라우저 불필요). - **시크릿은 이미지에 없음(강제)**: 이미지는 example config 로 빌드된다(`.dockerignore` 가 - config.local.toml·`.profiles/` 제외). 실값은 **리포 루트 `.env`**(템플릿 `.env.example`)에서 - compose env 로 주입. `.env` 없이 뜨면 AI OFF·프록시 미사용으로 조용히 동작하니, 기동 로그의 + config.local.toml·`.profiles/` 제외). 실값은 **`lps/config/config.docker.toml`**(example 복사, + 미커밋)을 compose 가 마운트해 주입(`APP_ENV=docker`). 마운트를 잊으면 기동 시 즉시 실패. 기동 로그의 `AI: ON/OFF`·`DECODO 프록시: ON/OFF` 로 주입 성공을 반드시 확인할 것. -- **Chrome 프로필 영속 볼륨**(`lps-profiles:/profiles`, `LPS_PROFILE_DIR`): 재시작해도 cf_clearance 유지 → 재웜업 회피. +- **Chrome 프로필 영속 볼륨**(`lps-profiles:/profiles`, `[WorkerConfig].profile_dir`): 재시작해도 cf_clearance 유지 → 재웜업 회피. - **워커 헬스**: HEALTHCHECK(하트비트<120s)로 행 워커 감지. compose 의 `restart` 는 unhealthy 를 재시작하지 않으므로 **autoheal 컨테이너**(라벨 `autoheal=true` 감시)가 재시작 담당. k8s 는 liveness probe 로 대체. -- **잡 데드라인**: 잡 1건 300s 상한(`LPS_JOB_DEADLINE_SEC`) — 크롤 행이 워커 슬롯을 영구 점유하지 못하게 함. -- **IP 선제 회전**: `LPS_IP_REQUEST_BUDGET`(기본 3) — IP당 요청 예산, 도달 시 차단 전에 회전(0=비활성). - `LPS_PORT_COOLDOWN_SEC`(기본 max(sticky, 1800)) — 차단 감지된 포트 격리 시간. 포트 수를 늘리면 - (DECODO_PORT_START/END) 자동 반영 — 코드에 포트 수 하드코딩 없음. 튜닝은 `ip_session` 분석 쿼리(database.md) 참고. +- **잡 데드라인**: 잡 1건 300s 상한(`[WorkerConfig].job_deadline_sec`) — 크롤 행이 워커 슬롯을 영구 점유하지 못하게 함. +- **IP 선제 회전**: `[DecodoConfig].ip_request_budget`(기본 3) — IP당 요청 예산, 도달 시 차단 전에 회전(0=비활성). + `[DecodoConfig].port_cooldown_sec`(0=자동 max(sticky, 1800)) — 차단 감지된 포트 격리 시간. 포트 수를 늘리면 + ([DecodoConfig].port_start/end) 자동 반영 — 코드에 포트 수 하드코딩 없음. 튜닝은 `ip_session` 분석 쿼리(database.md) 참고. -- **API guard**: `LPS_API_KEY` 설정 시 `/v1/*` 전체에 X-API-Key 검증(콤마 구분 복수 키 — - 무중단 교체). 개발(local/dev)은 미설정=개방 모드. **prod 체크리스트**: ① `.env` 에 - `LPS_API_KEY` 주입(negodata-backend 도 같은 키 — 헤더 자동 첨부) ② lps-api 포트 공개 +- **API guard**: `[WebServerConfig].api_keys` 설정 시 `/v1/*` 전체에 X-API-Key 검증(복수 키 — + 무중단 교체). 개발(local/dev)은 빈값=개방 모드. **prod 체크리스트**: ① config.docker.toml 에 + `api_keys` 채움(negodata 쪽은 `lps_api_key` 에 같은 키 — 헤더 자동 첨부) ② lps-api 포트 공개 제거(내부 네트워크만, `ports:` 삭제) ③ 기동 로그에서 `API guard ON` 확인. **남은 배포 과제**: 레이트리밋(키별 요청량 제한), 다중 레플리카 시 분산 레이트리밋/프록시 IP 조정. diff --git a/lps/loadtest/README.md b/lps/loadtest/README.md index 4a72cb0..0bad8eb 100644 --- a/lps/loadtest/README.md +++ b/lps/loadtest/README.md @@ -64,7 +64,7 @@ API 는 asyncio(스레드 1개)라 단일 프로세스=단일 코어. uvicorn `w | 4 | pool 3 / overflow 2 | 40 | | 8 | pool 1 / overflow 1 | 32 | -- **예산 설정**: 전용 PG(max_connections=100)면 `connection_budget≈90`, 공유 PG면 40 권장. `env DB_CONNECTION_BUDGET`. +- **예산 설정**: 전용 PG(max_connections=100)면 `connection_budget≈90`, 공유 PG면 40 권장(`[MainDBConfig].connection_budget`). (공유 PG 기본 40 은 API + worker + 타 서비스가 100 안에 공존하도록 잡은 안전값 → 처리량보다 안정 우선) - **override 우선순위**: 명시 `DB_POOL_SIZE`/`DB_MAX_OVERFLOW` > 자동 산정(budget>0) > toml `pool_size/max_overflow`(budget=0) - 더 큰 처리량이 필요하면: 예산 상향 + **PG `max_connections` 상향** 또는 **pgbouncer**(커넥션 풀러) 도입 diff --git a/lps/router/router.py b/lps/router/router.py index 657188e..23e2f11 100644 --- a/lps/router/router.py +++ b/lps/router/router.py @@ -86,10 +86,10 @@ async def readyz(): # 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.. 를 import 후 include. -# guard: LPS_API_KEY 설정 시 /v1 전체에 X-API-Key 검증(개발은 미설정=개방 — auth.py 참고). +# guard: [WebServerConfig].api_keys 설정 시 /v1 전체에 X-API-Key 검증(개발은 빈값=개방 — auth.py 참고). app.include_router(router.v1.lps.search.router, dependencies=[Depends(require_api_key)]) if configured_keys(): LOG.i(f"API guard ON — X-API-Key 검증({len(configured_keys())}개 키)") else: - LOG.w("LPS_API_KEY 미설정 — API 개방 모드(개발용). prod 배포 시 키 주입 + 포트 비공개 필수") + LOG.w("[WebServerConfig].api_keys 비어있음 — API 개방 모드(개발용). prod 는 toml 에 키 채움 + 포트 비공개 필수") diff --git a/lps/router/v1/validator/auth.py b/lps/router/v1/validator/auth.py index 2ae6ad9..584b285 100644 --- a/lps/router/v1/validator/auth.py +++ b/lps/router/v1/validator/auth.py @@ -1,22 +1,23 @@ -"""API 키 guard — LPS_API_KEY 가 설정된 경우에만 /v1 라우터 전체를 보호한다. +"""API 키 guard — [WebServerConfig].api_keys 가 설정된 경우에만 /v1 라우터 전체를 보호한다. -개발(local/dev)은 env 를 비워 **개방 모드**로 쓰고, prod 에서만 키를 주입한다(협의 결정 -2026-07-13). '키의 존재'가 토글이라 APP_ENV=local 고정 운영 전제와 충돌하지 않는다. -- 키는 콤마 구분 복수 허용 — 무중단 키 교체(새 키 추가 → 호출자 전환 → 옛 키 제거). +개발(local/dev)은 키를 비워 **개방 모드**로 쓰고, prod toml 에서만 키를 채운다(협의 결정 +2026-07-13). '키의 존재'가 토글이다. +- 키는 복수 허용 — 무중단 키 교체(새 키 추가 → 호출자 전환 → 옛 키 제거). - 비교는 secrets.compare_digest(상수시간) — 타이밍 공격 방지. - /healthz·/readyz 는 라우터 밖이라 guard 대상이 아니다(LB/오케스트레이터 프로브). - prod 는 여기에 더해 lps-api 포트 비공개(내부 네트워크만)를 권장 — docs/operations.md. """ -import os import secrets from fastapi import Header, HTTPException +from config.server_configs import web_server_config + def configured_keys() -> set[str]: - """유효 API 키 집합. 매 호출 env 를 읽는다 — 프로세스 재기동 없이 테스트 가능, 비용은 무시 수준.""" - return {k.strip() for k in os.environ.get("LPS_API_KEY", "").split(",") if k.strip()} + """유효 API 키 집합. 매 호출 config 를 읽는다 — 테스트에서 monkeypatch 로 on/off 전환 가능.""" + return {k.strip() for k in web_server_config.api_keys if k.strip()} async def require_api_key(x_api_key: str | None = Header(None, alias="X-API-Key")): diff --git a/lps/run_local_server.sh b/lps/run_local_server.sh index f851156..75b6b75 100755 --- a/lps/run_local_server.sh +++ b/lps/run_local_server.sh @@ -51,15 +51,11 @@ export APP_ENV=local case "$choice" in 1) # 멀티코어: API 는 asyncio(단일 스레드)라 프로세스 수 = 사용 코어 수. 커넥션 풀은 예산에서 자동 역산. cpu_count="$(sysctl -n hw.ncpu 2>/dev/null || echo '?')" - read -rp "프로세스 수 PROCESS_COUNT [${PROCESS_COUNT:-1}] (CPU ${cpu_count}코어, 부하테스트 벤치는 4): " pc - pc="${pc:-${PROCESS_COUNT:-1}}" - export PROCESS_COUNT="$pc" - if [[ "$pc" != "1" ]]; then - read -rp "DB 커넥션 예산 DB_CONNECTION_BUDGET [${DB_CONNECTION_BUDGET:-96}] (전용PG≈90+, 공유PG 40): " budget - export DB_CONNECTION_BUDGET="${budget:-${DB_CONNECTION_BUDGET:-96}}" - fi - echo "[run] web_main.py → http://localhost:$PORT/docs (프로세스 ${pc}개)" - echo " 기동 로그의 'DB Pool : ... × ${pc}workers ... (budget=...)' 으로 실효 풀 확인" + # 커넥션 예산은 config.local.toml [MainDBConfig].connection_budget 이 소스(2026-07-13 toml 단일화). + read -rp "프로세스 수 [엔터=toml 설정값] (CPU ${cpu_count}코어, 부하테스트 벤치는 4): " pc + [[ -n "$pc" ]] && export PROCESS_COUNT="$pc" + echo "[run] web_main.py → http://localhost:$PORT/docs (프로세스 ${pc:-toml 설정값}개)" + echo " 기동 로그의 'DB Pool : ...' 으로 실효 풀 확인" exec "$PY" web_main.py ;; 2) echo "[run] uvicorn --reload → http://localhost:$PORT/docs" exec "$VENV/bin/uvicorn" router.router:app --host 0.0.0.0 --port "$PORT" --reload ;; diff --git a/lps/run_local_worker.sh b/lps/run_local_worker.sh index f670adc..6993d44 100755 --- a/lps/run_local_worker.sh +++ b/lps/run_local_worker.sh @@ -36,27 +36,17 @@ if pgrep -f worker_main.py >/dev/null 2>&1; then fi fi -# 4) 동시성 입력 (상품 동시 검색 수 · 워커별 브라우저 세트 · Chrome 최대 4×N개) -echo "── 워커 설정 ──" -read -rp "동시 검색 수 WORKER_CONCURRENCY [3] (로컬 권장 2~3): " CONC -CONC="${CONC:-3}" - -# 5) Chrome 프로필 영속 디렉터리 (재시작해도 cf_clearance 유지 → 재웜업 회피). 비우면 기본(/tmp) -read -rp "Chrome 프로필 디렉터리 LPS_PROFILE_DIR [.profiles] (엔터=유지): " PROFILE -PROFILE="${PROFILE:-.profiles}" -mkdir -p "$PROFILE" - -# 6) 오픈마켓 폴백 — 기본 비활성(2026-07-10 협의: 최종 최저가 기여 0회, 검색당 최대 15s·프록시 비용의 대부분) -read -rp "오픈마켓 폴백 LPS_FALLBACKS [비활성] (켜려면 예: gmarket,auction,st11): " FALLBACKS -export LPS_FALLBACKS="${FALLBACKS:-}" +# 4) 동시성 입력 — 실행 시 임시 override(엔터=toml [WorkerConfig].concurrency 사용). +# 프로필·폴백·데드라인 등 나머지 설정은 config.local.toml [WorkerConfig] 가 소스(2026-07-13 toml 단일화). +echo "── 워커 설정 (프로필·폴백 등은 config.local.toml [WorkerConfig]에서) ──" +read -rp "동시 검색 수 [엔터=toml 설정값] (로컬 권장 2~3): " CONC export APP_ENV=local -export WORKER_CONCURRENCY="$CONC" -export LPS_PROFILE_DIR="$PROFILE" +[[ -n "$CONC" ]] && export WORKER_CONCURRENCY="$CONC" export PYTHONUNBUFFERED=1 # 로그 실시간 출력 echo "" -echo "[run] worker_main.py (동시성=$CONC · 프로필=$PROFILE · 폴백=${LPS_FALLBACKS:-OFF})" +echo "[run] worker_main.py (동시성=${CONC:-toml 설정값})" echo " 기동 로그의 'DECODO 프리플라이트 OK — egress IP ...' / 'AI: ON/OFF' 확인" echo " 중단: Ctrl+C" echo "" diff --git a/lps/services/search/browser_base.py b/lps/services/search/browser_base.py index 1c635e0..28d87b1 100644 --- a/lps/services/search/browser_base.py +++ b/lps/services/search/browser_base.py @@ -14,13 +14,13 @@ detect_block 은 순수 함수로 분리 — 브라우저 없이 단위 테스 """ import asyncio -import os import time from abc import abstractmethod from patchright.async_api import async_playwright from common.logger import LOG +from config.server_configs import decodo_config, worker_config from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth from services.search.rate_limiter import RateLimiter @@ -28,14 +28,10 @@ from services.search.rate_limiter import RateLimiter # 오픈마켓(ESM/11번가)은 CSS/JS 를 막으면 렌더/챌린지가 깨져 이미지·미디어·폰트만 막는다(어댑터에서 override). _BLOCKED_RESOURCES = {"image", "media", "font", "stylesheet"} -# 브라우저 실행 대상(env override): 로컬 Mac=실제 Chrome(channel=chrome), 컨테이너=시스템 chromium(executable_path). +# 브라우저 실행 대상([WorkerConfig]): 로컬 Mac=실제 Chrome(channel), 컨테이너=시스템 chromium(executable). # headless 는 안티봇에 탐지되므로 서버에선 Xvfb(가상 디스플레이)로 headful 실행한다(headless 실측 실패). -_CHROME_CHANNEL = os.environ.get("LPS_CHROME_CHANNEL", "chrome") -_CHROME_EXECUTABLE = os.environ.get("LPS_CHROME_EXECUTABLE") or None - -# IP(포트 세션)당 요청 예산 — 도달하면 차단당하기 **전에** 선제 회전해 IP 평판을 보존한다. -# 실측상 5회 부근에서 차단된 이력이 있어 보수적으로 3회. 0 이면 비활성(시간창 회전만). -_IP_REQUEST_BUDGET = int(os.environ.get("LPS_IP_REQUEST_BUDGET", "3")) +_CHROME_CHANNEL = worker_config.chrome_channel +_CHROME_EXECUTABLE = worker_config.chrome_executable or None def detect_block(html: str, product_count: int, markers: tuple, min_len: int) -> str | None: @@ -87,7 +83,9 @@ class BrowserSearchAdapter(SearchAdapter): self._block_active = self._block_resources # 요청별 실제 차단 여부(_blocking_now 로 갱신) self._on_detect = on_detect # async def(event: dict) — 감지 영속화(선택) self._max_block_retries = max_block_retries - self._ip_budget = _IP_REQUEST_BUDGET if ip_request_budget is None else ip_request_budget + # IP(포트 세션)당 요청 예산([DecodoConfig].ip_request_budget) — 도달하면 차단당하기 **전에** + # 선제 회전해 IP 평판을 보존한다. 실측상 5회 부근 차단 이력 → 기본 3. 0=비활성(시간창 회전만). + self._ip_budget = decodo_config.ip_request_budget if ip_request_budget is None else ip_request_budget self._on_session_end = on_session_end # async def(event: dict) — IP 세션 종료 기록(선택, 상한 튜닝 데이터) self._pw = None self._ctx = None diff --git a/lps/services/search/proxy.py b/lps/services/search/proxy.py index 26c9e5e..3d67bc9 100644 --- a/lps/services/search/proxy.py +++ b/lps/services/search/proxy.py @@ -9,7 +9,6 @@ Decodo residential 은 **포트 기반 sticky** 모델이다: 자격증명/엔드포인트는 config.local.toml [DecodoConfig] 에서 로드(시크릿). """ -import os import time from urllib.parse import quote @@ -29,10 +28,9 @@ class DecodoProxy: self.port_end = cfg.port_end self.session_minutes = cfg.session_minutes or 10 self._rotate_offset = 0 # 봇 감지 등으로 '즉시 회전'이 필요할 때 증가 - # 불탄(차단 감지된) 포트 격리 시간. sticky 만료(session_minutes) 이상이어야 - # 쿨다운 복귀 시 같은 포트라도 사실상 새 IP 가 배정된다. 기본 max(sticky, 30분). - self.cooldown_sec = int(os.environ.get("LPS_PORT_COOLDOWN_SEC", "0")) \ - or max(self.session_minutes * 60, 1800) + # 불탄(차단 감지된) 포트 격리 시간([DecodoConfig].port_cooldown_sec). sticky 만료(session_minutes) + # 이상이어야 쿨다운 복귀 시 같은 포트라도 사실상 새 IP 가 배정된다. 0=자동 max(sticky, 30분). + self.cooldown_sec = getattr(cfg, "port_cooldown_sec", 0) or max(self.session_minutes * 60, 1800) self._burned: dict[int, float] = {} # port → 쿨다운 만료 시각(monotonic) @property diff --git a/lps/tests/test_api_guard.py b/lps/tests/test_api_guard.py index fded048..e6eb966 100644 --- a/lps/tests/test_api_guard.py +++ b/lps/tests/test_api_guard.py @@ -1,19 +1,21 @@ -"""API guard 테스트 — LPS_API_KEY 설정 시에만 /v1 에 X-API-Key 검증(개발=미설정=개방 모드). +"""API guard 테스트 — [WebServerConfig].api_keys 설정 시에만 /v1 에 X-API-Key 검증(개발=빈값=개방 모드). -auth.configured_keys 가 매 요청 env 를 읽으므로 monkeypatch.setenv 만으로 on/off 를 전환한다 +auth.configured_keys 가 매 요청 config 를 읽으므로 monkeypatch.setattr 만으로 on/off 를 전환한다 (앱 재기동 불필요). """ import pytest +from config.server_configs import web_server_config + @pytest.fixture def guarded(monkeypatch): - monkeypatch.setenv("LPS_API_KEY", "k1,k2") + monkeypatch.setattr(web_server_config, "api_keys", ["k1", "k2"]) -async def test_open_mode_without_key_env(client, monkeypatch): - monkeypatch.delenv("LPS_API_KEY", raising=False) +async def test_open_mode_without_keys(client, monkeypatch): + monkeypatch.setattr(web_server_config, "api_keys", []) r = await client.get("/v1/lps/queue/stats") # 개방 모드 — 헤더 없이 통과 assert r.status_code == 200 diff --git a/lps/worker_main.py b/lps/worker_main.py index 4a59bb2..7af0cb7 100644 --- a/lps/worker_main.py +++ b/lps/worker_main.py @@ -13,7 +13,7 @@ import time from common.alerts import AlertManager from common.database.db_session_manager import DB_SESSION_MNG 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, worker_config, alert_config from crud.job_crud import JobQueue from crud.negative_cache import NegativeCache from crud.bot_detection import BotDetectionLog @@ -35,16 +35,16 @@ LOG.SetPrefix(f"{web_server_config.server_name}-worker") # 오픈마켓 폴백(G마켓·옥션·11번가)은 **기본 비활성** — 2026-07-10 협의 결정. # 실측상 크롤 몰이 최종 최저가를 바꾼 적이 없고(0회), 검색당 최대 15s + 프록시 대역폭의 # 대부분을 차지해 로직에서 제외했다(코드·테스트는 유지, 핸들러는 빈 폴백을 정상 처리). -# 재가동: LPS_FALLBACKS=gmarket,auction,st11 (일부만도 가능) — 켜기 전 라이브 스모크로 -# 셀렉터 드리프트 점검. 배경은 docs/decision-openmarket-crawler.md. +# 재가동: [WorkerConfig].fallbacks = ["gmarket", "auction", "st11"] (일부만도 가능) — +# 켜기 전 라이브 스모크로 셀렉터 드리프트 점검. 배경은 docs/decision-openmarket-crawler.md. _FALLBACK_SOURCES = ("gmarket", "auction", "st11") def _enabled_fallbacks() -> list[str]: - names = [s.strip() for s in os.environ.get("LPS_FALLBACKS", "").split(",") if s.strip()] + names = [s.strip() for s in worker_config.fallbacks if s.strip()] unknown = [n for n in names if n not in _FALLBACK_SOURCES] if unknown: - LOG.w(f"LPS_FALLBACKS 무시된 값: {unknown} (가능: {list(_FALLBACK_SOURCES)})") + LOG.w(f"[WorkerConfig].fallbacks 무시된 값: {unknown} (가능: {list(_FALLBACK_SOURCES)})") return [n for n in names if n in _FALLBACK_SOURCES] @@ -61,9 +61,8 @@ def _build_worker(i: int, concurrency: int, has_openai: bool, neg_cache, history suffix = f"_w{i}" if concurrency > 1 else "" def _pf(source): # 워커별 Chrome 프로필 경로(중복 실행 시 ProcessSingleton 충돌 방지) - # LPS_PROFILE_DIR 를 영속 볼륨으로 마운트하면 재시작해도 cf_clearance 등 쿠키 유지(재웜업 회피). - base = os.environ.get("LPS_PROFILE_DIR", "/tmp") - return f"{base}/lps_{source}{suffix}" + # [WorkerConfig].profile_dir 를 영속 볼륨으로 두면 재시작해도 cf_clearance 등 쿠키 유지(재웜업 회피). + return f"{worker_config.profile_dir}/lps_{source}{suffix}" adapters = { "coupang": CoupangAdapter(headless=False, user_data_dir=_pf("coupang"), proxy=proxy, @@ -133,16 +132,8 @@ async def run_ops_monitor(queue, bot_log, stop, interval: float = 30.0, adapters """워커 헬스 하트비트 + 임계 알림. 주기적으로 (1) 하트비트 파일 갱신(Docker HEALTHCHECK 가 행/좀비 워커 감지) (2) 큐/차단/DB풀/소스별 실패 지표 점검 → AlertManager 로 발화 (룰별 쿨다운으로 스팸 방지, 조건 해소 시 회복 알림).""" - hb_path = os.environ.get("LPS_HEARTBEAT_FILE", "/tmp/lps_worker_heartbeat") - 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")) - th_pool = int(os.environ.get("LPS_ALERT_POOL_PCT", "90")) - th_srcfail = int(os.environ.get("LPS_ALERT_SOURCE_FAIL_30M", "5")) - th_deadline = int(os.environ.get("LPS_ALERT_DEADLINE_1H", "5")) - th_cost = float(os.environ.get("LPS_ALERT_COST_1H_USD", "1.0")) - th_ports = int(os.environ.get("LPS_ALERT_PORTS_LOW_PCT", "30")) - th_leak = int(os.environ.get("LPS_ALERT_BLOCK_SESSIONS_6H", "1")) + hb_path = worker_config.heartbeat_file + th = alert_config # 임계값은 [AlertConfig] 섹션이 소스(docs/operations.md 표) alerts = alerts or AlertManager(origin="worker") ip_log = ip_log or IpSessionLog() while not stop.is_set(): @@ -156,28 +147,28 @@ async def run_ops_monitor(queue, bot_log, stop, interval: float = 30.0, adapters snap["blocks_1h"] = await bot_log.recent_count(60) pool = DB_SESSION_MNG.pool_status() snap["pool_pct"] = pool["pct"] - await alerts.check("dead", snap["dead_1h"] >= th_dead, f"DEAD 1h={snap['dead_1h']}", snap) - await alerts.check("blocks", snap["blocks_1h"] >= th_blocks, f"차단 1h={snap['blocks_1h']}", snap) - await alerts.check("queue_lag", snap["oldest_pending_sec"] >= th_lag, f"큐지연={snap['oldest_pending_sec']}s", snap) + await alerts.check("dead", snap["dead_1h"] >= th.dead_1h, f"DEAD 1h={snap['dead_1h']}", snap) + await alerts.check("blocks", snap["blocks_1h"] >= th.blocks_1h, f"차단 1h={snap['blocks_1h']}", snap) + await alerts.check("queue_lag", snap["oldest_pending_sec"] >= th.queue_lag_sec, f"큐지연={snap['oldest_pending_sec']}s", snap) await alerts.check("stuck", snap["stuck_running"] > 0, f"stuck={snap['stuck_running']}", snap) - await alerts.check("db_pool", pool["pct"] >= th_pool, + await alerts.check("db_pool", pool["pct"] >= th.pool_pct, f"DB 풀 포화 {pool['pct']}% (checked_out {pool['checked_out']}/{pool['capacity']})", snap) - await alerts.check("deadline", snap["deadline_1h"] >= th_deadline, + await alerts.check("deadline", snap["deadline_1h"] >= th.deadline_1h, f"잡 데드라인 강제종료 1h={snap['deadline_1h']} — 크롤 행 반복 신호", snap) - await alerts.check("cost", snap["cost_1h_usd"] >= th_cost, + await alerts.check("cost", snap["cost_1h_usd"] >= th.cost_1h_usd, f"검색원가 1h=${snap['cost_1h_usd']} — 비용 폭주(리소스차단 풀림·재시도 루프) 점검", snap) # 가용 프록시 포트 고갈 — 쿨다운 격리 누적. blocks_1h 보다 먼저 우는 대규모 차단 조기 신호. ports = _proxy_ports_snapshot(adapters) if ports: avail, total = ports snap["proxy_ports_avail"], snap["proxy_ports_total"] = avail, total - await alerts.check("proxy_ports_low", avail * 100 <= total * th_ports, + await alerts.check("proxy_ports_low", avail * 100 <= total * th.ports_low_pct, f"가용 프록시 포트 {avail}/{total} — 대규모 차단 진행 신호", snap) # 예산 누수 — 요청 예산을 지켰는데도 차단된 IP 세션 발생 = 현재 예산이 안전하지 않다는 신호. block_sessions = (await ip_log.recent_stats(360)).get("block", 0) snap["block_sessions_6h"] = block_sessions - await alerts.check("budget_leak", block_sessions >= th_leak, - f"예산 회전에도 차단된 IP 세션 6h={block_sessions} — LPS_IP_REQUEST_BUDGET 하향 검토", snap) + await alerts.check("budget_leak", block_sessions >= th.block_sessions_6h, + f"예산 회전에도 차단된 IP 세션 6h={block_sessions} — ip_request_budget 하향 검토", snap) # 소스별 장기 실패 — 최근 30분간 시도는 있는데 성공이 0건(쿼터 소진·셀렉터 드리프트·전면 차단 신호) per_source: dict[str, list[int]] = {} for ad in (adapters or []): @@ -186,7 +177,7 @@ async def run_ops_monitor(queue, bot_log, stop, interval: float = 30.0, adapters agg[0] += tries agg[1] += ok for src, (tries, ok) in per_source.items(): - await alerts.check(f"source_fail:{src}", tries >= th_srcfail and ok == 0, + await alerts.check(f"source_fail:{src}", tries >= th.source_fail_30m and ok == 0, f"{src} 최근 30분 {tries}회 시도·성공 0건", snap) except Exception as ex: LOG.e_no_callstack(f"[ops-monitor] {type(ex).__name__}: {ex}") @@ -261,7 +252,7 @@ async def main(concurrency: int = 1): loop.add_signal_handler(sig, _request_stop, sig.name) # 잡 1건 데드라인 — 정상 검색은 폴백 포함 수분 내 끝난다(실측 15~22s). 크롤 행 실측(15분) 대비 상한. - job_deadline = float(os.environ.get("LPS_JOB_DEADLINE_SEC", "300")) + job_deadline = worker_config.job_deadline_sec for i in range(concurrency): handler, worker_adapters = _build_worker(i, concurrency, has_openai, neg_cache, history) all_adapters += worker_adapters @@ -279,7 +270,7 @@ async def main(concurrency: int = 1): # 종료 유예: stop 후 하던 잡이 이 시간 안에 끝나면 자연 종료, 초과하면 강제 취소. # docker stop 을 쓰면 compose 의 stop_grace_period 를 이보다 길게 잡아야 SIGKILL 전에 마무리된다. - grace = float(os.environ.get("LPS_SHUTDOWN_GRACE_SEC", "60")) + grace = worker_config.shutdown_grace_sec gathered = asyncio.gather(*tasks) stop_waiter = asyncio.create_task(stop.wait()) try: @@ -316,4 +307,7 @@ async def main(concurrency: int = 1): if __name__ == "__main__": - asyncio.run(main(int(os.environ.get("WORKER_CONCURRENCY", "1")))) + # 동시성은 [WorkerConfig].concurrency 가 소스 — WORKER_CONCURRENCY env 는 실행 스크립트의 + # 대화형 입력 전용 임시 override(설정 관리는 toml 하나로, 2026-07-13 협의). + _conc = int(os.environ.get("WORKER_CONCURRENCY", "0")) or worker_config.concurrency + asyncio.run(main(_conc)) diff --git a/negodata/backend/config/config_models.py b/negodata/backend/config/config_models.py index 51eee43..69dd229 100644 --- a/negodata/backend/config/config_models.py +++ b/negodata/backend/config/config_models.py @@ -11,6 +11,7 @@ class WebServerConfig(ConfigModel): nego_chat_url: str = "http://localhost:3300" agent_base_url: str = "http://localhost:9500" # 협상 agent(9500). 공용 카탈로그 변경 알림용. lps_base_url: str = "http://localhost:9600" # 인터넷 최저가 검색 LPS(9600). 검색요청 enqueue 용. + lps_api_key: str = "" # LPS API guard 키 — LPS 쪽 [WebServerConfig].api_keys 와 동일 값. 빈값=헤더 미첨부(개발) class LogConfig(ConfigModel): diff --git a/negodata/backend/config/server_configs.py b/negodata/backend/config/server_configs.py index 4bcf500..45549dd 100644 --- a/negodata/backend/config/server_configs.py +++ b/negodata/backend/config/server_configs.py @@ -63,3 +63,6 @@ _apply_lps_db_env_override(lps_db_config) # LPS API 주소 env override (도커: http://lps-api:9600 또는 host.docker.internal:9600) if os.environ.get("LPS_BASE_URL"): web_server_config.lps_base_url = os.environ["LPS_BASE_URL"] +# LPS API guard 키 (LPS 는 toml 단일 관리로 전환 — negodata 쪽은 기존 관례대로 toml+env override 유지) +if os.environ.get("LPS_API_KEY"): + web_server_config.lps_api_key = os.environ["LPS_API_KEY"] diff --git a/negodata/backend/services/lps_sync_service.py b/negodata/backend/services/lps_sync_service.py index d34774b..2325ce1 100644 --- a/negodata/backend/services/lps_sync_service.py +++ b/negodata/backend/services/lps_sync_service.py @@ -17,7 +17,6 @@ - 반영은 한 트랜잭션(execute_lambda_run) — 부분 반영으로 워터마크가 오염되지 않는다. """ import asyncio -import os import uuid from collections import Counter from datetime import timedelta, timezone @@ -73,8 +72,8 @@ class LpsSyncService: "price": str(item.price) if item.price else "", } base = web_server_config.lps_base_url.rstrip("/") - # LPS API guard: prod 는 LPS_API_KEY 를 주입해 X-API-Key 로 인증(개발은 미설정=개방 모드). - headers = {"X-API-Key": os.environ["LPS_API_KEY"]} if os.environ.get("LPS_API_KEY") else None + # LPS API guard: prod 는 lps_api_key 를 채워 X-API-Key 로 인증(개발은 빈값=개방 모드). + headers = {"X-API-Key": web_server_config.lps_api_key} if web_server_config.lps_api_key else None try: async with httpx.AsyncClient(timeout=10.0) as client: r = await client.post(f"{base}/v1/lps/search", json={"data": [payload]}, headers=headers)