From 8fa77af8660da83f8b933e2cdfd06f0d661567da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Tue, 28 Jul 2026 16:39:14 +0900 Subject: [PATCH] =?UTF-8?q?[chore]=20lps:=20=EB=B0=B0=ED=8F=AC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=AC=B8=EC=84=9C=ED=99=94=20+=20=EC=A3=BD?= =?UTF-8?q?=EC=9D=80=20env=20=EC=A0=9C=EA=B1=B0=20+=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EB=AC=B8=EA=B5=AC=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배포서버 쿠팡 크롤 조사(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) --- lps/Dockerfile.worker | 5 +- lps/config/config.local.toml.example | 7 ++- lps/config/config.prod.toml.example | 85 ++++++++++++++++++++++++++++ lps/worker_main.py | 14 ++++- 4 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 lps/config/config.prod.toml.example diff --git a/lps/Dockerfile.worker b/lps/Dockerfile.worker index 795796b..be8a597 100644 --- a/lps/Dockerfile.worker +++ b/lps/Dockerfile.worker @@ -28,9 +28,12 @@ COPY . . # 실값은 compose env 로 주입: DB_*, OPENAI_API_KEY, DECODO_*(포트 포함), NAVER_KEYS. 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 \ PYTHONUNBUFFERED=1 \ - LPS_CHROME_EXECUTABLE=/opt/google/chrome/chrome \ DISPLAY=:99 \ LPS_HEARTBEAT_FILE=/tmp/lps_worker_heartbeat diff --git a/lps/config/config.local.toml.example b/lps/config/config.local.toml.example index f95981d..83b2056 100644 --- a/lps/config/config.local.toml.example +++ b/lps/config/config.local.toml.example @@ -49,7 +49,12 @@ sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"ve [WorkerConfig] concurrency = 1 # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 최대 4×N). 로컬 권장 2~3 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=무제한(테스트용) shutdown_grace_sec = 60 # graceful 종료 유예 — docker stop_grace_period 를 이보다 길게 chrome_channel = "chrome" # 로컬: 실제 Chrome diff --git a/lps/config/config.prod.toml.example b/lps/config/config.prod.toml.example new file mode 100644 index 0000000..4d67570 --- /dev/null +++ b/lps/config/config.prod.toml.example @@ -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 = [""] # ★ 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 = "" +write_pw = "" +read_host = "host.docker.internal" # ★ +read_port = 5432 +read_id = "" +read_pw = "" +show_log = false +pool_size = 10 +max_overflow = 20 +connection_budget = 40 +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 = "" +secret = "" + +[OpenAIConfig] +api_key = "" # 소진되면 크롤이 성공해도 AI 판정 실패로 잡이 DEAD 된다 +model = "gpt-4o-mini" + +[DecodoConfig] +host = "gate.decodo.com" +username = "" +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분) diff --git a/lps/worker_main.py b/lps/worker_main.py index 7af0cb7..aa25f34 100644 --- a/lps/worker_main.py +++ b/lps/worker_main.py @@ -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 await alerts.check("proxy_ports_low", avail * 100 <= total * th.ports_low_pct, 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) snap["block_sessions_6h"] = block_sessions - await alerts.check("budget_leak", block_sessions >= th.block_sessions_6h, - f"예산 회전에도 차단된 IP 세션 6h={block_sessions} — ip_request_budget 하향 검토", snap) + await alerts.check( + "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건(쿼터 소진·셀렉터 드리프트·전면 차단 신호) per_source: dict[str, list[int]] = {} for ad in (adapters or []):