From a267326bf7617c1d4bb394e84f0afecb98483e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Fri, 10 Jul 2026 11:53:47 +0900 Subject: [PATCH] =?UTF-8?q?fix(lps):=20Docker=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=AC=B4=EC=8B=9C=ED=81=AC=EB=A6=BF=ED=99=94=20+?= =?UTF-8?q?=20=EB=B0=B0=ED=8F=AC=20=EA=B2=AC=EA=B3=A0=ED=99=94=20=E2=80=94?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EB=B0=9C=EA=B2=AC=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시크릿 유출 차단(핵심): - config.local.toml(OpenAI·DECODO·네이버 키)이 COPY . . 로 이미지에 구워지던 문제 — .dockerignore 제외 + 빌드 시 example(플레이스홀더) 복사로 대체. 실값은 compose env 주입(리포 루트 .env, 템플릿 .env.example) - DECODO_PORT_START/END/SESSION_MINUTES env override 추가 — 포트가 toml(플레이스홀더 0)에만 있으면 자격증명을 넣어도 프록시가 조용히 꺼지는 구멍 봉합 배포 견고화: - API Dockerfile 에 HEALTHCHECK(/healthz) 추가 - autoheal 컨테이너 추가 — compose restart 는 unhealthy 를 재시작하지 않으므로 라벨(autoheal=true) 기반 자동 재시작 담당 - 이미지 python 3.12→3.14 정렬(로컬 개발·테스트 환경과 일치) - API 이미지 경량화: requirements-api.txt 분리(크롤 의존성 제거, 330MB) 검증: 양 이미지 빌드 성공, 이미지 내 시크릿·.profiles 부재 확인, 무시크릿 API 이미지 스모크(healthz/readyz/HEALTHCHECK healthy) 통과 Co-Authored-By: Claude Fable 5 --- .env.example | 17 ++++++++++++++ docker-compose.yml | 43 ++++++++++++++++++++++++++++++------ lps/.dockerignore | 3 +++ lps/Dockerfile | 19 +++++++++++----- lps/Dockerfile.worker | 6 ++++- lps/config/server_configs.py | 6 +++++ lps/docs/operations.md | 14 +++++++++--- lps/requirements-api.txt | 11 +++++++++ 8 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 .env.example create mode 100644 lps/requirements-api.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e83bae --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# docker compose 용 환경변수 템플릿 — 복사해서 사용: cp .env.example .env +# 실제 값(.env)은 커밋하지 않는다(.gitignore). 이미지에는 시크릿이 없으므로(lps 는 example +# config 로 빌드) 아래 값이 없으면 해당 기능이 꺼진 채 뜬다(주석 참고). + +# ── LPS DB (미설정 시 postgres/postgres) ── +LPS_DB_USER=postgres +LPS_DB_PASSWORD=postgres + +# ── 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) diff --git a/docker-compose.yml b/docker-compose.yml index bde179c..dc66df7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -115,6 +115,8 @@ services: # ── LPS (인터넷 최저가 검색) ────────────────────────────────── # API(요청 접수, lean) + 워커(크롤, 헤드풀 Chromium+Xvfb). DB 는 외부(host.docker.internal). + # 이미지엔 시크릿이 없다(example config 로 빌드) — 실값은 아래 env 로 주입. + # 시크릿 값은 리포 루트 .env 파일에 채운다(.env.example 참고, .env 는 미커밋). lps-api: build: context: ./lps @@ -122,7 +124,9 @@ services: container_name: lps-api environment: APP_ENV: local - DB_HOST: host.docker.internal # 컨테이너→호스트 DB (config.local.toml의 127.0.0.1 override) + 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} PYTHONUNBUFFERED: "1" # 멀티코어: PROCESS_COUNT(uvicorn 워커=코어수)를 올리면 커넥션 풀은 자동 산정된다. # (pool+overflow)×2엔진×PROCESS_COUNT ≤ DB_CONNECTION_BUDGET 를 config 가 스스로 보장. @@ -132,6 +136,8 @@ services: - "9600:9600" extra_hosts: - "host.docker.internal:host-gateway" + labels: + autoheal: "true" # HEALTHCHECK 실패 시 autoheal 이 재시작 restart: unless-stopped logging: driver: json-file @@ -145,22 +151,45 @@ services: environment: APP_ENV: local DB_HOST: host.docker.internal + DB_USER: ${LPS_DB_USER:-postgres} + DB_PASSWORD: ${LPS_DB_PASSWORD:-postgres} PYTHONUNBUFFERED: "1" WORKER_CONCURRENCY: "1" # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 4×N) LPS_PROFILE_DIR: /profiles # Chrome 프로필을 영속 볼륨에 → 재시작해도 cf_clearance 유지(재웜업 회피) # LPS_FALLBACKS: "gmarket,auction,st11" # 오픈마켓 폴백(기본 OFF — 켜기 전 라이브 스모크로 셀렉터 점검) - # ── 프로덕션 시크릿 주입(이미지에 안 굽고 env 로). 아래 주석 해제 후 값 채우거나 secrets 매니저 연동 ── - # OPENAI_API_KEY: ${OPENAI_API_KEY} - # DECODO_USERNAME: ${DECODO_USERNAME} - # DECODO_PASSWORD: ${DECODO_PASSWORD} - # DECODO_HOST: ${DECODO_HOST} - # NAVER_KEYS: ${NAVER_KEYS} # "id1:secret1,id2:secret2" + # LPS_JOB_DEADLINE_SEC: "300" # 잡 1건 처리 상한(행 방어) — 기본 300s + # ── 시크릿 주입(이미지엔 없음 — 필수). 리포 루트 .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 프로필(쿠키) 영속 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 + labels: + autoheal: "true" # 하트비트 HEALTHCHECK 실패(행/좀비) 시 autoheal 이 재시작 + restart: unless-stopped + logging: + driver: json-file + options: { max-size: "10m", max-file: "5" } + + # HEALTHCHECK 실패 컨테이너 자동 재시작 — compose 의 restart 는 '프로세스 종료'만 다루고 + # unhealthy 는 표시만 하므로, autoheal 라벨 붙은 컨테이너(lps-api/lps-worker)를 감시해 재시작한다. + # docker.sock 마운트 = 도커 제어 권한이므로 신뢰 환경에서만 사용. + autoheal: + image: willfarrell/autoheal:latest + container_name: autoheal + environment: + AUTOHEAL_CONTAINER_LABEL: autoheal + volumes: + - /var/run/docker.sock:/var/run/docker.sock restart: unless-stopped logging: driver: json-file diff --git a/lps/.dockerignore b/lps/.dockerignore index 23930c7..c1ff510 100644 --- a/lps/.dockerignore +++ b/lps/.dockerignore @@ -10,3 +10,6 @@ loadtest/ # Chrome 프로필(쿠키·cf_clearance 세션) — 이미지에 구우면 세션 유출 + 149MB 비대. # 컨테이너는 빈 프로필로 시작해 웜업으로 쿠키를 만들고 volume(/profiles)에 영속한다. .profiles/ +# 시크릿(OpenAI·DECODO·네이버 키) — 이미지에 굽지 않는다. Dockerfile 이 example 을 +# 복사해 넣고, 실값은 compose env 로 주입(server_configs 의 env override). +config/config.local.toml diff --git a/lps/Dockerfile b/lps/Dockerfile index 5cc7118..8aee42e 100644 --- a/lps/Dockerfile +++ b/lps/Dockerfile @@ -1,18 +1,27 @@ # LPS API 서버 이미지 — 요청 접수/조회만(브라우저 불필요, lean). # 크롤은 별도 워커 이미지(Dockerfile.worker, Chromium+Xvfb)가 담당한다. -FROM python:3.12-slim +# 시크릿은 이미지에 굽지 않는다 — config 는 example(플레이스홀더)로 대체되고, +# 실제 값은 compose 의 env(DB_USER/DB_PASSWORD 등)로 주입된다(server_configs override). +FROM python:3.14-slim WORKDIR /app -# 의존성 먼저 설치 (레이어 캐시 활용) -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +# 의존성 먼저 설치 (레이어 캐시 활용) — API 전용 경량 세트 +COPY requirements-api.txt . +RUN pip install --no-cache-dir -r requirements-api.txt COPY . . -# 항상 APP_ENV=local 로 실행 → config.local.toml 사용. +# 시크릿 든 config.local.toml 은 .dockerignore 로 제외됨 → example(플레이스홀더)로 대체. +# 실값은 env 주입: DB_HOST/DB_USER/DB_PASSWORD/DB_NAME, PROCESS_COUNT, DB_CONNECTION_BUDGET … +RUN cp config/config.local.toml.example config/config.local.toml + +# 항상 APP_ENV=local 로 실행 → config.local.toml(=example 사본) + env override. ENV APP_ENV=local EXPOSE 9600 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:9600/healthz', timeout=4).status==200 else 1)" + CMD ["python", "web_main.py"] diff --git a/lps/Dockerfile.worker b/lps/Dockerfile.worker index 7082d4a..b8813e6 100644 --- a/lps/Dockerfile.worker +++ b/lps/Dockerfile.worker @@ -6,7 +6,7 @@ # 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 +FROM python:3.14-slim # chromium(런타임 의존성 apt 가 자동 해결) + Xvfb + 한글 폰트 RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -19,6 +19,10 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . +# 시크릿 든 config.local.toml 은 .dockerignore 로 제외됨 → example(플레이스홀더)로 대체. +# 실값은 compose env 로 주입: DB_*, OPENAI_API_KEY, DECODO_*(포트 포함), NAVER_KEYS. +RUN cp config/config.local.toml.example config/config.local.toml + ENV APP_ENV=local \ PYTHONUNBUFFERED=1 \ LPS_CHROME_EXECUTABLE=/usr/bin/chromium \ diff --git a/lps/config/server_configs.py b/lps/config/server_configs.py index 7e74058..2e31bb7 100644 --- a/lps/config/server_configs.py +++ b/lps/config/server_configs.py @@ -85,6 +85,12 @@ def _apply_secret_env_override(): 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" 형식으로 키 로테이션 주입 diff --git a/lps/docs/operations.md b/lps/docs/operations.md index 4f43fb0..d97104e 100644 --- a/lps/docs/operations.md +++ b/lps/docs/operations.md @@ -18,7 +18,10 @@ cp config/config.local.toml.example config/config.local.toml | `[OpenAIConfig]` | `api_key` (AI 판정·검색어 생성용) | | `[DecodoConfig]` | 프록시 정보(비워두면 프록시 미사용) | -> **한 파일에 설정+시크릿 통합** 관리. 배포 시엔 이 파일을 마운트하거나, 환경별로 바뀌는 값(DB_HOST 등)만 환경변수로 덮어씁니다. +> **한 파일에 설정+시크릿 통합** 관리(로컬). **Docker 이미지에는 이 파일이 들어가지 않는다** — +> 빌드 시 `.dockerignore` 로 제외되고 example(플레이스홀더)이 대신 들어가며, 실값은 compose 의 +> env 로 주입한다(리포 루트 `.env`, 템플릿 `.env.example`). `server_configs` 의 env override 가 +> DB 접속·`OPENAI_API_KEY`·`DECODO_*`(포트 포함)·`NAVER_KEYS` 를 모두 덮는다. **DB 준비**: `lps_db` 생성 후 최초 실행 시 테이블 자동 생성. ```bash @@ -180,9 +183,14 @@ docker ps # lps-worker "(healthy)" 확인 ``` - **워커 = 헤드풀 Chromium + Xvfb**(`Dockerfile.worker`): **headless 는 Akamai·Cloudflare Turnstile 에 탐지됨**(실측). Xvfb 가상 디스플레이로 headful 실행. - **API = lean**(`Dockerfile`, 브라우저 불필요). -- **시크릿은 이미지에 안 굽고 env 주입**: `OPENAI_API_KEY`·`DECODO_*`·`NAVER_KEYS` (compose 주석 참고). 로컬은 config.local.toml. +- **시크릿은 이미지에 없음(강제)**: 이미지는 example config 로 빌드된다(`.dockerignore` 가 + config.local.toml·`.profiles/` 제외). 실값은 **리포 루트 `.env`**(템플릿 `.env.example`)에서 + compose env 로 주입. `.env` 없이 뜨면 AI OFF·프록시 미사용으로 조용히 동작하니, 기동 로그의 + `AI: ON/OFF`·`DECODO 프록시: ON/OFF` 로 주입 성공을 반드시 확인할 것. - **Chrome 프로필 영속 볼륨**(`lps-profiles:/profiles`, `LPS_PROFILE_DIR`): 재시작해도 cf_clearance 유지 → 재웜업 회피. -- **워커 헬스**: HEALTHCHECK(하트비트<120s)로 행 워커 감지. k8s 는 liveness probe 로 자동 재시작 연결. +- **워커 헬스**: HEALTHCHECK(하트비트<120s)로 행 워커 감지. compose 의 `restart` 는 unhealthy 를 + 재시작하지 않으므로 **autoheal 컨테이너**(라벨 `autoheal=true` 감시)가 재시작 담당. k8s 는 liveness probe 로 대체. +- **잡 데드라인**: 잡 1건 300s 상한(`LPS_JOB_DEADLINE_SEC`) — 크롤 행이 워커 슬롯을 영구 점유하지 못하게 함. **남은 배포 과제**: API 인증·레이트리밋(비용 남용 방지), 다중 레플리카 시 분산 레이트리밋/프록시 IP 조정. **비용**: 대역폭이 원가의 대부분(오픈마켓 크롤) — 같은 상품 재크롤을 줄이는 **TTL 캐시**가 다음 절감 후보. diff --git a/lps/requirements-api.txt b/lps/requirements-api.txt new file mode 100644 index 0000000..82caf9d --- /dev/null +++ b/lps/requirements-api.txt @@ -0,0 +1,11 @@ +# LPS API 서버 전용 의존성 — 요청 접수/조회만(크롤 없음). +# 크롤 의존성(patchright·curl_cffi·selectolax·openai)은 워커 전용(requirements.txt). +# web_main import 체인 정적 추적으로 확인(2026-07-10): fastapi/uvicorn/sqlalchemy/pydantic 만 필요. +fastapi +uvicorn[standard] +sqlalchemy>=2.0 +greenlet # SQLAlchemy async 의 sync/async 브리지에 필수 +asyncpg +orjson +pydantic>=2.0 +httpx # (얇음) 향후 API→워커 헬스 프록시 등 대비 유지