[chore] lps: 배포 설정 문서화 + 죽은 env 제거 + 알림 문구 정정

배포서버 쿠팡 크롤 조사(2026-07-28)에서 드러난 '설정한 줄 알았는데 아니었던' 것들 정리.
크롤 동작 자체를 바꾸는 변경은 없다.

- Dockerfile.worker: ENV LPS_CHROME_EXECUTABLE 제거.
  읽는 코드가 없는데 값이 박혀 있어 "컨테이너 Chrome 경로가 설정돼 있다"는 오해를 만들었다.
  경로의 유일한 소스는 [WorkerConfig].chrome_executable 이다(설정 시 --no-sandbox 동반).

- config.local.toml.example: profile_dir 경고 추가.
  컨테이너에서 ".profiles" 로 두면 /app/.profiles(컨테이너 레이어)에 쌓여 재생성마다 쿠키가
  날아가고, compose 가 마운트한 lps-profiles 볼륨은 붙어만 있고 아무 일도 하지 않는다.
  로컬·배포서버 양쪽에서 실측(/profiles 는 7/9·7/16 잔재, /app/.profiles 에 9MB 최신).

- config.prod.toml.example 신규: 배포서버 설정 템플릿.
  로컬과 다른 값만 ★ 표시. 헤더에 로드 경로의 함정을 명시했다 —
  docker-compose.prod.yml 이 이 파일을 config.local.toml 자리에 마운트하므로
  APP_ENV=local 인데도 내용은 prod 설정이다(파일명만 보면 오해한다).

- worker_main.py: budget_leak 알림이 "ip_request_budget 하향 검토"를 단정하던 것을 수정.
  차단이 ip_req#1 에 몰리면 새 IP 첫 요청부터 막히는 것이라 예산과 무관하다.
  bot_detection.ip_request_no 분포를 보고 처방을 고르도록 문구를 바꿨다.

⚠️ Dockerfile.worker 가 바뀌었으므로 배포 시 lps-worker 재빌드 필요.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-28 16:39:14 +09:00
parent 89652fac1f
commit 8fa77af866
4 changed files with 106 additions and 5 deletions

View File

@ -28,9 +28,12 @@ COPY . .
# 실값은 compose env 로 주입: DB_*, OPENAI_API_KEY, DECODO_*(포트 포함), NAVER_KEYS. # 실값은 compose env 로 주입: DB_*, OPENAI_API_KEY, DECODO_*(포트 포함), NAVER_KEYS.
RUN cp config/config.local.toml.example config/config.local.toml RUN cp config/config.local.toml.example config/config.local.toml
# Chrome 경로는 [WorkerConfig].chrome_executable(설정 파일)이 유일한 소스다.
# 예전에 LPS_CHROME_EXECUTABLE env 를 뒀지만 읽는 코드가 없어 '설정한 줄 알았는데 아니었다'는
# 오진을 만들었다(2026-07-28 배포서버 크롤 조사) — 죽은 env 는 두지 않는다.
# 컨테이너에서는 config 에 "/opt/google/chrome/chrome" 을 넣어야 --no-sandbox 가 함께 붙는다.
ENV APP_ENV=local \ ENV APP_ENV=local \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
LPS_CHROME_EXECUTABLE=/opt/google/chrome/chrome \
DISPLAY=:99 \ DISPLAY=:99 \
LPS_HEARTBEAT_FILE=/tmp/lps_worker_heartbeat LPS_HEARTBEAT_FILE=/tmp/lps_worker_heartbeat

View File

@ -49,7 +49,12 @@ sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"ve
[WorkerConfig] [WorkerConfig]
concurrency = 1 # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 최대 4×N). 로컬 권장 2~3 concurrency = 1 # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 최대 4×N). 로컬 권장 2~3
fallbacks = [] # 오픈마켓 폴백(기본 OFF). 예: ["gmarket", "auction", "st11"] — 켜기 전 라이브 스모크 fallbacks = [] # 오픈마켓 폴백(기본 OFF). 예: ["gmarket", "auction", "st11"] — 켜기 전 라이브 스모크
profile_dir = ".profiles" # Chrome 프로필 베이스. 영속 경로면 재시작에도 cf_clearance 유지(재웜업 회피) # Chrome 프로필 베이스. 영속 경로면 재시작에도 쿠키 유지(재웜업 회피).
# ⚠️ 컨테이너로 띄우면 반드시 "/profiles" — compose 가 lps-profiles 볼륨을 그 경로에 마운트한다.
# ".profiles" 로 두면 /app/.profiles(컨테이너 레이어)에 쌓여 재생성 때마다 쿠키가 전부 날아가고,
# 볼륨은 붙어만 있고 아무 일도 하지 않는다(2026-07-28 로컬·배포서버 양쪽에서 실측).
# 호스트 직접 실행(run_local_worker.sh)일 때만 ".profiles" 를 쓴다.
profile_dir = ".profiles"
job_deadline_sec = 300 # 잡 1건 처리 상한(크롤 행 방어). 0=무제한(테스트용) job_deadline_sec = 300 # 잡 1건 처리 상한(크롤 행 방어). 0=무제한(테스트용)
shutdown_grace_sec = 60 # graceful 종료 유예 — docker stop_grace_period 를 이보다 길게 shutdown_grace_sec = 60 # graceful 종료 유예 — docker stop_grace_period 를 이보다 길게
chrome_channel = "chrome" # 로컬: 실제 Chrome chrome_channel = "chrome" # 로컬: 실제 Chrome

View File

