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:
parent
993301be8b
commit
c81df5bd88
24
.env.example
24
.env.example
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -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 검증(개발은 빈값=개방 모드) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
80
lps/config/config.docker.toml.example
Normal file
80
lps/config/config.docker.toml.example
Normal 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
|
||||
@ -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분)
|
||||
|
||||
@ -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 세션 수 임계
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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`
|
||||
|
||||
---
|
||||
|
||||
@ -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세트인가 (더 띄우면 안 되나?)
|
||||
|
||||
@ -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`)
|
||||
상한 튜닝의 원천 데이터가 됩니다.
|
||||
|
||||
| 컬럼 | 뜻 |
|
||||
|
||||
@ -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) → ③ 웜업/차단 로그 확인. 미사용 기간 동안 셀렉터는 낡는다고 가정할 것.
|
||||
|
||||
@ -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 조정.
|
||||
|
||||
@ -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**(커넥션 풀러) 도입
|
||||
|
||||
@ -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 에 키 채움 + 포트 비공개 필수")
|
||||
|
||||
@ -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")):
|
||||
|
||||
@ -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 ;;
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user