diff --git a/docker-compose.yml b/docker-compose.yml index 0446071..8f2d376 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,3 +112,41 @@ services: options: max-size: "10m" max-file: "5" + + # ── LPS (인터넷 최저가 검색) ────────────────────────────────── + # API(요청 접수, lean) + 워커(크롤, 헤드풀 Chromium+Xvfb). DB 는 외부(host.docker.internal). + lps-api: + build: + context: ./lps + dockerfile: Dockerfile + container_name: lps-api + environment: + APP_ENV: local + DB_HOST: host.docker.internal # 컨테이너→호스트 DB (config.local.toml의 127.0.0.1 override) + PYTHONUNBUFFERED: "1" + ports: + - "9600:9600" + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + logging: + driver: json-file + options: { max-size: "10m", max-file: "5" } + + lps-worker: + build: + context: ./lps + dockerfile: Dockerfile.worker # Chromium + Xvfb (headless 는 안티봇에 탐지됨) + container_name: lps-worker + environment: + APP_ENV: local + DB_HOST: host.docker.internal + PYTHONUNBUFFERED: "1" + WORKER_CONCURRENCY: "1" # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 4×N) + extra_hosts: + - "host.docker.internal:host-gateway" + shm_size: "1gb" # Chrome 는 /dev/shm 을 많이 씀 — 부족하면 탭 크래시 + restart: unless-stopped + logging: + driver: json-file + options: { max-size: "10m", max-file: "5" } diff --git a/lps/Dockerfile b/lps/Dockerfile index 83b9d3f..5cc7118 100644 --- a/lps/Dockerfile +++ b/lps/Dockerfile @@ -1,3 +1,5 @@ +# LPS API 서버 이미지 — 요청 접수/조회만(브라우저 불필요, lean). +# 크롤은 별도 워커 이미지(Dockerfile.worker, Chromium+Xvfb)가 담당한다. FROM python:3.12-slim WORKDIR /app diff --git a/lps/Dockerfile.worker b/lps/Dockerfile.worker new file mode 100644 index 0000000..8bc6edc --- /dev/null +++ b/lps/Dockerfile.worker @@ -0,0 +1,29 @@ +# LPS 워커 이미지 — 크롤용 **헤드풀 Chromium + 가상 디스플레이(Xvfb)**. +# headless Chrome 은 Akamai(쿠팡)·Cloudflare Turnstile(G마켓/옥션)에 탐지돼 통과 못 함(실측 확인). +# → 컨테이너에선 Xvfb(가상 프레임버퍼)로 headful Chromium 을 실행한다. +# +# 빌드/실행: +# docker compose build lps-worker && docker compose up -d lps-worker +# 스텔스 참고: 로컬 Mac 은 실제 Chrome(channel=chrome), 컨테이너는 시스템 chromium(executable_path). +# 프로덕션에서 stealth 가 부족하면 amd64 이미지에 google-chrome-stable 설치 후 LPS_CHROME_CHANNEL=chrome 로 전환. +FROM python:3.12-slim + +# chromium(런타임 의존성 apt 가 자동 해결) + Xvfb + 한글 폰트 +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium xvfb xauth fonts-nanum ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV APP_ENV=local \ + PYTHONUNBUFFERED=1 \ + LPS_CHROME_EXECUTABLE=/usr/bin/chromium \ + DISPLAY=:99 + +# Xvfb(가상 디스플레이)를 백그라운드로 띄우고 python 을 exec 로 승계 실행. +# → 헤드풀 Chromium 이 :99 에 뜨고, 워커 로그는 그대로 docker logs 로 나온다(xvfb-run 은 로그를 삼킴). +CMD ["bash", "-c", "Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp >/dev/null 2>&1 & sleep 1; exec python worker_main.py"] diff --git a/lps/services/search/browser_base.py b/lps/services/search/browser_base.py index 4124c84..f2842b5 100644 --- a/lps/services/search/browser_base.py +++ b/lps/services/search/browser_base.py @@ -14,6 +14,7 @@ detect_block 은 순수 함수로 분리 — 브라우저 없이 단위 테스 """ import asyncio +import os import time from abc import abstractmethod @@ -27,6 +28,11 @@ 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). +# headless 는 안티봇에 탐지되므로 서버에선 Xvfb(가상 디스플레이)로 headful 실행한다(headless 실측 실패). +_CHROME_CHANNEL = os.environ.get("LPS_CHROME_CHANNEL", "chrome") +_CHROME_EXECUTABLE = os.environ.get("LPS_CHROME_EXECUTABLE") or None + def detect_block(html: str, product_count: int, markers: tuple, min_len: int) -> str | None: """0건 응답의 차단 여부 판정(순수 함수). 반환: 차단 마커(차단) 또는 None(정상 빈결과). @@ -142,7 +148,13 @@ class BrowserSearchAdapter(SearchAdapter): return if self._pw is None: self._pw = await async_playwright().start() - kwargs = dict(user_data_dir=self._user_data_dir, channel="chrome", headless=self._headless, no_viewport=True) + kwargs = dict(user_data_dir=self._user_data_dir, headless=self._headless, no_viewport=True) + if _CHROME_EXECUTABLE: + kwargs["executable_path"] = _CHROME_EXECUTABLE # 컨테이너: 시스템 chromium + # 컨테이너(root)에선 sandbox 불가 → --no-sandbox 필수(없으면 런칭 행). /dev/shm 부족 크래시 방지. + kwargs["args"] = ["--no-sandbox", "--disable-dev-shm-usage"] + else: + kwargs["channel"] = _CHROME_CHANNEL # 로컬: 실제 Chrome if self._proxy and self._proxy.enabled: kwargs["proxy"] = self._proxy.playwright_proxy() self._current_port = self._proxy.current_port