@ -0,0 +1,85 @@
# 배포서버용. 복사해서 사용: cp config.prod.toml.example config.prod.toml
# 실제 config.prod.toml 은 시크릿 포함이라 커밋하지 않는다.
#
# ⚠️ 로드 경로 주의 — docker-compose.prod.yml 이 이 파일을 config.local.toml 자리에 마운트한다:
# ./lps/config/config.prod.toml : /app/config/config.local.toml : ro
# 컨테이너는 APP_ENV=local 로 뜨므로 코드가 읽는 파일명은 config.local.toml 이지만
# 내용은 이 파일이다. 파일명만 보고 "prod 설정이 안 읽힌다"고 오해하기 쉽다.
#
# 로컬(config.local.toml.example)과 다른 항목만 ★ 로 표시했다.
[WebServerConfig]
server_name = "LpsServer"
port = 9600
process_count = 1
is_ssl = false # 리버스프록시 뒤 — TLS 는 프록시가 종단
is_test = false # ★ prod
cors_origins = [] # ★ 브라우저가 직접 호출하지 않으면 비움
api_keys = ["<API_KEY>"] # ★ prod 는 반드시 채운다(비면 무인증 개방). openssl rand -hex 32
[LogConfig]
print_console = true
log_level = "info" # ★ prod
[MainDBConfig]
db_type = "postgresql"
name = "lps_db"
write_host = "host.docker.internal" # ★ 컨테이너 → 호스트 DB (compose extra_hosts)
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
sslmode = "" # 관리형 DB 면 "require"
[WorkerConfig]
concurrency = 1 # ★ 동시성을 올리면 차단 시 IP 소모도 비례해 늘어난다
fallbacks = []
profile_dir = "/profiles" # ★ 필수 — compose 가 lps-profiles 볼륨을 여기에 마운트한다.
# ".profiles" 로 두면 컨테이너 레이어에 쌓여 재생성마다 쿠키가 날아간다.
job_deadline_sec = 300
shutdown_grace_sec = 60
chrome_channel = "chrome"
chrome_executable = "/opt/google/chrome/chrome" # ★ 컨테이너 필수(설정 시 --no-sandbox 가 함께 붙음)
heartbeat_file = "/tmp/lps_worker_heartbeat"
[AlertConfig]
webhook = "" # ★ Slack 호환 웹훅. 비우면 로그로만 알림
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 판정 실패로 잡이 DEAD 된다
model = "gpt-4o-mini"
[DecodoConfig]
host = "gate.decodo.com"
username = "<DECODO_USERNAME>"
password = "<DECODO_PASSWORD>"
port_start = 10001
port_end = 10100
session_minutes = 10
cost_per_gb = 3.0
ip_request_budget = 3 # IP당 요청 예산. 차단이 ip_req#1 에 몰리면 이 값과 무관한 문제다
port_cooldown_sec = 0 # 0=자동 max(sticky, 30분)

View File

@ -164,11 +164,19 @@ async def run_ops_monitor(queue, bot_log, stop, interval: float = 30.0, adapters
snap["proxy_ports_avail"], snap["proxy_ports_total"] = avail, total snap["proxy_ports_avail"], snap["proxy_ports_total"] = avail, total
await alerts.check("proxy_ports_low", avail * 100 <= total * th.ports_low_pct, await alerts.check("proxy_ports_low", avail * 100 <= total * th.ports_low_pct,
f"가용 프록시 포트 {avail}/{total} — 대규모 차단 진행 신호", snap) f"가용 프록시 포트 {avail}/{total} — 대규모 차단 진행 신호", snap)
# 예산 누수 — 요청 예산을 지켰는데도 차단된 IP 세션 발생 = 현재 예산이 안전하지 않다는 신호. # 예산 누수 — 요청 예산을 지켰는데도 차단된 IP 세션 발생.
# 처방은 '몇 번째 요청에서 막혔나'로 갈린다:
# ip_req#1 위주 → 새 IP 첫 요청부터 차단 = IP 평판 문제. 예산을 낮춰도 소용없다.
# ip_req#2~ 위주 → 같은 IP 로 너무 많이 긁은 것 = 예산 하향이 유효.
# 예산 하향만 권하면 오진을 부른다(2026-07-28 배포서버 조사에서 전량 ip_req#1 이었다).
block_sessions = (await ip_log.recent_stats(360)).get("block", 0) block_sessions = (await ip_log.recent_stats(360)).get("block", 0)
snap["block_sessions_6h"] = block_sessions snap["block_sessions_6h"] = block_sessions
await alerts.check("budget_leak", block_sessions >= th.block_sessions_6h, await alerts.check(
f"예산 회전에도 차단된 IP 세션 6h={block_sessions} — ip_request_budget 하향 검토", snap) "budget_leak", block_sessions >= th.block_sessions_6h,
f"예산 회전에도 차단된 IP 세션 6h={block_sessions} — "
f"bot_detection.ip_request_no 분포 확인(1 위주면 IP 평판/프록시 대역, 2 이상이면 ip_request_budget 하향)",
snap,
)
# 소스별 장기 실패 — 최근 30분간 시도는 있는데 성공이 0건(쿼터 소진·셀렉터 드리프트·전면 차단 신호) # 소스별 장기 실패 — 최근 30분간 시도는 있는데 성공이 0건(쿼터 소진·셀렉터 드리프트·전면 차단 신호)
per_source: dict[str, list[int]] = {} per_source: dict[str, list[int]] = {}
for ad in (adapters or []): for ad in (adapters or []):