feat(lps): P1 컨테이너화 — 워커 이미지(Chromium+Xvfb) + compose
프로덕션 배포 토대. headless Chrome 은 Akamai/Cloudflare Turnstile 에 탐지됨(실측 실패) → 컨테이너에선 Xvfb(가상 디스플레이)로 headful Chromium 을 실행한다. - Dockerfile.worker: chromium+xvfb+xauth+한글폰트. Xvfb 를 백그라운드로 띄우고 python 을 exec 승계(로그가 docker logs 로 나옴; xvfb-run 은 stdout 을 삼킴). root=--no-sandbox 필수. - Dockerfile: API 전용(lean, 브라우저 불필요)임을 명시. - docker-compose: lps-api(:9600) + lps-worker(shm 1g, DB=host.docker.internal) 서비스 추가. - browser_base: 브라우저 실행 대상 env 화(LPS_CHROME_EXECUTABLE/CHANNEL). 컨테이너=시스템 chromium(+no-sandbox/disable-dev-shm-usage), 로컬=실제 Chrome(channel=chrome). 검증(컨테이너): DB연결·프리플라이트·쿠팡(Akamai)·gmarket(Turnstile) 웜업 통과 → end-to-end 검색 DONE(8몰: 네이버·쿠팡·G마켓·옥션·11번가, 비용 $0.016). docker logs 로 로그 확인. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e2cd2570e3
commit
94d4e54d5e
@ -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" }
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# LPS API 서버 이미지 — 요청 접수/조회만(브라우저 불필요, lean).
|
||||
# 크롤은 별도 워커 이미지(Dockerfile.worker, Chromium+Xvfb)가 담당한다.
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
29
lps/Dockerfile.worker
Normal file
29
lps/Dockerfile.worker
Normal file
@ -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"]
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user