refactor(lps): 설정을 TOML 단일 소스로 통합 — env/.env 이중 관리 제거

설정이 .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>
This commit is contained in:
민헌 2026-07-13 21:11:31 +09:00
parent 993301be8b
commit c81df5bd88
25 changed files with 308 additions and 276 deletions

View File

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

View File

@ -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

View File

@ -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 검증(개발은 빈값=개방 모드) |
---

View File

@ -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()

View File

@ -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 = "<DB_USER>"
write_pw = "<DB_PASSWORD>"
read_host = "host.docker.internal"
read_port = 5432
read_id = "<DB_USER>"
read_pw = "<DB_PASSWORD>"
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 = "<NAVER_CLIENT_ID>"
secret = "<NAVER_CLIENT_SECRET>"
[OpenAIConfig]
api_key = "<OPENAI_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

View File

@ -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분)

View File

@ -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 세션 수 임계

View File

@ -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.<APP_ENV>.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)

View File

@ -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`
---

View File

@ -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세트인가 (더 띄우면 안 되나?)

View File

@ -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`)
상한 튜닝의 원천 데이터가 됩니다.
| 컬럼 | 뜻 |

View File

@ -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) → ③ 웜업/차단 로그 확인. 미사용 기간 동안 셀렉터는 낡는다고 가정할 것.

View File

@ -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:<src>` | 소스별 최근 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:<src>` | 소스별 최근 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 조정.

View File

@ -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**(커넥션 풀러) 도입

View File

@ -86,10 +86,10 @@ async def readyz():
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 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 에 키 채움 + 포트 비공개 필수")

View File

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

View File

@ -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 ;;

View File

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

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

View File

@ -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):

View File

@ -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"]

View File

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